fix(panels): keep image fill popover interactive
This commit is contained in:
parent
bfd1876538
commit
26501fb84a
|
|
@ -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<String>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<ImageFillSummary> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
159
crates/op-editor-core/src/property_panel_state.rs
Normal file
159
crates/op-editor-core/src/property_panel_state.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Self>`; `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<Color> {
|
||||
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<Color>,
|
||||
/// 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<SceneStroke>,
|
||||
/// 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<f32>,
|
||||
/// 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<GradientStopSummary>,
|
||||
/// The node's visual effects, in paint order — drives the
|
||||
/// Effects section's rows + param inputs.
|
||||
pub effects: Vec<EffectSummary>,
|
||||
/// 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<Self> {
|
||||
// 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<f32> {
|
||||
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<GradientStopSummary> {
|
||||
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<PropertyPanelAction> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: <data-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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
479
crates/op-editor-ui/src/widgets/property_panel_image_fill.rs
Normal file
479
crates/op-editor-ui/src/widgets/property_panel_image_fill.rs
Normal file
|
|
@ -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<Rect> {
|
||||
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<Rect> {
|
||||
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<PropertyPanelAction> {
|
||||
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<PropertyPanelAction> {
|
||||
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<Vec<u8>> {
|
||||
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));
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
328
crates/op-editor-ui/src/widgets/property_panel_snapshot.rs
Normal file
328
crates/op-editor-ui/src/widgets/property_panel_snapshot.rs
Normal file
|
|
@ -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<Color> {
|
||||
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<Color>,
|
||||
/// 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<SceneStroke>,
|
||||
/// 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<f32>,
|
||||
/// 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<GradientStopSummary>,
|
||||
/// Primary image-fill mode + adjustment values. `None` unless
|
||||
/// the selected node's first fill is `PenFill::Image`.
|
||||
pub image_fill: Option<op_editor_core::ImageFillSummary>,
|
||||
/// The node's visual effects, in paint order — drives the
|
||||
/// Effects section's rows + param inputs.
|
||||
pub effects: Vec<EffectSummary>,
|
||||
/// 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<Self> {
|
||||
// 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<f32> {
|
||||
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<GradientStopSummary> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -217,15 +217,20 @@ fn multi_selection_panel_shows_union_bounds_and_is_inert() {
|
|||
#[derive(Default)]
|
||||
struct CountingBackend {
|
||||
text: usize,
|
||||
texts: Vec<String>,
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ pub struct WidgetHostNative {
|
|||
pub(in crate::widget_host) design_md_drag: Option<DesignMdDragState>,
|
||||
/// Active Component-Browser panel drag.
|
||||
pub(in crate::widget_host) component_browser_drag: Option<ComponentBrowserDragState>,
|
||||
/// Active image-fill adjustment slider drag in the floating
|
||||
/// property popover.
|
||||
pub(in crate::widget_host) image_adjustment_drag: Option<op_editor_core::ImageAdjustmentField>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
41
crates/op-host-native/tests/paint_order.rs
Normal file
41
crates/op-host-native/tests/paint_order.rs
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -79,6 +79,9 @@ pub struct WidgetHost {
|
|||
pub(in crate::widget_host) theme: Theme,
|
||||
drag: Option<DragState>,
|
||||
chat_drag: Option<ChatDragState>,
|
||||
/// Active image-fill adjustment slider drag in the floating
|
||||
/// property popover.
|
||||
image_adjustment_drag: Option<op_editor_core::ImageAdjustmentField>,
|
||||
/// 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<op_editor_core::NodeId> = ids.iter().map(|id| id.clone()).collect();
|
||||
let ec_ids: Vec<op_editor_core::NodeId> =
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
41
crates/op-host-web/tests/paint_order.rs
Normal file
41
crates/op-host-web/tests/paint_order.rs
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue