fix(panels): keep image fill popover interactive

This commit is contained in:
Kayshen-X 2026-05-24 14:01:39 +08:00
parent bfd1876538
commit 26501fb84a
29 changed files with 1885 additions and 462 deletions

View file

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

View file

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

View file

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

View file

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

View 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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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));
}

View file

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

View file

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

View 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,
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
);
}