From 26501fb84a70bced3e798f3115ee301e1662d2ea Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 24 May 2026 14:01:39 +0800 Subject: [PATCH] fix(panels): keep image fill popover interactive --- crates/op-editor-core/src/editor_ui_state.rs | 85 +--- crates/op-editor-core/src/fills.rs | 119 ++++- crates/op-editor-core/src/lib.rs | 13 +- crates/op-editor-core/src/mutators.rs | 40 ++ .../src/property_panel_state.rs | 159 ++++++ crates/op-editor-core/src/tests_mutators.rs | 70 +++ crates/op-editor-ui/src/widgets/mod.rs | 2 + .../src/widgets/property_panel.rs | 385 +++----------- .../src/widgets/property_panel_action.rs | 17 +- .../src/widgets/property_panel_fill.rs | 10 +- .../src/widgets/property_panel_image_fill.rs | 479 ++++++++++++++++++ .../src/widgets/property_panel_layout.rs | 6 +- .../src/widgets/property_panel_sections.rs | 4 + .../src/widgets/property_panel_snapshot.rs | 328 ++++++++++++ .../src/widgets/property_panel_tests.rs | 178 ++++++- crates/op-host-native/src/widget_host.rs | 4 + .../op-host-native/src/widget_host/input.rs | 26 + .../src/widget_host/input_tests.rs | 55 ++ .../src/widget_host/keyboard.rs | 5 + .../op-host-native/src/widget_host/paint.rs | 61 ++- .../op-host-native/src/widget_host/press.rs | 7 + .../src/widget_host/property_dispatch.rs | 65 ++- crates/op-host-native/tests/paint_order.rs | 41 ++ crates/op-host-web/src/widget_host.rs | 37 +- .../op-host-web/src/widget_host/keyboard.rs | 5 + crates/op-host-web/src/widget_host/paint.rs | 47 +- crates/op-host-web/src/widget_host/press.rs | 23 + .../src/widget_host/property_dispatch.rs | 35 +- crates/op-host-web/tests/paint_order.rs | 41 ++ 29 files changed, 1885 insertions(+), 462 deletions(-) create mode 100644 crates/op-editor-core/src/property_panel_state.rs create mode 100644 crates/op-editor-ui/src/widgets/property_panel_image_fill.rs create mode 100644 crates/op-editor-ui/src/widgets/property_panel_snapshot.rs create mode 100644 crates/op-host-native/tests/paint_order.rs create mode 100644 crates/op-host-web/tests/paint_order.rs diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index d50cd496c..002f76695 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -49,85 +49,9 @@ impl ThemeMode { } } -/// Which PropertyPanel tab is active — toggled by `Cmd+Shift+C`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PropertyTab { - Design, - Code, -} - -/// Variants the Fill section's type-selector pill exposes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FillType { - Solid, - LinearGradient, - RadialGradient, - Image, -} - -/// Three flex-layout modes the property panel exposes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FlexLayout { - Free, - Vertical, - Horizontal, -} - -/// Path boolean ops — TS parity with Paper.js (Ctrl+Alt+U/S/I/X). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BooleanOp { - Union, - Subtract, - Intersect, - Exclude, -} - -/// Raster export format. State enum ported from shell-core's -/// `widgets/export_dialog::ExportFormat` (the widget render code stays -/// in shell-core). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExportFormat { - Png, - Jpeg, - Webp, - Svg, - Pdf, -} - -impl ExportFormat { - pub const ALL: [ExportFormat; 5] = [ - ExportFormat::Png, - ExportFormat::Jpeg, - ExportFormat::Webp, - ExportFormat::Svg, - ExportFormat::Pdf, - ]; - - pub fn label(self) -> &'static str { - match self { - ExportFormat::Png => "PNG", - ExportFormat::Jpeg => "JPEG", - ExportFormat::Webp => "WEBP", - ExportFormat::Svg => "SVG", - ExportFormat::Pdf => "PDF", - } - } - - pub fn extension(self) -> &'static str { - match self { - ExportFormat::Png => "png", - ExportFormat::Jpeg => "jpg", - ExportFormat::Webp => "webp", - ExportFormat::Svg => "svg", - ExportFormat::Pdf => "pdf", - } - } - - /// Whether the format has a working export backend. - pub fn is_implemented(self) -> bool { - true - } -} +pub use crate::property_panel_state::{ + BooleanOp, ExportFormat, FillType, FlexLayout, ImageAdjustmentField, ImageFillMode, PropertyTab, +}; /// File-menu choices. State enum ported from shell-core's /// `widgets/file_menu::FileMenuChoice`. @@ -608,6 +532,8 @@ pub struct EditorUiState { pub size_clip_content: bool, /// Whether the fill-type dropdown is open. pub fill_type_picker_open: bool, + /// Whether the image-fill editor popover is open. + pub image_fill_popover_open: bool, /// Active-theme axis whose value picker is open; `None` = closed. pub axis_dropdown_open: Option, /// Editor focus for a non-color variable row (Number / String). @@ -751,6 +677,7 @@ impl Default for EditorUiState { size_hug_height: false, size_clip_content: false, fill_type_picker_open: false, + image_fill_popover_open: false, axis_dropdown_open: None, variable_row_focus: None, effect_param_focus: None, diff --git a/crates/op-editor-core/src/fills.rs b/crates/op-editor-core/src/fills.rs index ab8f877d5..1b2e626ff 100644 --- a/crates/op-editor-core/src/fills.rs +++ b/crates/op-editor-core/src/fills.rs @@ -23,13 +23,51 @@ //! ever touches the *first solid* entry, mirroring shell-core's //! single-colour behaviour without flattening the canonical model. -use crate::editor_ui_state::FillType; +use crate::editor_ui_state::{FillType, ImageAdjustmentField, ImageFillMode}; use jian_ops_schema::node::PenNode; use jian_ops_schema::style::{ GradientStop, ImageFillBody, LinearGradientBody, PenEffect, PenFill, PenStroke, RadialGradientBody, ShadowBody, SolidFillBody, StrokeThickness, }; +/// Display/edit summary of the primary image fill. +#[derive(Debug, Clone, PartialEq)] +pub struct ImageFillSummary { + pub mode: ImageFillMode, + pub has_image: bool, + /// Current primary image URL. Native picked images are stored as + /// inline `data:image/...;base64,...` URLs so the property popover + /// can decode and preview them without another host-side fetch. + pub image_url: Option, + pub exposure: f32, + pub contrast: f32, + pub saturation: f32, + pub temperature: f32, + pub tint: f32, + pub highlights: f32, + pub shadows: f32, +} + +impl ImageFillSummary { + pub fn adjustment(&self, field: ImageAdjustmentField) -> f32 { + match field { + ImageAdjustmentField::Exposure => self.exposure, + ImageAdjustmentField::Contrast => self.contrast, + ImageAdjustmentField::Saturation => self.saturation, + ImageAdjustmentField::Temperature => self.temperature, + ImageAdjustmentField::Tint => self.tint, + ImageAdjustmentField::Highlights => self.highlights, + ImageAdjustmentField::Shadows => self.shadows, + } + } + + pub fn has_adjustments(&self) -> bool { + ImageAdjustmentField::ALL + .iter() + .any(|field| self.adjustment(*field).abs() > f32::EPSILON) + } +} + /// Borrow a node's `fill` list, if the variant carries one. Frame / /// Group fills live on `container.fill`; the leaf paintable variants /// (Rectangle / Ellipse / Polygon / Path / Text / TextInput / @@ -196,6 +234,85 @@ pub fn first_solid_fill_opacity(node: &PenNode) -> f32 { .unwrap_or(1.0) } +/// Summary of the node's primary image fill. `None` when the first +/// fill isn't `Image`. +pub fn first_image_fill_summary(node: &PenNode) -> Option { + let PenFill::Image(body) = node_fills(node)?.first()? else { + return None; + }; + let trimmed_url = body.url.trim(); + Some(ImageFillSummary { + mode: ImageFillMode::from_schema(body.mode.as_ref()), + has_image: !trimmed_url.is_empty(), + image_url: (!trimmed_url.is_empty()).then(|| body.url.clone()), + exposure: body.exposure.unwrap_or(0.0), + contrast: body.contrast.unwrap_or(0.0), + saturation: body.saturation.unwrap_or(0.0), + temperature: body.temperature.unwrap_or(0.0), + tint: body.tint.unwrap_or(0.0), + highlights: body.highlights.unwrap_or(0.0), + shadows: body.shadows.unwrap_or(0.0), + }) +} + +fn primary_image_fill_mut(node: &mut PenNode) -> Option<&mut ImageFillBody> { + if node_fills(node).map(|f| f.is_empty()).unwrap_or(true) { + return None; + } + let fills = node_fills_mut(node)?; + match fills.first_mut()? { + PenFill::Image(body) => Some(body), + _ => None, + } +} + +/// Set the primary image fill's fit mode. +pub fn set_primary_image_fill_mode(node: &mut PenNode, mode: ImageFillMode) -> bool { + let Some(body) = primary_image_fill_mut(node) else { + return false; + }; + body.mode = Some(mode.to_schema()); + true +} + +/// Set one primary image-fill adjustment, clamped to the TS slider +/// range `[-100, 100]`. +pub fn set_primary_image_adjustment( + node: &mut PenNode, + field: ImageAdjustmentField, + value: f32, +) -> bool { + let Some(body) = primary_image_fill_mut(node) else { + return false; + }; + let value = value.clamp(-100.0, 100.0); + match field { + ImageAdjustmentField::Exposure => body.exposure = Some(value), + ImageAdjustmentField::Contrast => body.contrast = Some(value), + ImageAdjustmentField::Saturation => body.saturation = Some(value), + ImageAdjustmentField::Temperature => body.temperature = Some(value), + ImageAdjustmentField::Tint => body.tint = Some(value), + ImageAdjustmentField::Highlights => body.highlights = Some(value), + ImageAdjustmentField::Shadows => body.shadows = Some(value), + } + true +} + +/// Reset every primary image-fill adjustment to zero. +pub fn reset_primary_image_adjustments(node: &mut PenNode) -> bool { + let Some(body) = primary_image_fill_mut(node) else { + return false; + }; + body.exposure = Some(0.0); + body.contrast = Some(0.0); + body.saturation = Some(0.0); + body.temperature = Some(0.0); + body.tint = Some(0.0); + body.highlights = Some(0.0); + body.shadows = Some(0.0); + true +} + /// Write the primary fill's `opacity` (clamped to `[0.0, 1.0]`), /// matching on whatever variant the first fill is. Touches no /// other field — a gradient / image fill keeps its stops, image diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index feb6450a6..189fd710d 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -32,6 +32,7 @@ pub mod page_mutators; pub mod path_bounds; pub mod pen; pub mod pen_node_ext; +pub mod property_panel_state; pub mod rename; pub mod render_backend; pub mod selection; @@ -81,14 +82,13 @@ pub use design_md::parse_design_md; pub use editor_ui_state::{ BooleanOp, DesignMdRequest, EditorUiState, ExportFormat, FileAction, FileMenuChoice, FillType, FlexLayout, GitCommitSummary, GitDiffTarget, GitDiffView, GitFileEntry, GitPanelAction, - GitPanelState, LayerContextMenuState, Locale, MergeConflictRow, MergeResolveFile, - MergeResolveState, PageRenameState, PropertyTab, RecentFile, ShapeChoice, ThemeMode, - UpdateStatus, VariableRowFocus, + GitPanelState, ImageAdjustmentField, ImageFillMode, LayerContextMenuState, Locale, + MergeConflictRow, MergeResolveFile, MergeResolveState, PageRenameState, PropertyTab, + RecentFile, ShapeChoice, ThemeMode, UpdateStatus, VariableRowFocus, }; -pub use toolbar_state::{ToolbarAction, ToolbarHover}; pub use fills::{ - first_fill_type, first_solid_fill_hex, first_solid_fill_opacity, first_solid_stroke_hex, - node_effects, + first_fill_type, first_image_fill_summary, first_solid_fill_hex, first_solid_fill_opacity, + first_solid_stroke_hex, node_effects, ImageFillSummary, }; pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect}; pub use history::{EditorSnapshot, History, HISTORY_CAP}; @@ -99,6 +99,7 @@ pub use render_backend::*; pub use selection::SelectionState; pub use state::EditorState; pub use tool::Tool; +pub use toolbar_state::{ToolbarAction, ToolbarHover}; pub use ui_draft::{ ColorPickerDrag, ColorPickerState, ColorTarget, LayerContextTarget, LayerRenameState, PropertyFocus, UiDraftState, VariableUiState, diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index 030b48eb3..a54acad10 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -513,6 +513,46 @@ impl EditorState { crate::fills::set_primary_fill_type(node, fill_type) } + /// Set the selected node's primary image-fill fit mode. + pub fn set_selected_image_fill_mode(&mut self, mode: crate::ImageFillMode) -> bool { + let sel = self.selection.anchor.clone(); + if !sel.is_real() || !self.is_editable(&sel) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), &sel) else { + return false; + }; + crate::fills::set_primary_image_fill_mode(node, mode) + } + + /// Set one selected-node primary image-fill adjustment. + pub fn set_selected_image_adjustment( + &mut self, + field: crate::ImageAdjustmentField, + value: f32, + ) -> bool { + let sel = self.selection.anchor.clone(); + if !sel.is_real() || !self.is_editable(&sel) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), &sel) else { + return false; + }; + crate::fills::set_primary_image_adjustment(node, field, value) + } + + /// Reset all selected-node primary image-fill adjustments. + pub fn reset_selected_image_adjustments(&mut self) -> bool { + let sel = self.selection.anchor.clone(); + if !sel.is_real() || !self.is_editable(&sel) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), &sel) else { + return false; + }; + crate::fills::reset_primary_image_adjustments(node) + } + // --- Tree ops ---------------------------------------------------- /// Remove every editable node in the selection set from its diff --git a/crates/op-editor-core/src/property_panel_state.rs b/crates/op-editor-core/src/property_panel_state.rs new file mode 100644 index 000000000..0cd63a9e4 --- /dev/null +++ b/crates/op-editor-core/src/property_panel_state.rs @@ -0,0 +1,159 @@ +//! Property-panel state enums shared by editor core and widget hosts. + +/// Which PropertyPanel tab is active — toggled by `Cmd+Shift+C`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropertyTab { + Design, + Code, +} + +/// Variants the Fill section's type-selector pill exposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FillType { + Solid, + LinearGradient, + RadialGradient, + Image, +} + +/// Image-fill fit modes exposed by the TS image-fill popover. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageFillMode { + Fill, + Fit, + Crop, + Tile, +} + +impl ImageFillMode { + pub const ALL: [Self; 4] = [Self::Fill, Self::Fit, Self::Crop, Self::Tile]; + + pub fn label_key(self) -> &'static str { + match self { + Self::Fill => "image.fill", + Self::Fit => "image.fitMode", + Self::Crop => "image.crop", + Self::Tile => "image.tile", + } + } + + pub fn to_schema(self) -> jian_ops_schema::style::ImageFillMode { + match self { + Self::Fill => jian_ops_schema::style::ImageFillMode::Fill, + Self::Fit => jian_ops_schema::style::ImageFillMode::Fit, + Self::Crop => jian_ops_schema::style::ImageFillMode::Crop, + Self::Tile => jian_ops_schema::style::ImageFillMode::Tile, + } + } + + pub fn from_schema(value: Option<&jian_ops_schema::style::ImageFillMode>) -> Self { + match value { + Some(jian_ops_schema::style::ImageFillMode::Fit) => Self::Fit, + Some(jian_ops_schema::style::ImageFillMode::Crop) => Self::Crop, + Some(jian_ops_schema::style::ImageFillMode::Tile) => Self::Tile, + Some(jian_ops_schema::style::ImageFillMode::Fill) + | Some(jian_ops_schema::style::ImageFillMode::Stretch) + | None => Self::Fill, + } + } +} + +/// Image-fill adjustment sliders. Values are clamped to `[-100, 100]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageAdjustmentField { + Exposure, + Contrast, + Saturation, + Temperature, + Tint, + Highlights, + Shadows, +} + +impl ImageAdjustmentField { + pub const ALL: [Self; 7] = [ + Self::Exposure, + Self::Contrast, + Self::Saturation, + Self::Temperature, + Self::Tint, + Self::Highlights, + Self::Shadows, + ]; + + pub fn label_key(self) -> &'static str { + match self { + Self::Exposure => "image.exposure", + Self::Contrast => "image.contrast", + Self::Saturation => "image.saturation", + Self::Temperature => "image.temperature", + Self::Tint => "image.tint", + Self::Highlights => "image.highlights", + Self::Shadows => "image.shadows", + } + } +} + +/// Three flex-layout modes the property panel exposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlexLayout { + Free, + Vertical, + Horizontal, +} + +/// Path boolean ops — TS parity with Paper.js (Ctrl+Alt+U/S/I/X). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BooleanOp { + Union, + Subtract, + Intersect, + Exclude, +} + +/// Raster export format. State enum ported from shell-core's +/// `widgets/export_dialog::ExportFormat` (the widget render code stays +/// in shell-core). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Png, + Jpeg, + Webp, + Svg, + Pdf, +} + +impl ExportFormat { + pub const ALL: [ExportFormat; 5] = [ + ExportFormat::Png, + ExportFormat::Jpeg, + ExportFormat::Webp, + ExportFormat::Svg, + ExportFormat::Pdf, + ]; + + pub fn label(self) -> &'static str { + match self { + ExportFormat::Png => "PNG", + ExportFormat::Jpeg => "JPEG", + ExportFormat::Webp => "WEBP", + ExportFormat::Svg => "SVG", + ExportFormat::Pdf => "PDF", + } + } + + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Png => "png", + ExportFormat::Jpeg => "jpg", + ExportFormat::Webp => "webp", + ExportFormat::Svg => "svg", + ExportFormat::Pdf => "pdf", + } + } + + /// Whether the format has a working export backend. + pub fn is_implemented(self) -> bool { + true + } +} diff --git a/crates/op-editor-core/src/tests_mutators.rs b/crates/op-editor-core/src/tests_mutators.rs index 70ddb612a..96a480c36 100644 --- a/crates/op-editor-core/src/tests_mutators.rs +++ b/crates/op-editor-core/src/tests_mutators.rs @@ -8,6 +8,7 @@ use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; use crate::test_support::{frame, group, rect, sample, state_with}; use crate::walkers::{find_node, ReorderDirection}; +use jian_ops_schema::style::PenFill; // --- Selection ------------------------------------------------------- @@ -56,6 +57,75 @@ fn select_all_top_level_empty_page_is_noop() { assert!(!s.select_all_top_level()); } +#[test] +fn set_selected_image_fill_mode_updates_primary_image_fill() { + let mut node = rect("n60", "Photo fill", 0.0, 0.0, 100.0, 80.0); + crate::fills::set_primary_fill_type(&mut node, crate::FillType::Image); + let mut s = state_with(vec![node]); + s.set_single_selection(NodeId::new("n60")); + + assert!(s.set_selected_image_fill_mode(crate::ImageFillMode::Crop)); + + let node = find_node(s.active_children(), &NodeId::new("n60")).unwrap(); + match crate::fills::node_fills(node).unwrap().first().unwrap() { + PenFill::Image(body) => { + assert_eq!(body.mode, Some(jian_ops_schema::style::ImageFillMode::Crop)); + } + other => panic!("expected image fill, got {other:?}"), + } +} + +#[test] +fn image_fill_summary_exposes_selected_image_url_for_preview() { + let mut node = rect("n62", "Photo fill", 0.0, 0.0, 100.0, 80.0); + crate::fills::set_primary_fill_type(&mut node, crate::FillType::Image); + let mut s = state_with(vec![node]); + s.set_single_selection(NodeId::new("n62")); + + let url = "data:image/png;base64,iVBORw0KGgo="; + assert!(s.set_selected_fill_image_url(url)); + + let node = find_node(s.active_children(), &NodeId::new("n62")).unwrap(); + let summary = crate::fills::first_image_fill_summary(node).unwrap(); + assert!(summary.has_image); + assert_eq!(summary.image_url.as_deref(), Some(url)); +} + +#[test] +fn set_selected_image_adjustment_clamps_and_resets() { + let mut node = rect("n61", "Photo fill", 0.0, 0.0, 100.0, 80.0); + crate::fills::set_primary_fill_type(&mut node, crate::FillType::Image); + let mut s = state_with(vec![node]); + s.set_single_selection(NodeId::new("n61")); + + assert!(s.set_selected_image_adjustment(crate::ImageAdjustmentField::Exposure, 125.0)); + assert!(s.set_selected_image_adjustment(crate::ImageAdjustmentField::Contrast, -125.0)); + + let node = find_node(s.active_children(), &NodeId::new("n61")).unwrap(); + match crate::fills::node_fills(node).unwrap().first().unwrap() { + PenFill::Image(body) => { + assert_eq!(body.exposure, Some(100.0)); + assert_eq!(body.contrast, Some(-100.0)); + } + other => panic!("expected image fill, got {other:?}"), + } + + assert!(s.reset_selected_image_adjustments()); + let node = find_node(s.active_children(), &NodeId::new("n61")).unwrap(); + match crate::fills::node_fills(node).unwrap().first().unwrap() { + PenFill::Image(body) => { + assert_eq!(body.exposure, Some(0.0)); + assert_eq!(body.contrast, Some(0.0)); + assert_eq!(body.saturation, Some(0.0)); + assert_eq!(body.temperature, Some(0.0)); + assert_eq!(body.tint, Some(0.0)); + assert_eq!(body.highlights, Some(0.0)); + assert_eq!(body.shadows, Some(0.0)); + } + other => panic!("expected image fill, got {other:?}"), + } +} + // --- Delete ---------------------------------------------------------- #[test] diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index ae65b77be..9ebb0939a 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -46,10 +46,12 @@ pub mod property_panel_code; pub mod property_panel_effects; pub mod property_panel_export; pub mod property_panel_fill; +pub mod property_panel_image_fill; pub mod property_panel_input_layout; pub mod property_panel_inputs; pub mod property_panel_layout; pub mod property_panel_sections; +pub mod property_panel_snapshot; #[cfg(test)] mod property_panel_tests; pub mod toolbar; diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 9276b3c7d..bca45d586 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -3,9 +3,9 @@ //! Mirrors `apps/web/src/components/panels/right-panel.tsx` and the //! per-section TS files (`*-section.tsx`). The bulk of the paint //! logic lives in [`super::property_panel_sections`] — this file -//! holds the `PropertyPanel` struct, the `Widget` impl, and the -//! per-frame snapshot extraction. Splitting the file keeps both -//! pieces under the openpencil 800-line ceiling. +//! holds the `PropertyPanel` struct, the `Widget` impl, and wiring +//! around snapshot extraction. Splitting the file keeps the pieces +//! under the openpencil 800-line ceiling. //! //! Sections (top → bottom, mirroring TS order): //! 1. Tab strip (Design / Code) @@ -23,49 +23,15 @@ //! Host calls [`PropertyPanel::for_selection`] which returns //! `Option`; `None` = panel hidden entirely. -use crate::layout_scene::{NodeKind, SceneStroke}; use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::property_panel_sections as sections; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; -use crate::{Color, Point2D, Rect}; +use crate::{Point2D, Rect}; use op_editor_core::PropertyFocus; -use jian_ops_schema::node::PenNode; -use op_editor_core::pen_node_ext::PenNodeExt; use op_editor_core::EditorState; -/// Map a `PenNode` variant onto shell-core's `document::NodeKind`, -/// which drives the per-kind section-capability filtering. The -/// canonical schema's extra variants degrade onto the closest -/// shell-core kind (TextInput → Text; Image / IconFont / Ref → -/// `Other(tag)` so the section mask treats them structurally). -fn node_kind_of(node: &PenNode) -> NodeKind { - match node { - PenNode::Frame(_) => NodeKind::Frame, - PenNode::Group(_) => NodeKind::Group, - PenNode::Rectangle(_) => NodeKind::Rect, - PenNode::Ellipse(_) => NodeKind::Ellipse, - PenNode::Polygon(_) => NodeKind::Polygon, - PenNode::Line(_) => NodeKind::Line, - PenNode::Path(_) => NodeKind::Path, - PenNode::Text(_) | PenNode::TextInput(_) => NodeKind::Text, - PenNode::Image(_) => NodeKind::Other("image".to_string()), - PenNode::IconFont(_) => NodeKind::Other("icon_font".to_string()), - PenNode::Ref(_) => NodeKind::Other("ref".to_string()), - } -} - -/// Parse a `#RRGGBB` / `#RGB` hex string into a `Color`. Reuses the -/// editor-state colour parser; 8-char `#RRGGBBAA` is honoured so -/// gradient stop swatches (and any other authored alpha) round-trip -/// transparency into paint instead of always reading as opaque. -fn color_from_hex(hex: &str) -> Option { - let (r, g, b) = op_editor_core::parse_hex_rgb(hex)?; - let a = op_editor_core::parse_hex_alpha(hex); - Some(Color { r, g, b, a }) -} - pub const PROPERTY_PANEL_WIDTH: f32 = 280.0; // `PropertyPanelAction` lives in `property_panel_action.rs` (split @@ -79,291 +45,9 @@ pub use crate::widgets::property_panel_action::PropertyPanelAction; // feeds); re-exported so `property_panel::SectionCapabilities` // resolves unchanged. pub(crate) use crate::widgets::property_panel_layout::SectionCapabilities; - -/// Snapshot of the selected node's editable fields, formatted for -/// display. Built once per `for_selection` call so all paint -/// helpers can read pre-computed strings instead of re-formatting. -#[derive(Debug, Clone)] -pub struct NodeSnapshot { - pub kind: String, - pub name: String, - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, - /// Rotation in degrees (clockwise positive). - pub rotation_deg: f32, - /// Uniform corner radius in doc-px. - pub corner_radius: f32, - pub fill: Option, - /// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill - /// section's `100 %` paints `fill_opacity * 100`. - pub fill_opacity: f32, - pub stroke: Option, - /// LinearGradient angle in degrees (canonical `.op` convention, - /// 0° = bottom→top). `None` when the primary fill isn't a - /// linear gradient — the Fill section hides the angle row in - /// that case. - pub gradient_angle: Option, - /// Resolved primary-fill gradient stops, in authored order. - /// Populated for Linear + Radial fills; empty for Solid / Image - /// / no-fill. Each entry carries the schema hex string (so the - /// panel input can paint exactly what the file authored) plus - /// the parsed paint colour for the stop swatch. - pub gradient_stops: Vec, - /// The node's visual effects, in paint order — drives the - /// Effects section's rows + param inputs. - pub effects: Vec, - /// Drives per-kind section filtering (Line hides fill, etc.). - pub kind_variant: crate::layout_scene::NodeKind, -} - -/// One gradient stop summary for the Fill section. -#[derive(Debug, Clone)] -pub struct GradientStopSummary { - /// Offset 0.0..=1.0 — the Fill panel paints `offset * 100` as - /// the per-stop `%` input. - pub offset: f32, - /// Schema hex string (`#RRGGBB` or `#RRGGBBAA`). The panel - /// paints this verbatim so a freshly-typed user value isn't - /// silently re-cased by `format_color_hex` round-trips. - pub hex: String, - /// Parsed paint colour for the per-row swatch. Falls back to - /// black when the hex fails to parse. - pub color: Color, -} - -/// Which visual-effect variant a row represents. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EffectKind { - Shadow, - Blur, - BackgroundBlur, -} - -impl EffectKind { - /// Human-readable row label. - pub fn label(self) -> &'static str { - match self { - EffectKind::Shadow => "Drop Shadow", - EffectKind::Blur => "Layer Blur", - EffectKind::BackgroundBlur => "Background Blur", - } - } -} - -/// One effect's editable scalar parameters, formatted for the -/// Effects section. Shadow uses all four; the blur kinds use `blur` -/// as the radius and leave offset / spread at 0. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct EffectSummary { - pub kind: EffectKind, - pub offset_x: f32, - pub offset_y: f32, - pub blur: f32, - pub spread: f32, - /// Effect colour — Shadow carries an authored hex string; the - /// blur kinds don't have a colour field, so paint reads - /// `Color::TRANSPARENT` (and the colour row is hidden by the - /// effects-section painter when alpha is zero). - pub color: Color, -} - -impl EffectSummary { - /// Current value of one editable parameter — Blur / BackgroundBlur - /// keep their radius in `blur`, so `Blur` and `Radius` both read - /// that field. - pub fn param_value(&self, field: op_editor_core::EffectField) -> f32 { - use op_editor_core::EffectField as F; - match field { - F::OffsetX => self.offset_x, - F::OffsetY => self.offset_y, - F::Blur | F::Radius => self.blur, - F::Spread => self.spread, - } - } - - /// Summarise a canonical `PenEffect` for the panel. - fn from_pen_effect(e: &jian_ops_schema::style::PenEffect) -> Self { - use jian_ops_schema::style::PenEffect; - match e { - PenEffect::Shadow(s) => EffectSummary { - kind: EffectKind::Shadow, - offset_x: s.offset_x, - offset_y: s.offset_y, - blur: s.blur, - spread: s.spread, - color: color_from_hex(&s.color).unwrap_or(Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.25, - }), - }, - PenEffect::Blur(b) => EffectSummary { - kind: EffectKind::Blur, - offset_x: 0.0, - offset_y: 0.0, - blur: b.radius, - spread: 0.0, - color: Color::TRANSPARENT, - }, - PenEffect::BackgroundBlur(b) => EffectSummary { - kind: EffectKind::BackgroundBlur, - offset_x: 0.0, - offset_y: 0.0, - blur: b.radius, - spread: 0.0, - color: Color::TRANSPARENT, - }, - } - } -} - -impl NodeSnapshot { - /// Build an aggregate snapshot for a multi-node selection. - /// Returns None when nothing on the active page resolves from - /// `selected_set`. Uses `Document::selection_bounds` (the union - /// of every selected node's `aggregate_bounds`) for x/y/w/h. - /// Rotation / fill / stroke are zeroed in v1 — broadcasting - /// "Mixed" or per-axis aggregation is a follow-up; the panel - /// hides those inputs anyway since `is_multi` flips them - /// inert. - fn from_multi_selection(state: &EditorState) -> Option { - // Confirm at least 2 selected ids resolve on the active - // page — bails on cross-page selections but NOT on - // all-zero-size selections (matches single-select - // semantics, which paint the panel even for a 0x0 node). - if state.selection_count() < 2 { - return None; - } - // `selection_bounds` returns `None` when nothing resolves; - // an empty union still paints (zeroed) like single-select. - if state.selected_node().is_none() && state.selection_bounds().is_none() { - return None; - } - let bounds = state - .selection_bounds() - .unwrap_or(op_editor_core::DocRect::ZERO); - let n = state.selection_count(); - Some(Self { - kind: format!("{} items", n), - name: format!("{} selected", n), - x: bounds.x.round() as i32, - y: bounds.y.round() as i32, - width: bounds.w.round() as i32, - height: bounds.h.round() as i32, - rotation_deg: 0.0, - corner_radius: 0.0, - fill: None, - fill_opacity: 1.0, - stroke: None, - gradient_angle: None, - gradient_stops: Vec::new(), - // Multi-select shows no per-effect rows — the Effects - // section paints just its header + the add affordance. - effects: Vec::new(), - // `kind_variant` is informational for the snapshot - // header label only — the paint capability mask is - // driven by `SectionCapabilities::for_multi()` instead - // of `for_kind`, see `paint`. Frame chosen so any - // future kind-specific lookups paint a neutral default. - kind_variant: NodeKind::Frame, - }) - } - - /// Build the snapshot from a canonical `PenNode`. Geometry uses - /// `aggregate_bounds` so Group / unbounded container nodes report - /// the visual extent of their subtree instead of "0 × 0". - fn from_node(node: &PenNode) -> Self { - let base = node.base(); - let kind = node_kind_of(node); - let bounds = op_editor_core::aggregate_bounds(node); - // Corner radius — only the container variants carry one; - // a `PerCorner` radius reports its top-left corner. - let corner_radius = container_corner_radius(node); - let fill = op_editor_core::first_solid_fill_hex(node).and_then(color_from_hex); - let stroke = op_editor_core::first_solid_stroke_hex(node) - .and_then(color_from_hex) - .map(|color| SceneStroke { - color, - width: op_editor_core::fills::node_stroke_width(node).unwrap_or(1.0) as f32, - }); - Self { - kind: kind.label().to_string(), - name: base.name.clone().unwrap_or_default(), - x: bounds.x.round() as i32, - y: bounds.y.round() as i32, - width: bounds.w.round() as i32, - height: bounds.h.round() as i32, - // `base.rotation` is stored in degrees by the canonical - // schema; the snapshot's `rotation_deg` wants degrees. - rotation_deg: base.rotation.unwrap_or(0.0) as f32, - corner_radius, - fill, - fill_opacity: op_editor_core::first_solid_fill_opacity(node), - stroke, - gradient_angle: gradient_angle_of(node), - gradient_stops: gradient_stops_of(node), - effects: op_editor_core::node_effects(node) - .iter() - .map(EffectSummary::from_pen_effect) - .collect(), - kind_variant: kind, - } - } -} - -/// LinearGradient `angle` for the node's first fill, when it has -/// one. Falls back to `0.0` (canonical default, bottom→top) when -/// the body omits an explicit angle. `None` for non-linear primary -/// fills — the Fill section uses that to hide the angle row. -fn gradient_angle_of(node: &PenNode) -> Option { - use jian_ops_schema::style::PenFill; - match op_editor_core::fills::node_fills(node).and_then(|f| f.first())? { - PenFill::LinearGradient(body) => Some(body.angle.unwrap_or(0.0)), - _ => None, - } -} - -/// Resolved stops for the primary Linear / Radial gradient — empty -/// list for Solid / Image / no-fill nodes. -fn gradient_stops_of(node: &PenNode) -> Vec { - use jian_ops_schema::style::PenFill; - let Some(first) = op_editor_core::fills::node_fills(node).and_then(|f| f.first()) else { - return Vec::new(); - }; - let raw = match first { - PenFill::LinearGradient(b) => &b.stops, - PenFill::RadialGradient(b) => &b.stops, - _ => return Vec::new(), - }; - raw.iter() - .map(|s| GradientStopSummary { - offset: s.offset.clamp(0.0, 1.0), - hex: s.color.clone(), - color: color_from_hex(&s.color).unwrap_or(Color::BLACK), - }) - .collect() -} - -/// Uniform corner radius (doc-px) for a container variant — Frame / -/// Group / Rectangle carry a `CornerRadius`. A `PerCorner` radius -/// reports its top-left value. Non-container variants read 0. -fn container_corner_radius(node: &PenNode) -> f32 { - use jian_ops_schema::node::container::CornerRadius; - let cr = match node { - PenNode::Frame(n) => n.container.corner_radius.as_ref(), - PenNode::Group(n) => n.container.corner_radius.as_ref(), - PenNode::Rectangle(n) => n.container.corner_radius.as_ref(), - _ => None, - }; - match cr { - Some(CornerRadius::Uniform(r)) => *r as f32, - Some(CornerRadius::PerCorner(c)) => c[0] as f32, - None => 0.0, - } -} +pub use crate::widgets::property_panel_snapshot::{ + EffectKind, EffectSummary, GradientStopSummary, NodeSnapshot, +}; pub struct PropertyPanel { pub id: WidgetId, @@ -395,6 +79,7 @@ pub struct PropertyPanel { /// Active fill type — drives the dropdown label + picker. pub fill_type: op_editor_core::FillType, pub fill_type_picker_open: bool, + pub image_fill_popover_open: bool, /// True for multi-select aggregate (inputs inert, "N items"). pub is_multi: bool, /// Active header tab — toggled by Cmd+Shift+C. @@ -519,6 +204,7 @@ impl PropertyPanel { }, fill_type, fill_type_picker_open: ui.fill_type_picker_open, + image_fill_popover_open: ui.image_fill_popover_open, is_multi, tab: ui.property_tab, export_format: ui.export_format, @@ -606,6 +292,16 @@ impl PropertyPanel { // Multi-select inputs / toggles are inert in v1. return None; } + if self.image_fill_popover_open { + if let Some(action) = sections::image_fill_popover_action_at( + self.scrolled_rect(panel_rect), + self.visible_sections(), + &self.snapshot, + point, + ) { + return Some(action); + } + } if !self.point_in_section_viewport(panel_rect, point) { return None; } @@ -659,6 +355,23 @@ impl PropertyPanel { .position(|(_, rect)| rect_contains(rect, point)) } + pub fn image_adjustment_drag_action( + &self, + panel_rect: Rect, + field: op_editor_core::ImageAdjustmentField, + x: f32, + ) -> Option { + if self.is_multi || !self.image_fill_popover_open { + return None; + } + sections::image_fill_popover_adjustment_action_for_drag( + self.scrolled_rect(panel_rect), + self.visible_sections(), + field, + x, + ) + } + /// Hit-test the panel at `point` and return which input row /// (if any) contains the click. The layout walk mirrors the /// per-kind section filtering applied in `paint`, so rects @@ -732,7 +445,6 @@ impl Widget for PropertyPanel { caret_anchor_ms: self.caret_anchor_ms, now_ms: self.now_ms, }; - use crate::layout_scene::NodeKind; let caps = self.capabilities(); if matches!(self.tab, op_editor_core::PropertyTab::Code) { paint_code_placeholder(cx, &self.theme, &self.snapshot, x, tab_bottom, w); @@ -878,7 +590,6 @@ impl Widget for PropertyPanel { ); } cx.backend.restore(); - let _ = NodeKind::Frame; // ensure NodeKind is in scope above for tests } fn access_node(&self) -> accesskit::Node { @@ -887,3 +598,27 @@ impl Widget for PropertyPanel { node } } + +impl PropertyPanel { + /// Paint inspector overlays that are allowed to extend out of the + /// right rail. Hosts call this late in their composition pass so + /// the image-fill popover is above floating canvas controls. + pub fn paint_overlays(&self, cx: &mut PaintCx<'_>, rect: Rect) { + if !self.capabilities().fill || !self.image_fill_popover_open { + return; + } + let scroll = self.effective_scroll(rect); + let scrolled = Rect { + origin: Point2D::new(rect.origin.x, rect.origin.y - scroll), + size: rect.size, + }; + sections::paint_image_fill_popover( + cx, + &self.theme, + scrolled, + self.visible_sections(), + &self.snapshot, + self.locale, + ); + } +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_action.rs b/crates/op-editor-ui/src/widgets/property_panel_action.rs index 17536f4a7..6edd4b241 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_action.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_action.rs @@ -71,7 +71,22 @@ pub enum PropertyPanelAction { /// `effect[index].color`. OpenEffectColorPicker(usize), /// User clicked the `图片` fill body row — host opens an image - /// file picker (rfd) and writes the chosen file into the selected + /// fill popover, matching the TS right-panel image editor. + ToggleImageFillPopover, + /// User clicked the popover close button. + CloseImageFillPopover, + /// User picked a fill/fit/crop/tile mode in the image-fill + /// popover. + SetImageFillMode(op_editor_core::ImageFillMode), + /// User clicked the image-fill popover's upload well — host opens + /// a file picker and writes the chosen file into the selected /// node's primary fill as `PenFill::Image { url: }`. PickFillImage, + /// User clicked one of the image adjustment tracks. + SetImageAdjustment { + field: op_editor_core::ImageAdjustmentField, + value: f32, + }, + /// User clicked the image adjustment reset affordance. + ResetImageAdjustments, } diff --git a/crates/op-editor-ui/src/widgets/property_panel_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_fill.rs index d88bcb8fb..2ffde7164 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_fill.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_fill.rs @@ -327,7 +327,7 @@ pub fn paint_fill_section( paint_fill_gradient_body(cx, theme, edit, snapshot, locale, x, y, width, false); } FillType::Image => { - paint_fill_image_body(cx, theme, locale, x, y, width); + paint_fill_image_body(cx, theme, snapshot, locale, x, y, width); } } y += fill_body_height(fill_type) - 6.0 + 12.0; @@ -669,6 +669,7 @@ fn paint_alpha_checker(cx: &mut PaintCx<'_>, rect: Rect, radius: f32) { fn paint_fill_image_body( cx: &mut PaintCx<'_>, theme: &Theme, + snapshot: &NodeSnapshot, locale: op_editor_core::Locale, x: f32, y: f32, @@ -690,8 +691,13 @@ fn paint_fill_image_body( theme.muted_foreground, 1.4, ); + let label_key = snapshot + .image_fill + .as_ref() + .map(|summary| summary.mode.label_key()) + .unwrap_or("fill.title"); let label = TextLayout::single_run( - op_i18n::translate(locale, "fill.title"), + op_i18n::translate(locale, label_key), "system-ui", 12.0, to_jian_color(theme.foreground), diff --git a/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs new file mode 100644 index 000000000..659b39b44 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs @@ -0,0 +1,479 @@ +//! TS-parity floating editor for image fills. +//! +//! The body row in the Fill section toggles this popover; the upload +//! well inside it triggers the native file picker. + +use crate::theme::Theme; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::property_panel::{NodeSnapshot, PropertyPanelAction}; +use crate::widgets::property_panel_inputs::to_jian_color; +use crate::widgets::property_panel_layout::{ + action_button_rects_with_fill_picker, VisibleSections, +}; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect, TextLayout}; + +const PANEL_W: f32 = 220.0; +const PANEL_GAP: f32 = 8.0; +const PANEL_PAD: f32 = 12.0; +const HEADER_H: f32 = 44.0; +const MODE_H: f32 = 30.0; +const UPLOAD_H: f32 = 112.0; +const ADJ_HEADER_H: f32 = 28.0; +const ADJ_ROW_H: f32 = 34.0; + +fn panel_h() -> f32 { + HEADER_H + + MODE_H + + 10.0 + + UPLOAD_H + + 12.0 + + 1.0 + + ADJ_HEADER_H + + op_editor_core::ImageAdjustmentField::ALL.len() as f32 * ADJ_ROW_H + + PANEL_PAD +} + +fn rect_contains(r: Rect, p: Point2D) -> bool { + p.x >= r.origin.x + && p.x <= r.origin.x + r.size.x + && p.y >= r.origin.y + && p.y <= r.origin.y + r.size.y +} + +fn image_body_rect(panel_rect: Rect, visible: VisibleSections) -> Option { + if !visible.fill || visible.fill_type != op_editor_core::FillType::Image { + return None; + } + action_button_rects_with_fill_picker(panel_rect, visible, &[], false, false, false) + .into_iter() + .find_map(|(action, rect)| { + matches!(action, PropertyPanelAction::ToggleImageFillPopover).then_some(rect) + }) +} + +fn popover_rect(panel_rect: Rect, visible: VisibleSections) -> Option { + let anchor = image_body_rect(panel_rect, visible)?; + let h = panel_h(); + let min_top = panel_rect.origin.y + 8.0; + let max_top = (panel_rect.origin.y + panel_rect.size.y - h - 8.0).max(min_top); + Some(Rect { + origin: Point2D::new( + anchor.origin.x - PANEL_W - PANEL_GAP, + anchor.origin.y.clamp(min_top, max_top), + ), + size: Point2D::new(PANEL_W, h), + }) +} + +pub fn image_fill_popover_action_rects( + panel_rect: Rect, + visible: VisibleSections, + snapshot: &NodeSnapshot, +) -> Vec<(PropertyPanelAction, Rect)> { + let Some(pop) = popover_rect(panel_rect, visible) else { + return Vec::new(); + }; + let mut out = Vec::new(); + out.push(( + PropertyPanelAction::CloseImageFillPopover, + Rect { + origin: Point2D::new(pop.origin.x + pop.size.x - 34.0, pop.origin.y + 8.0), + size: Point2D::new(26.0, 26.0), + }, + )); + + let mode_y = pop.origin.y + HEADER_H; + let mode_rect = Rect { + origin: Point2D::new(pop.origin.x + PANEL_PAD, mode_y), + size: Point2D::new(pop.size.x - PANEL_PAD * 2.0, MODE_H), + }; + let chip_w = mode_rect.size.x / op_editor_core::ImageFillMode::ALL.len() as f32; + for (i, mode) in op_editor_core::ImageFillMode::ALL.iter().enumerate() { + out.push(( + PropertyPanelAction::SetImageFillMode(*mode), + Rect { + origin: Point2D::new(mode_rect.origin.x + i as f32 * chip_w, mode_rect.origin.y), + size: Point2D::new(chip_w, mode_rect.size.y), + }, + )); + } + + let upload = upload_rect(pop); + out.push((PropertyPanelAction::PickFillImage, upload)); + + if snapshot + .image_fill + .as_ref() + .map(|summary| summary.has_adjustments()) + .unwrap_or(false) + { + out.push(( + PropertyPanelAction::ResetImageAdjustments, + Rect { + origin: Point2D::new( + pop.origin.x + pop.size.x - 62.0, + adjustments_header_y(pop) + 3.0, + ), + size: Point2D::new(50.0, 20.0), + }, + )); + } + out +} + +pub fn image_fill_popover_action_at( + panel_rect: Rect, + visible: VisibleSections, + snapshot: &NodeSnapshot, + point: Point2D, +) -> Option { + for (action, rect) in image_fill_popover_action_rects(panel_rect, visible, snapshot) + .into_iter() + .rev() + { + if rect_contains(rect, point) { + return Some(action); + } + } + let pop = popover_rect(panel_rect, visible)?; + for (field, track) in adjustment_track_rects(pop) { + if rect_contains(track, point) { + let pct = ((point.x - track.origin.x) / track.size.x).clamp(0.0, 1.0); + return Some(PropertyPanelAction::SetImageAdjustment { + field, + value: (pct * 200.0 - 100.0).round(), + }); + } + } + None +} + +pub fn image_fill_popover_adjustment_action_for_drag( + panel_rect: Rect, + visible: VisibleSections, + field: op_editor_core::ImageAdjustmentField, + x: f32, +) -> Option { + let pop = popover_rect(panel_rect, visible)?; + let track = adjustment_track_rects(pop) + .into_iter() + .find_map(|(candidate, rect)| (candidate == field).then_some(rect))?; + let pct = ((x - track.origin.x) / track.size.x).clamp(0.0, 1.0); + Some(PropertyPanelAction::SetImageAdjustment { + field, + value: (pct * 200.0 - 100.0).round(), + }) +} + +pub fn paint_image_fill_popover( + cx: &mut PaintCx<'_>, + theme: &Theme, + panel_rect: Rect, + visible: VisibleSections, + snapshot: &NodeSnapshot, + locale: op_editor_core::Locale, +) { + let Some(pop) = popover_rect(panel_rect, visible) else { + return; + }; + let summary = snapshot + .image_fill + .clone() + .unwrap_or(op_editor_core::ImageFillSummary { + mode: op_editor_core::ImageFillMode::Fill, + has_image: false, + image_url: None, + exposure: 0.0, + contrast: 0.0, + saturation: 0.0, + temperature: 0.0, + tint: 0.0, + highlights: 0.0, + shadows: 0.0, + }); + cx.backend.fill_round_rect(pop, 8.0, theme.popover); + cx.backend.stroke_round_rect(pop, 8.0, theme.border, 1.0); + + paint_label( + cx, + theme, + op_i18n::translate(locale, "image.title"), + pop.origin.x + PANEL_PAD, + pop.origin.y + 28.0, + 13.0, + theme.foreground, + ); + draw_icon( + cx.backend, + Icon::Close, + Point2D::new(pop.origin.x + pop.size.x - 30.0, pop.origin.y + 13.0), + 18.0, + theme.foreground, + 1.8, + ); + paint_mode_control(cx, theme, pop, summary.mode, locale); + paint_upload(cx, theme, pop, &summary, locale); + paint_adjustments(cx, theme, pop, &summary, locale); +} + +fn upload_rect(pop: Rect) -> Rect { + Rect { + origin: Point2D::new( + pop.origin.x + PANEL_PAD, + pop.origin.y + HEADER_H + MODE_H + 10.0, + ), + size: Point2D::new(pop.size.x - PANEL_PAD * 2.0, UPLOAD_H), + } +} + +fn adjustments_header_y(pop: Rect) -> f32 { + upload_rect(pop).origin.y + UPLOAD_H + 13.0 +} + +fn adjustment_track_rects(pop: Rect) -> Vec<(op_editor_core::ImageAdjustmentField, Rect)> { + let mut out = Vec::new(); + let start_y = adjustments_header_y(pop) + ADJ_HEADER_H; + for (i, field) in op_editor_core::ImageAdjustmentField::ALL.iter().enumerate() { + out.push(( + *field, + Rect { + origin: Point2D::new(pop.origin.x + 80.0, start_y + i as f32 * ADJ_ROW_H + 15.0), + size: Point2D::new(86.0, 8.0), + }, + )); + } + out +} + +fn paint_mode_control( + cx: &mut PaintCx<'_>, + theme: &Theme, + pop: Rect, + active: op_editor_core::ImageFillMode, + locale: op_editor_core::Locale, +) { + let mode_y = pop.origin.y + HEADER_H; + let rect = Rect { + origin: Point2D::new(pop.origin.x + PANEL_PAD, mode_y), + size: Point2D::new(pop.size.x - PANEL_PAD * 2.0, MODE_H), + }; + cx.backend.fill_round_rect(rect, 6.0, theme.muted); + let chip_w = rect.size.x / op_editor_core::ImageFillMode::ALL.len() as f32; + for (i, mode) in op_editor_core::ImageFillMode::ALL.iter().enumerate() { + let chip = Rect { + origin: Point2D::new(rect.origin.x + i as f32 * chip_w + 2.0, rect.origin.y + 2.0), + size: Point2D::new(chip_w - 4.0, rect.size.y - 4.0), + }; + let is_active = *mode == active; + if is_active { + cx.backend.fill_round_rect(chip, 5.0, theme.primary); + } + paint_centered_label( + cx, + theme, + op_i18n::translate(locale, mode.label_key()), + chip, + if is_active { + theme.primary_foreground + } else { + theme.muted_foreground + }, + ); + } +} + +fn paint_upload( + cx: &mut PaintCx<'_>, + theme: &Theme, + pop: Rect, + summary: &op_editor_core::ImageFillSummary, + locale: op_editor_core::Locale, +) { + let rect = upload_rect(pop); + cx.backend.fill_round_rect(rect, 6.0, theme.muted); + let image_bytes = summary.image_url.as_deref().and_then(data_url_bytes); + if let (Some(src), Some(bytes)) = (summary.image_url.as_deref(), image_bytes.as_ref()) { + let preview = Rect { + origin: Point2D::new(rect.origin.x + 6.0, rect.origin.y + 6.0), + size: Point2D::new((rect.size.x - 12.0).max(0.0), (rect.size.y - 12.0).max(0.0)), + }; + cx.backend.save(); + cx.backend.clip_rect(rect); + cx.backend.draw_image(preview, src_hash(src), bytes); + cx.backend.restore(); + } else { + draw_icon( + cx.backend, + Icon::ImagePlus, + Point2D::new( + rect.origin.x + rect.size.x / 2.0 - 12.0, + rect.origin.y + 35.0, + ), + 24.0, + theme.muted_foreground, + 1.6, + ); + let label = if summary.has_image { + op_i18n::translate(locale, "image.title") + } else { + op_i18n::translate(locale, "image.clickToUpload") + }; + paint_centered_label( + cx, + theme, + label, + Rect { + origin: Point2D::new(rect.origin.x, rect.origin.y + 64.0), + size: Point2D::new(rect.size.x, 24.0), + }, + theme.muted_foreground, + ); + } + cx.backend.stroke_round_rect(rect, 6.0, theme.border, 1.0); +} + +fn data_url_bytes(src: &str) -> Option> { + let after_scheme = src.strip_prefix("data:")?; + let comma = after_scheme.find(',')?; + let meta = &after_scheme[..comma]; + let payload = &after_scheme[comma + 1..]; + if !meta.contains(";base64") { + return None; + } + let clean: String = payload + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + use base64::engine::general_purpose::STANDARD as B64; + use base64::Engine as _; + B64.decode(clean.as_bytes()).ok() +} + +fn src_hash(src: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut h); + h.finish() +} + +fn paint_adjustments( + cx: &mut PaintCx<'_>, + theme: &Theme, + pop: Rect, + summary: &op_editor_core::ImageFillSummary, + locale: op_editor_core::Locale, +) { + let divider_y = upload_rect(pop).origin.y + UPLOAD_H + 12.0; + cx.backend.fill_rect( + Rect { + origin: Point2D::new(pop.origin.x, divider_y), + size: Point2D::new(pop.size.x, 1.0), + }, + theme.border, + ); + let header_y = adjustments_header_y(pop); + paint_label( + cx, + theme, + op_i18n::translate(locale, "image.adjustments"), + pop.origin.x + PANEL_PAD, + header_y + 18.0, + 11.0, + theme.muted_foreground, + ); + if summary.has_adjustments() { + paint_label( + cx, + theme, + op_i18n::translate(locale, "image.reset"), + pop.origin.x + pop.size.x - 56.0, + header_y + 18.0, + 10.0, + theme.muted_foreground, + ); + } + let start_y = header_y + ADJ_HEADER_H; + for (i, field) in op_editor_core::ImageAdjustmentField::ALL.iter().enumerate() { + let row_y = start_y + i as f32 * ADJ_ROW_H; + let value = summary.adjustment(*field).clamp(-100.0, 100.0); + paint_label( + cx, + theme, + op_i18n::translate(locale, field.label_key()), + pop.origin.x + PANEL_PAD, + row_y + 21.0, + 11.0, + theme.muted_foreground, + ); + let track = Rect { + origin: Point2D::new(pop.origin.x + 80.0, row_y + 17.0), + size: Point2D::new(86.0, 4.0), + }; + cx.backend.fill_round_rect(track, 2.0, theme.border); + let fill_w = ((value + 100.0) / 200.0 * track.size.x).clamp(0.0, track.size.x); + cx.backend.fill_round_rect( + Rect { + origin: track.origin, + size: Point2D::new(fill_w, track.size.y), + }, + 2.0, + theme.primary, + ); + let knob_x = track.origin.x + fill_w; + cx.backend.fill_oval( + Rect { + origin: Point2D::new(knob_x - 6.0, track.origin.y - 4.0), + size: Point2D::new(12.0, 12.0), + }, + theme.foreground, + ); + paint_label( + cx, + theme, + &format!("{}", value.round() as i32), + pop.origin.x + pop.size.x - 30.0, + row_y + 21.0, + 11.0, + theme.muted_foreground, + ); + } +} + +fn paint_centered_label( + cx: &mut PaintCx<'_>, + theme: &Theme, + label: &str, + rect: Rect, + color: Color, +) { + let w = cx.backend.measure_text(label, 11.0); + paint_label( + cx, + theme, + label, + rect.origin.x + (rect.size.x - w) / 2.0, + rect.origin.y + rect.size.y / 2.0 + 4.0, + 11.0, + color, + ); +} + +fn paint_label( + cx: &mut PaintCx<'_>, + _theme: &Theme, + label: &str, + x: f32, + baseline_y: f32, + size: f32, + color: Color, +) { + let layout = TextLayout::single_run( + label, + "system-ui", + size, + to_jian_color(color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text(&layout, Point2D::new(x, baseline_y)); +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index d5ff07297..0a6481e26 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -499,11 +499,11 @@ pub fn action_button_rects_with_fill_picker( )); } if visible.fill_type == FillType::Image { - // Whole image-fill row opens the file picker — there's no - // other affordance inside it. + // Whole image-fill row opens the TS-parity image editor + // popover. The popover's upload well opens the file picker. let usable_w = w - PAD_X * 2.0; out.push(( - PropertyPanelAction::PickFillImage, + PropertyPanelAction::ToggleImageFillPopover, Rect { origin: Point2D::new(x0 + PAD_X, y), size: Point2D::new(usable_w, INPUT_HEIGHT), diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index b08150760..64c55cd0b 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -21,6 +21,10 @@ pub use crate::widgets::property_panel_effects::paint_effects_section; pub use crate::widgets::property_panel_fill::{ fill_type_label, paint_fill_section, paint_fill_type_picker, }; +pub use crate::widgets::property_panel_image_fill::{ + image_fill_popover_action_at, image_fill_popover_action_rects, + image_fill_popover_adjustment_action_for_drag, paint_image_fill_popover, +}; pub use crate::widgets::property_panel_inputs::format_color_hex as _format_color_hex_compat; /// Hit-result for a click on the property panel — payload is the diff --git a/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs b/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs new file mode 100644 index 000000000..154b5fea7 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs @@ -0,0 +1,328 @@ +//! Snapshot extraction for the right-rail `PropertyPanel`. + +use crate::layout_scene::{NodeKind, SceneStroke}; +use crate::Color; +use jian_ops_schema::node::PenNode; +use op_editor_core::pen_node_ext::PenNodeExt; +use op_editor_core::EditorState; + +/// Map a `PenNode` variant onto shell-core's `document::NodeKind`, +/// which drives the per-kind section-capability filtering. The +/// canonical schema's extra variants degrade onto the closest +/// shell-core kind (TextInput → Text; Image / IconFont / Ref → +/// `Other(tag)` so the section mask treats them structurally). +fn node_kind_of(node: &PenNode) -> NodeKind { + match node { + PenNode::Frame(_) => NodeKind::Frame, + PenNode::Group(_) => NodeKind::Group, + PenNode::Rectangle(_) => NodeKind::Rect, + PenNode::Ellipse(_) => NodeKind::Ellipse, + PenNode::Polygon(_) => NodeKind::Polygon, + PenNode::Line(_) => NodeKind::Line, + PenNode::Path(_) => NodeKind::Path, + PenNode::Text(_) | PenNode::TextInput(_) => NodeKind::Text, + PenNode::Image(_) => NodeKind::Other("image".to_string()), + PenNode::IconFont(_) => NodeKind::Other("icon_font".to_string()), + PenNode::Ref(_) => NodeKind::Other("ref".to_string()), + } +} + +/// Parse a `#RRGGBB` / `#RGB` hex string into a `Color`. Reuses the +/// editor-state colour parser; 8-char `#RRGGBBAA` is honoured so +/// gradient stop swatches (and any other authored alpha) round-trip +/// transparency into paint instead of always reading as opaque. +fn color_from_hex(hex: &str) -> Option { + let (r, g, b) = op_editor_core::parse_hex_rgb(hex)?; + let a = op_editor_core::parse_hex_alpha(hex); + Some(Color { r, g, b, a }) +} + +/// Snapshot of the selected node's editable fields, formatted for +/// display. Built once per `for_selection` call so all paint +/// helpers can read pre-computed strings instead of re-formatting. +#[derive(Debug, Clone)] +pub struct NodeSnapshot { + pub kind: String, + pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + /// Rotation in degrees (clockwise positive). + pub rotation_deg: f32, + /// Uniform corner radius in doc-px. + pub corner_radius: f32, + pub fill: Option, + /// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill + /// section's `100 %` paints `fill_opacity * 100`. + pub fill_opacity: f32, + pub stroke: Option, + /// LinearGradient angle in degrees (canonical `.op` convention, + /// 0° = bottom→top). `None` when the primary fill isn't a + /// linear gradient — the Fill section hides the angle row in + /// that case. + pub gradient_angle: Option, + /// Resolved primary-fill gradient stops, in authored order. + /// Populated for Linear + Radial fills; empty for Solid / Image + /// / no-fill. Each entry carries the schema hex string (so the + /// panel input can paint exactly what the file authored) plus + /// the parsed paint colour for the stop swatch. + pub gradient_stops: Vec, + /// Primary image-fill mode + adjustment values. `None` unless + /// the selected node's first fill is `PenFill::Image`. + pub image_fill: Option, + /// The node's visual effects, in paint order — drives the + /// Effects section's rows + param inputs. + pub effects: Vec, + /// Drives per-kind section filtering (Line hides fill, etc.). + pub kind_variant: crate::layout_scene::NodeKind, +} + +/// One gradient stop summary for the Fill section. +#[derive(Debug, Clone)] +pub struct GradientStopSummary { + /// Offset 0.0..=1.0 — the Fill panel paints `offset * 100` as + /// the per-stop `%` input. + pub offset: f32, + /// Schema hex string (`#RRGGBB` or `#RRGGBBAA`). The panel + /// paints this verbatim so a freshly-typed user value isn't + /// silently re-cased by `format_color_hex` round-trips. + pub hex: String, + /// Parsed paint colour for the per-row swatch. Falls back to + /// black when the hex fails to parse. + pub color: Color, +} + +/// Which visual-effect variant a row represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectKind { + Shadow, + Blur, + BackgroundBlur, +} + +impl EffectKind { + /// Human-readable row label. + pub fn label(self) -> &'static str { + match self { + EffectKind::Shadow => "Drop Shadow", + EffectKind::Blur => "Layer Blur", + EffectKind::BackgroundBlur => "Background Blur", + } + } +} + +/// One effect's editable scalar parameters, formatted for the +/// Effects section. Shadow uses all four; the blur kinds use `blur` +/// as the radius and leave offset / spread at 0. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EffectSummary { + pub kind: EffectKind, + pub offset_x: f32, + pub offset_y: f32, + pub blur: f32, + pub spread: f32, + /// Effect colour — Shadow carries an authored hex string; the + /// blur kinds don't have a colour field, so paint reads + /// `Color::TRANSPARENT` (and the colour row is hidden by the + /// effects-section painter when alpha is zero). + pub color: Color, +} + +impl EffectSummary { + /// Current value of one editable parameter — Blur / BackgroundBlur + /// keep their radius in `blur`, so `Blur` and `Radius` both read + /// that field. + pub fn param_value(&self, field: op_editor_core::EffectField) -> f32 { + use op_editor_core::EffectField as F; + match field { + F::OffsetX => self.offset_x, + F::OffsetY => self.offset_y, + F::Blur | F::Radius => self.blur, + F::Spread => self.spread, + } + } + + /// Summarise a canonical `PenEffect` for the panel. + fn from_pen_effect(e: &jian_ops_schema::style::PenEffect) -> Self { + use jian_ops_schema::style::PenEffect; + match e { + PenEffect::Shadow(s) => EffectSummary { + kind: EffectKind::Shadow, + offset_x: s.offset_x, + offset_y: s.offset_y, + blur: s.blur, + spread: s.spread, + color: color_from_hex(&s.color).unwrap_or(Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.25, + }), + }, + PenEffect::Blur(b) => EffectSummary { + kind: EffectKind::Blur, + offset_x: 0.0, + offset_y: 0.0, + blur: b.radius, + spread: 0.0, + color: Color::TRANSPARENT, + }, + PenEffect::BackgroundBlur(b) => EffectSummary { + kind: EffectKind::BackgroundBlur, + offset_x: 0.0, + offset_y: 0.0, + blur: b.radius, + spread: 0.0, + color: Color::TRANSPARENT, + }, + } + } +} + +impl NodeSnapshot { + /// Build an aggregate snapshot for a multi-node selection. + /// Returns None when nothing on the active page resolves from + /// `selected_set`. Uses `Document::selection_bounds` (the union + /// of every selected node's `aggregate_bounds`) for x/y/w/h. + /// Rotation / fill / stroke are zeroed in v1 — broadcasting + /// "Mixed" or per-axis aggregation is a follow-up; the panel + /// hides those inputs anyway since `is_multi` flips them + /// inert. + pub(crate) fn from_multi_selection(state: &EditorState) -> Option { + // Confirm at least 2 selected ids resolve on the active + // page — bails on cross-page selections but NOT on + // all-zero-size selections (matches single-select + // semantics, which paint the panel even for a 0x0 node). + if state.selection_count() < 2 { + return None; + } + // `selection_bounds` returns `None` when nothing resolves; + // an empty union still paints (zeroed) like single-select. + if state.selected_node().is_none() && state.selection_bounds().is_none() { + return None; + } + let bounds = state + .selection_bounds() + .unwrap_or(op_editor_core::DocRect::ZERO); + let n = state.selection_count(); + Some(Self { + kind: format!("{} items", n), + name: format!("{} selected", n), + x: bounds.x.round() as i32, + y: bounds.y.round() as i32, + width: bounds.w.round() as i32, + height: bounds.h.round() as i32, + rotation_deg: 0.0, + corner_radius: 0.0, + fill: None, + fill_opacity: 1.0, + stroke: None, + gradient_angle: None, + gradient_stops: Vec::new(), + image_fill: None, + // Multi-select shows no per-effect rows — the Effects + // section paints just its header + the add affordance. + effects: Vec::new(), + // `kind_variant` is informational for the snapshot + // header label only — the paint capability mask is + // driven by `SectionCapabilities::for_multi()` instead + // of `for_kind`, see `paint`. Frame chosen so any + // future kind-specific lookups paint a neutral default. + kind_variant: NodeKind::Frame, + }) + } + + /// Build the snapshot from a canonical `PenNode`. Geometry uses + /// `aggregate_bounds` so Group / unbounded container nodes report + /// the visual extent of their subtree instead of "0 × 0". + pub(crate) fn from_node(node: &PenNode) -> Self { + let base = node.base(); + let kind = node_kind_of(node); + let bounds = op_editor_core::aggregate_bounds(node); + // Corner radius — only the container variants carry one; + // a `PerCorner` radius reports its top-left corner. + let corner_radius = container_corner_radius(node); + let fill = op_editor_core::first_solid_fill_hex(node).and_then(color_from_hex); + let stroke = op_editor_core::first_solid_stroke_hex(node) + .and_then(color_from_hex) + .map(|color| SceneStroke { + color, + width: op_editor_core::fills::node_stroke_width(node).unwrap_or(1.0) as f32, + }); + Self { + kind: kind.label().to_string(), + name: base.name.clone().unwrap_or_default(), + x: bounds.x.round() as i32, + y: bounds.y.round() as i32, + width: bounds.w.round() as i32, + height: bounds.h.round() as i32, + // `base.rotation` is stored in degrees by the canonical + // schema; the snapshot's `rotation_deg` wants degrees. + rotation_deg: base.rotation.unwrap_or(0.0) as f32, + corner_radius, + fill, + fill_opacity: op_editor_core::first_solid_fill_opacity(node), + stroke, + gradient_angle: gradient_angle_of(node), + gradient_stops: gradient_stops_of(node), + image_fill: op_editor_core::first_image_fill_summary(node), + effects: op_editor_core::node_effects(node) + .iter() + .map(EffectSummary::from_pen_effect) + .collect(), + kind_variant: kind, + } + } +} + +/// LinearGradient `angle` for the node's first fill, when it has +/// one. Falls back to `0.0` (canonical default, bottom→top) when +/// the body omits an explicit angle. `None` for non-linear primary +/// fills — the Fill section uses that to hide the angle row. +fn gradient_angle_of(node: &PenNode) -> Option { + use jian_ops_schema::style::PenFill; + match op_editor_core::fills::node_fills(node).and_then(|f| f.first())? { + PenFill::LinearGradient(body) => Some(body.angle.unwrap_or(0.0)), + _ => None, + } +} + +/// Resolved stops for the primary Linear / Radial gradient — empty +/// list for Solid / Image / no-fill nodes. +fn gradient_stops_of(node: &PenNode) -> Vec { + use jian_ops_schema::style::PenFill; + let Some(first) = op_editor_core::fills::node_fills(node).and_then(|f| f.first()) else { + return Vec::new(); + }; + let raw = match first { + PenFill::LinearGradient(b) => &b.stops, + PenFill::RadialGradient(b) => &b.stops, + _ => return Vec::new(), + }; + raw.iter() + .map(|s| GradientStopSummary { + offset: s.offset.clamp(0.0, 1.0), + hex: s.color.clone(), + color: color_from_hex(&s.color).unwrap_or(Color::BLACK), + }) + .collect() +} + +/// Uniform corner radius (doc-px) for a container variant — Frame / +/// Group / Rectangle carry a `CornerRadius`. A `PerCorner` radius +/// reports its top-left value. Non-container variants read 0. +fn container_corner_radius(node: &PenNode) -> f32 { + use jian_ops_schema::node::container::CornerRadius; + let cr = match node { + PenNode::Frame(n) => n.container.corner_radius.as_ref(), + PenNode::Group(n) => n.container.corner_radius.as_ref(), + PenNode::Rectangle(n) => n.container.corner_radius.as_ref(), + _ => None, + }; + match cr { + Some(CornerRadius::Uniform(r)) => *r as f32, + Some(CornerRadius::PerCorner(c)) => c[0] as f32, + None => 0.0, + } +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_tests.rs index 068a97a92..06b5b03d0 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_tests.rs @@ -217,15 +217,20 @@ fn multi_selection_panel_shows_union_bounds_and_is_inert() { #[derive(Default)] struct CountingBackend { text: usize, + texts: Vec, round_rects: usize, + images: Vec<(Rect, u64, usize)>, } impl crate::RenderBackend for CountingBackend { fn begin_frame(&mut self) {} fn end_frame(&mut self) {} fn fill_rect(&mut self, _: Rect, _: Color) {} fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} - fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) { + fn draw_text(&mut self, layout: &crate::TextLayout, _: Point2D) { self.text += 1; + if let Some(run) = layout.runs().first() { + self.texts.push(run.content.clone()); + } } fn clip_rect(&mut self, _: Rect) {} fn save(&mut self) {} @@ -237,6 +242,9 @@ impl crate::RenderBackend for CountingBackend { } fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} + fn draw_image(&mut self, rect: Rect, image_id: u64, encoded: &[u8]) { + self.images.push((rect, image_id, encoded.len())); + } fn resize(&mut self, _: u32, _: u32) {} fn dpi_scale(&self) -> f32 { 1.0 @@ -311,3 +319,171 @@ fn multi_select_panel_shows_even_when_all_zero_size() { assert_eq!(panel.snapshot.width, 0); assert_eq!(panel.snapshot.height, 0); } + +fn image_fill_state_with_url(url: &str) -> EditorState { + let mut state = state_from(&format!( + r##"{{ "version": "0.8.0", "children": [ + {{"type":"rectangle","id":"n60","name":"Photo fill", + "x":40,"y":40,"width":180,"height":120, + "fill":[{{"type":"image","url":"{}","mode":"fill", + "exposure":0,"contrast":0,"saturation":0, + "temperature":0,"tint":0,"highlights":0,"shadows":0}}]}} + ]}}"##, + url + )); + state.set_single_selection(NodeId::new("n60")); + state +} + +fn image_fill_state() -> EditorState { + image_fill_state_with_url("") +} + +#[test] +fn image_fill_body_click_opens_the_image_popover() { + let state = image_fill_state(); + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + let rects = sections::action_button_rects_with_fill_picker( + rect, + visible_for(&panel), + &panel.snapshot.effects, + false, + false, + false, + ); + let body = rects + .iter() + .find(|(a, _)| matches!(a, PropertyPanelAction::ToggleImageFillPopover)) + .map(|(_, r)| *r) + .expect("image fill body emits popover toggle action"); + let center = Point2D::new( + body.origin.x + body.size.x / 2.0, + body.origin.y + body.size.y / 2.0, + ); + assert!( + matches!( + panel.hit_test_action(rect, center), + Some(PropertyPanelAction::ToggleImageFillPopover) + ), + "image fill body click should open the image editor popover", + ); +} + +#[test] +fn open_image_fill_popover_paints_selected_image_preview() { + const PNG_DATA_URL: &str = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + let mut state = image_fill_state_with_url(PNG_DATA_URL); + state.editor_ui.image_fill_popover_open = true; + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + assert_eq!( + panel + .snapshot + .image_fill + .as_ref() + .unwrap() + .image_url + .as_deref(), + Some(PNG_DATA_URL), + ); + + let mut backend = CountingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + panel.paint(&mut cx, rect); + panel.paint_overlays(&mut cx, rect); + } + assert!( + backend.images.iter().any(|(_, _, bytes)| *bytes > 0), + "selected image data URL should be decoded and painted in the upload well", + ); +} + +#[test] +fn image_fill_adjustment_reset_label_uses_i18n() { + let mut state = image_fill_state(); + state.editor_ui.image_fill_popover_open = true; + state.editor_ui.locale = op_editor_core::Locale::ZhCn; + assert!( + state.set_selected_image_adjustment(op_editor_core::ImageAdjustmentField::Exposure, 36.0) + ); + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + + let mut backend = CountingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint_overlays( + &mut cx, + Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }, + ); + } + assert!(backend.texts.iter().any(|s| s == "重置")); + assert!(!backend.texts.iter().any(|s| s == "Reset")); +} + +#[test] +fn open_image_fill_popover_routes_upload_and_mode_actions() { + let mut state = image_fill_state(); + state.editor_ui.image_fill_popover_open = true; + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + let popup_rects = + sections::image_fill_popover_action_rects(rect, visible_for(&panel), &panel.snapshot); + let upload = popup_rects + .iter() + .find(|(a, _)| matches!(a, PropertyPanelAction::PickFillImage)) + .map(|(_, r)| *r) + .expect("open image popover exposes an upload hit rect"); + let upload_center = Point2D::new( + upload.origin.x + upload.size.x / 2.0, + upload.origin.y + upload.size.y / 2.0, + ); + assert!( + matches!( + panel.hit_test_action(rect, upload_center), + Some(PropertyPanelAction::PickFillImage) + ), + "upload well should trigger the image file picker", + ); + let crop = popup_rects + .iter() + .find(|(a, _)| { + matches!( + a, + PropertyPanelAction::SetImageFillMode(op_editor_core::ImageFillMode::Crop) + ) + }) + .map(|(_, r)| *r) + .expect("open image popover exposes fit-mode hit rects"); + let crop_center = Point2D::new( + crop.origin.x + crop.size.x / 2.0, + crop.origin.y + crop.size.y / 2.0, + ); + assert!( + matches!( + panel.hit_test_action(rect, crop_center), + Some(PropertyPanelAction::SetImageFillMode( + op_editor_core::ImageFillMode::Crop + )) + ), + "fit-mode chips should dispatch mode updates", + ); +} diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index fd430ded5..102a0ddd0 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -126,6 +126,9 @@ pub struct WidgetHostNative { pub(in crate::widget_host) design_md_drag: Option, /// Active Component-Browser panel drag. pub(in crate::widget_host) component_browser_drag: Option, + /// Active image-fill adjustment slider drag in the floating + /// property popover. + pub(in crate::widget_host) image_adjustment_drag: Option, /// Active panel-resize drag — set when the cursor is pressed /// within the resize gutter of LayerPanel's right edge or /// PropertyPanel's left edge. @@ -391,6 +394,7 @@ impl WidgetHostNative { chat_drag: None, design_md_drag: None, component_browser_drag: None, + image_adjustment_drag: None, panel_resize: None, node_drag: None, path_anchor_drag: None, diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index ee52b06f2..f08b24103 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -160,6 +160,26 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if let Some(field) = self.image_adjustment_drag { + if let Some(panel) = + op_editor_ui::widgets::PropertyPanel::for_selection(&self.editor_state) + { + let property_rect = Rect { + origin: Point2D::new( + self.last_viewport_w - self.editor_state.editor_ui.property_panel_width, + op_editor_ui::widgets::TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (self.last_viewport_h - op_editor_ui::widgets::TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.image_adjustment_drag_action(property_rect, field, x) { + self.apply_property_action(action); + return true; + } + } + } // Pen rubber-band — track cursor doc coord for preview. if self.editor_state.ui.pen_in_progress.is_some() { let (cx0, cy0) = self.canvas_origin(); @@ -565,6 +585,9 @@ impl WidgetHostNative { if self.component_browser_drag.take().is_some() { return true; } + if self.image_adjustment_drag.take().is_some() { + return true; + } if let Some(m) = self.marquee_drag.take() { self.commit_marquee_selection(m, viewport_w, viewport_h); return true; @@ -644,6 +667,9 @@ impl WidgetHostNative { if self.component_browser_drag.take().is_some() { return true; } + if self.image_adjustment_drag.take().is_some() { + return true; + } let was_dragging = self.drag.is_some(); self.drag = None; was_dragging diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index e40de9b3a..d23b8f741 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -89,6 +89,61 @@ fn escape_closes_one_overlay_per_press_in_priority_order() { assert!(!host.apply_escape()); } +#[test] +fn pick_fill_image_keeps_image_popover_open_for_mode_selection() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.image_fill_popover_open = true; + + host.apply_property_action(op_editor_ui::widgets::PropertyPanelAction::PickFillImage); + + assert_eq!( + host.editor_state().editor_ui.pending_file_action, + Some(op_editor_core::editor_ui_state::FileAction::PickFillImage), + ); + assert!( + host.editor_state().editor_ui.image_fill_popover_open, + "the image popover must stay open so Fill/Fit/Crop/Tile remain selectable", + ); +} + +#[test] +fn image_adjustment_drag_updates_live_after_press() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r##"{ "version": "0.8.0", "children": [ + {"type":"rectangle","id":"n60","name":"Photo fill", + "x":40,"y":40,"width":180,"height":120, + "fill":[{"type":"image","url":"","mode":"fill", + "exposure":0,"contrast":0,"saturation":0, + "temperature":0,"tint":0,"highlights":0,"shadows":0}]} + ]}"##, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("n60")); + host.editor_state_mut().editor_ui.image_fill_popover_open = true; + host.image_adjustment_drag = Some(op_editor_core::ImageAdjustmentField::Exposure); + host.last_viewport_w = 900.0; + host.last_viewport_h = 760.0; + + assert!(host.apply_cursor_move(0.0, 0.0)); + + let node = host + .editor_state() + .selected_node() + .expect("selected image-fill node"); + match op_editor_core::fills::node_fills(node) + .unwrap() + .first() + .unwrap() + { + jian_ops_schema::style::PenFill::Image(body) => { + assert_eq!(body.exposure, Some(-100.0)); + } + other => panic!("expected image fill, got {other:?}"), + } +} + #[test] fn backspace_with_property_draft_does_not_delete_selected() { // With a non-empty property draft buffer, Backspace must pop a diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index df0db4460..e074e78ea 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -681,6 +681,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.editor_state.editor_ui.image_fill_popover_open { + self.editor_state.editor_ui.image_fill_popover_open = false; + self.mark_dirty(); + return true; + } if self.editor_state.chat.focused { self.editor_state.chat.focused = false; self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 5a95a92c3..6948857c0 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -99,7 +99,27 @@ impl WidgetHostNative { layer_panel.paint(&mut cx, layer_panel_rect); } - // 4. PropertyPanel — only when selection. + // 4. CanvasViewport — middle band, respects sidebar + // collapse state. It paints before the right rail so + // PropertyPanel popovers can extend into the canvas. + let (canvas_left, _canvas_y, canvas_w, canvas_h) = + self.canvas_region(viewport_width, viewport_height); + let canvas_rect = Rect { + origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), + size: Point2D::new(canvas_w, canvas_h), + }; + if canvas_w > 0.0 && canvas_h > 0.0 { + // PAINT path — the canvas reads editor state + the + // layout-resolved render scene (`refresh_layout_scene`). + let mut canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); + canvas.now_ms = self.now_ms; + let mut cx = PaintCx { + backend: &mut *frame, + }; + canvas.paint(&mut cx, canvas_rect); + } + + // 5. PropertyPanel — only when selection. let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); let has_property = property_panel.is_some(); let property_panel_width = ui.property_panel_width; @@ -118,7 +138,7 @@ impl WidgetHostNative { panel.paint(&mut cx, property_rect); } - // 4b. VariablesPanel — paints whenever the document has + // 5b. VariablesPanel — paints whenever the document has // variables (so users with themed `.op` files see them // immediately without needing to select a node). Sits in // the same right-rail column as PropertyPanel. When a @@ -157,26 +177,6 @@ impl WidgetHostNative { vars.paint(&mut cx, vars_rect); } - // 5. CanvasViewport — middle band, respects sidebar - // collapse state. - let (canvas_left, _canvas_y, canvas_w, canvas_h) = - self.canvas_region(viewport_width, viewport_height); - let _ = has_property; - let canvas_rect = Rect { - origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), - size: Point2D::new(canvas_w, canvas_h), - }; - if canvas_w > 0.0 && canvas_h > 0.0 { - // PAINT path — the canvas reads editor state + the - // layout-resolved render scene (`refresh_layout_scene`). - let mut canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); - canvas.now_ms = self.now_ms; - let mut cx = PaintCx { - backend: &mut *frame, - }; - canvas.paint(&mut cx, canvas_rect); - } - // 6. Toolbar — floating column. let toolbar = Toolbar::for_editor(&self.editor_state); let toolbar_h = toolbar @@ -286,6 +286,23 @@ impl WidgetHostNative { } } + // 8.6. PropertyPanel overlays — painted after canvas floating + // controls so the image-fill popover can cover the zoom + // status pill when it extends into the canvas. + if let Some(panel) = property_panel.as_ref() { + let property_rect = Rect { + origin: Point2D::new(right_rail_x, TOP_BAR_HEIGHT), + size: Point2D::new( + property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + let mut cx = PaintCx { + backend: &mut *frame, + }; + panel.paint_overlays(&mut cx, property_rect); + } + // 9. ShapePicker — anchored to the right of the toolbar // shape slot; same z-priority as the locale picker. if ui.shape_picker_open { diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index d6ce31357..6b21c1daf 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -331,6 +331,13 @@ impl WidgetHostNative { return rename_committed || text_edit_committed; } + // 0c0a. Image-fill popover — outside-click dismiss. + if !in_git_panel + && self.dismiss_image_fill_popover_on_press(x, y, viewport_width, viewport_height) + { + return true; + } + // 0c0. Fill-type picker — outside-click dismiss. if self.editor_state.editor_ui.fill_type_picker_open && !in_git_panel { self.refresh_layout_scene(); diff --git a/crates/op-host-native/src/widget_host/property_dispatch.rs b/crates/op-host-native/src/widget_host/property_dispatch.rs index bbb17dc0a..dac65d2e3 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -46,12 +46,37 @@ impl WidgetHostNative { } } A::ToggleFillTypePicker => { - let v = &mut self.editor_state.editor_ui.fill_type_picker_open; - *v = !*v; + let ui = &mut self.editor_state.editor_ui; + ui.fill_type_picker_open = !ui.fill_type_picker_open; + ui.image_fill_popover_open = false; } A::SetFillType(t) => { self.editor_state.set_selected_fill_type(t); self.editor_state.editor_ui.fill_type_picker_open = false; + self.editor_state.editor_ui.image_fill_popover_open = false; + } + A::ToggleImageFillPopover => { + let ui = &mut self.editor_state.editor_ui; + ui.image_fill_popover_open = !ui.image_fill_popover_open; + ui.fill_type_picker_open = false; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + } + A::CloseImageFillPopover => { + self.editor_state.editor_ui.image_fill_popover_open = false; + } + A::SetImageFillMode(mode) => { + let _ = self.editor_state.set_selected_image_fill_mode(mode); + } + A::SetImageAdjustment { field, value } => { + self.image_adjustment_drag = Some(field); + let _ = self + .editor_state + .set_selected_image_adjustment(field, value); + } + A::ResetImageAdjustments => { + self.image_adjustment_drag = None; + let _ = self.editor_state.reset_selected_image_adjustments(); } A::OpenColorPicker(target) => { // Fallback anchor when called outside the press path. @@ -157,6 +182,42 @@ impl WidgetHostNative { self.mark_dirty(); } + /// Image-fill popover outside-click dismiss. Returns `true` + /// when the popover was open and the press was consumed. + pub(in crate::widget_host) fn dismiss_image_fill_popover_on_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.image_fill_popover_open { + return false; + } + self.refresh_layout_scene(); + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + self.apply_property_action(action); + return true; + } + } + self.editor_state.editor_ui.image_fill_popover_open = false; + self.mark_dirty(); + true + } + /// Outside-click dismiss for the Export section's inline scale / /// format select popups. Returns `true` when a picker was open /// and the press was consumed — an option / toggle was applied, diff --git a/crates/op-host-native/tests/paint_order.rs b/crates/op-host-native/tests/paint_order.rs new file mode 100644 index 000000000..a801eaea1 --- /dev/null +++ b/crates/op-host-native/tests/paint_order.rs @@ -0,0 +1,41 @@ +#[test] +fn canvas_paints_before_property_panel_overlays() { + let source = std::fs::read_to_string(format!( + "{}/src/widget_host/paint.rs", + env!("CARGO_MANIFEST_DIR") + )) + .expect("paint source is readable"); + + let canvas = source + .find("CanvasViewport — middle band") + .expect("canvas paint block marker exists"); + let property = source + .find("PropertyPanel — only when selection") + .expect("property panel paint block marker exists"); + + assert!( + canvas < property, + "PropertyPanel must paint after CanvasViewport so popovers extending into the canvas are not covered" + ); +} + +#[test] +fn image_fill_popover_paints_above_status_bar() { + let source = std::fs::read_to_string(format!( + "{}/src/widget_host/paint.rs", + env!("CARGO_MANIFEST_DIR") + )) + .expect("paint source is readable"); + + let status = source + .find("StatusBar — floating bottom-right") + .expect("status paint block marker exists"); + let overlays = source + .find("PropertyPanel overlays") + .expect("property overlay paint block marker exists"); + + assert!( + status < overlays, + "image-fill popover must paint after StatusBar so the zoom pill cannot cover adjustment rows" + ); +} diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index b1c51ce6f..17ca3d21e 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -79,6 +79,9 @@ pub struct WidgetHost { pub(in crate::widget_host) theme: Theme, drag: Option, chat_drag: Option, + /// Active image-fill adjustment slider drag in the floating + /// property popover. + image_adjustment_drag: Option, /// Active marquee rect-select drag. Mirrors the native host — /// drag a rect on empty canvas with the Select tool, every /// intersecting top-level node joins (or extends) the @@ -172,6 +175,7 @@ impl WidgetHost { theme: Theme::dark(), drag: None, chat_drag: None, + image_adjustment_drag: None, marquee_drag: None, layer_drag: None, next_node_id: 100, @@ -424,6 +428,26 @@ impl WidgetHost { d.pos_y = y - d.grab_dy; return true; } + if let Some(field) = self.image_adjustment_drag { + if let Some(panel) = + op_editor_ui::widgets::PropertyPanel::for_selection(&self.editor_state) + { + let property_rect = Rect { + origin: Point2D::new( + self.last_viewport_w - self.editor_state.editor_ui.property_panel_width, + op_editor_ui::widgets::TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (self.last_viewport_h - op_editor_ui::widgets::TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.image_adjustment_drag_action(property_rect, field, x) { + self.apply_property_action(action); + return true; + } + } + } if let Some(drag) = self.drag.as_mut() { let dx = x - drag.last_x; let dy = y - drag.last_y; @@ -504,14 +528,15 @@ impl WidgetHost { // ADD-only: every hit joins the set; already-selected // hits stay selected. Shift-marquee never removes. for id in ids { - let ec_id = id.clone(); + let ec_id = op_editor_core::NodeId::new(&id); if !self.editor_state.is_selected(&ec_id) { self.editor_state.toggle_selection(ec_id); } } self.mark_dirty(); } else if !ids.is_empty() { - let ec_ids: Vec = ids.iter().map(|id| id.clone()).collect(); + let ec_ids: Vec = + ids.iter().map(op_editor_core::NodeId::new).collect(); let anchor = ec_ids.last().unwrap().clone(); self.editor_state.selection.set = ec_ids; self.editor_state.selection.anchor = anchor; @@ -544,6 +569,9 @@ impl WidgetHost { self.mark_dirty(); return true; } + if self.image_adjustment_drag.take().is_some() { + return true; + } let was_dragging = self.drag.is_some(); self.drag = None; was_dragging @@ -568,6 +596,9 @@ impl WidgetHost { if self.chat_drag.take().is_some() { return true; } + if self.image_adjustment_drag.take().is_some() { + return true; + } let was_dragging = self.drag.is_some(); self.drag = None; was_dragging @@ -737,7 +768,7 @@ impl WidgetHost { } /// Per-button hover wash on the floating toolbar. Mirrors - /// `op_host_native::widget_host::toolbar_hover::update_toolbar_hover`. + /// `op_host_native::widget_host::geometry::update_toolbar_hover`. /// Returns `true` if the hover state changed. fn update_toolbar_hover(&mut self, x: f32, y: f32) -> bool { let rect = self.toolbar_rect(self.last_viewport_w); diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index 8ba2127de..983893415 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -335,6 +335,11 @@ impl WidgetHost { self.mark_dirty(); return true; } + if self.editor_state.editor_ui.image_fill_popover_open { + self.editor_state.editor_ui.image_fill_popover_open = false; + self.mark_dirty(); + return true; + } if self.editor_state.chat.focused { self.editor_state.chat.focused = false; self.mark_dirty(); diff --git a/crates/op-host-web/src/widget_host/paint.rs b/crates/op-host-web/src/widget_host/paint.rs index e5384f6bd..1a4361e7d 100644 --- a/crates/op-host-web/src/widget_host/paint.rs +++ b/crates/op-host-web/src/widget_host/paint.rs @@ -105,21 +105,6 @@ impl WidgetHost { layer_panel.paint(&mut cx, layer_panel_rect); } - let property_panel = PropertyPanel::for_selection(&self.editor_state); - if let Some(panel) = property_panel.as_ref() { - let property_rect = Rect { - origin: Point2D::new(viewport_width - ui.property_panel_width, TOP_BAR_HEIGHT), - size: Point2D::new( - ui.property_panel_width, - (viewport_height - TOP_BAR_HEIGHT).max(0.0), - ), - }; - let mut cx = PaintCx { - backend: &mut *backend, - }; - panel.paint(&mut cx, property_rect); - } - let (canvas_left, _canvas_y, canvas_w, canvas_h) = self.canvas_region(viewport_width, viewport_height); let canvas_rect = Rect { @@ -137,6 +122,21 @@ impl WidgetHost { canvas.paint(&mut cx, canvas_rect); } + let property_panel = PropertyPanel::for_selection(&self.editor_state); + if let Some(panel) = property_panel.as_ref() { + let property_rect = Rect { + origin: Point2D::new(viewport_width - ui.property_panel_width, TOP_BAR_HEIGHT), + size: Point2D::new( + ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + let mut cx = PaintCx { + backend: &mut *backend, + }; + panel.paint(&mut cx, property_rect); + } + let toolbar = Toolbar::for_editor(&self.editor_state); let toolbar_h = toolbar .layout(&LayoutCx { @@ -224,6 +224,23 @@ impl WidgetHost { } } + // PropertyPanel overlays — painted after canvas floating + // controls so the image-fill popover can cover the zoom + // status pill when it extends into the canvas. + if let Some(panel) = property_panel.as_ref() { + let property_rect = Rect { + origin: Point2D::new(viewport_width - ui.property_panel_width, TOP_BAR_HEIGHT), + size: Point2D::new( + ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + let mut cx = PaintCx { + backend: &mut *backend, + }; + panel.paint_overlays(&mut cx, property_rect); + } + if ui.locale_picker_open { let picker_rect = self.locale_picker_rect(viewport_width); let picker = LocalePicker::for_editor_ui(&self.editor_state.editor_ui); diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 63d8557de..e62d38055 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -221,6 +221,29 @@ impl WidgetHost { return rename_committed || text_edit_committed; } + // 0c0a. Image-fill popover — outside-click dismiss. + if self.editor_state.editor_ui.image_fill_popover_open { + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + self.apply_property_action(action); + return true; + } + } + self.editor_state.editor_ui.image_fill_popover_open = false; + self.mark_dirty(); + return true; + } + // 0c0b. Export scale / format inline select popup — // outside-click dismiss. A click on a popup row or a // dropdown toggle is applied; any other click closes diff --git a/crates/op-host-web/src/widget_host/property_dispatch.rs b/crates/op-host-web/src/widget_host/property_dispatch.rs index b2c6bd7b7..9e6018f1b 100644 --- a/crates/op-host-web/src/widget_host/property_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_dispatch.rs @@ -34,13 +34,44 @@ impl WidgetHost { let v = &mut self.editor_state.editor_ui.size_clip_content; *v = !*v; } + A::CreateComponent => { + let id = self.editor_state.selection.anchor.clone(); + if id.is_real() { + let _ = self.editor_state.create_component_from_node_name(&id); + } + } A::ToggleFillTypePicker => { - let v = &mut self.editor_state.editor_ui.fill_type_picker_open; - *v = !*v; + let ui = &mut self.editor_state.editor_ui; + ui.fill_type_picker_open = !ui.fill_type_picker_open; + ui.image_fill_popover_open = false; } A::SetFillType(t) => { self.editor_state.set_selected_fill_type(t); self.editor_state.editor_ui.fill_type_picker_open = false; + self.editor_state.editor_ui.image_fill_popover_open = false; + } + A::ToggleImageFillPopover => { + let ui = &mut self.editor_state.editor_ui; + ui.image_fill_popover_open = !ui.image_fill_popover_open; + ui.fill_type_picker_open = false; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + } + A::CloseImageFillPopover => { + self.editor_state.editor_ui.image_fill_popover_open = false; + } + A::SetImageFillMode(mode) => { + let _ = self.editor_state.set_selected_image_fill_mode(mode); + } + A::SetImageAdjustment { field, value } => { + self.image_adjustment_drag = Some(field); + let _ = self + .editor_state + .set_selected_image_adjustment(field, value); + } + A::ResetImageAdjustments => { + self.image_adjustment_drag = None; + let _ = self.editor_state.reset_selected_image_adjustments(); } A::ToggleExportScalePicker => { let ui = &mut self.editor_state.editor_ui; diff --git a/crates/op-host-web/tests/paint_order.rs b/crates/op-host-web/tests/paint_order.rs new file mode 100644 index 000000000..4c746d6e8 --- /dev/null +++ b/crates/op-host-web/tests/paint_order.rs @@ -0,0 +1,41 @@ +#[test] +fn canvas_paints_before_property_panel_overlays() { + let source = std::fs::read_to_string(format!( + "{}/src/widget_host/paint.rs", + env!("CARGO_MANIFEST_DIR") + )) + .expect("paint source is readable"); + + let canvas = source + .find("CanvasViewport::from_editor") + .expect("canvas paint block marker exists"); + let property = source + .find("PropertyPanel::for_selection") + .expect("property panel paint block marker exists"); + + assert!( + canvas < property, + "PropertyPanel must paint after CanvasViewport so popovers extending into the canvas are not covered" + ); +} + +#[test] +fn image_fill_popover_paints_above_status_bar() { + let source = std::fs::read_to_string(format!( + "{}/src/widget_host/paint.rs", + env!("CARGO_MANIFEST_DIR") + )) + .expect("paint source is readable"); + + let status = source + .find("StatusBar::for_editor") + .expect("status paint block marker exists"); + let overlays = source + .find("PropertyPanel overlays") + .expect("property overlay paint block marker exists"); + + assert!( + status < overlays, + "image-fill popover must paint after StatusBar so the zoom pill cannot cover adjustment rows" + ); +}