perf(canvas): Arc-share image src/url to make scene-cache O(1)

Bump vendor/jian to the ImageSrc(Arc<str>) change: ImageNode.src and
ImageFillBody.url are now reference-counted, so the per-frame
SceneBuildCache document clone + content-compare settle in O(1) for an
unchanged multi-MB image source instead of walking the whole base64
payload. Completes the deeper half of the image-drag perf fix (the
insert-time down-scale landed in a084628f).

Consumer sites updated for the newtype: writes/constructs use .into();
sites assigning into a String field use .to_string(); reads are
unchanged via Deref. Also fixes a stale gl-host test
(remote_icon_insert_bakes_svg_d_as_path_node) that still assumed icon
insert appends to the end — it now locates the baked path by icon_id and
asserts it sits above the selection (the insert-above behavior).
This commit is contained in:
Kayshen-X 2026-06-21 10:01:37 +08:00
parent a922bd2d0b
commit ba6aae07dc
17 changed files with 44 additions and 32 deletions

View file

@ -360,7 +360,7 @@ pub fn first_image_fill_summary(node: &PenNode) -> Option<ImageFillSummary> {
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()),
image_url: (!trimmed_url.is_empty()).then(|| body.url.to_string()),
exposure: body.exposure.unwrap_or(0.0),
contrast: body.contrast.unwrap_or(0.0),
saturation: body.saturation.unwrap_or(0.0),
@ -630,7 +630,7 @@ fn default_fill_of_type(kind: FillType, hex: &str) -> PenFill {
blend_mode: None,
}),
FillType::Image => PenFill::Image(ImageFillBody {
url: String::new(),
url: "".into(),
mode: None,
original_size: None,
transform: None,

View file

@ -240,7 +240,7 @@ impl EditorState {
y: Some(centre_y - H / 2.0),
..Default::default()
},
src: src.to_string(),
src: src.into(),
object_fit: None,
width: Some(SizingBehavior::Number(W)),
height: Some(SizingBehavior::Number(H)),
@ -448,14 +448,14 @@ impl EditorState {
return false;
};
if let PenNode::Image(image) = node {
image.src = src.to_string();
image.src = src.into();
return true;
}
let Some(fills) = crate::fills::node_fills_mut(node) else {
return false;
};
let body = PenFill::Image(ImageFillBody {
url: src.to_string(),
url: src.into(),
mode: None,
original_size: None,
transform: None,

View file

@ -14,7 +14,7 @@ pub fn image_node_summary(node: &PenNode) -> Option<ImageFillSummary> {
Some(ImageFillSummary {
mode: ImageFillMode::from_image_node_schema(image.object_fit.as_ref()),
has_image: !trimmed_url.is_empty(),
image_url: (!trimmed_url.is_empty()).then(|| image.src.clone()),
image_url: (!trimmed_url.is_empty()).then(|| image.src.to_string()),
exposure: image.exposure.unwrap_or(0.0) as f32,
contrast: image.contrast.unwrap_or(0.0) as f32,
saturation: image.saturation.unwrap_or(0.0) as f32,

View file

@ -81,7 +81,7 @@ pub fn image_panel_view(state: &EditorState, node: &PenNode) -> Option<ImagePane
};
let node_id = image.base.id.as_str().to_string();
let name = image.base.name.clone().unwrap_or_default();
let src = (!image.src.trim().is_empty()).then(|| image.src.clone());
let src = (!image.src.trim().is_empty()).then(|| image.src.to_string());
let warning = src
.as_deref()
.filter(|s| is_local_asset_path(s))

View file

@ -122,7 +122,7 @@ fn parse_clipboard_html_styles_handles_background_color() {
fn fix_unresolved_images_swaps_blob_image_for_placeholder_rect() {
let mut nodes = vec![PenNode::Image(ImageNode {
base: base(),
src: "__blob:5".to_string(),
src: "__blob:5".into(),
object_fit: None,
width: Some(SizingBehavior::Number(100.0)),
height: Some(SizingBehavior::Number(80.0)),

View file

@ -48,7 +48,7 @@ fn patch_fills(
if let PenFill::Image(img) = fill {
if img.url.starts_with("__blob:") || img.url.starts_with("__hash:") {
if let Some(url) = resolve_ref(&img.url, data_urls, hash_urls) {
img.url = url;
img.url = url.into();
count += 1;
}
}

View file

@ -134,7 +134,7 @@ fn map_single_fill(paint: &FigValue) -> Option<PenFill> {
})
.unwrap_or_default();
Some(PenFill::Image(ImageFillBody {
url,
url: url.into(),
mode: Some(map_scale_mode(paint.get_str("imageScaleMode"))),
original_size: normalize_original_size(
paint.get_f64("originalImageWidth"),

View file

@ -203,7 +203,7 @@ impl ImagePanelJobs {
fn selected_image_src(host: &WidgetHostNative) -> Option<(String, String)> {
match host.editor_state().selected_node() {
Some(PenNode::Image(image)) => Some((image.base.id.clone(), image.src.clone())),
Some(PenNode::Image(image)) => Some((image.base.id.clone(), image.src.to_string())),
_ => None,
}
}

View file

@ -598,12 +598,12 @@ pub(crate) fn apply_result(state: &mut EditorState, node_id: &NodeId, url: &str)
if image.src == url {
return false;
}
image.src = url.to_string();
image.src = url.into();
true
}
PenNode::Frame(frame) if is_unfilled_placeholder_frame => {
frame.container.fill = Some(vec![PenFill::Image(ImageFillBody {
url: url.to_string(),
url: url.into(),
mode: Some(ImageFillMode::Crop),
original_size: None,
transform: None,
@ -622,7 +622,7 @@ pub(crate) fn apply_result(state: &mut EditorState, node_id: &NodeId, url: &str)
}
PenNode::Rectangle(rect) if is_unfilled_placeholder_rectangle => {
rect.container.fill = Some(vec![PenFill::Image(ImageFillBody {
url: url.to_string(),
url: url.into(),
mode: Some(ImageFillMode::Crop),
original_size: None,
transform: None,

View file

@ -13,7 +13,7 @@ fn image_node(id: &str, src: &str, query: Option<&str>) -> PenNode {
name: Some("Menu photo".into()),
..Default::default()
},
src: src.to_string(),
src: src.into(),
object_fit: None,
width: Some(SizingBehavior::Number(240.0)),
height: Some(SizingBehavior::Number(160.0)),

View file

@ -172,7 +172,7 @@ impl WidgetHostNative {
if let Some(PenNode::Image(image)) =
op_editor_core::walkers::find_node_mut(self.editor_state.active_children_mut(), &id)
{
image.src = src.to_string();
image.src = src.into();
}
self.mark_editor_state_dirty();
}

View file

@ -228,15 +228,27 @@ fn remote_icon_insert_bakes_svg_d_as_path_node() {
let children = host.editor_state().active_children();
assert_eq!(children.len(), before + 1, "insert landed");
match children.last().expect("inserted node") {
PenNode::Path(p) => {
assert_eq!(
p.d.as_deref(),
Some("M3 9l9-7 9 7v11h-6v-7H9v7H3z"),
"the remote icon's d is baked (no fallback dot)"
);
assert_eq!(p.icon_id.as_deref(), Some("mdi:zwxq-home"));
}
other => panic!("expected a Path node with baked d, got {}", other.id_str()),
}
// The icon inserts ABOVE the selected node (`inst1`), so it is no
// longer the last child — locate the baked path by its icon id.
let path_idx = children
.iter()
.position(|n| matches!(n, PenNode::Path(p) if p.icon_id.as_deref() == Some("mdi:zwxq-home")))
.expect("baked remote icon path was inserted");
let inst1_idx = children
.iter()
.position(|n| n.id_str() == "inst1")
.expect("selection still present");
assert!(
path_idx < inst1_idx,
"the inserted icon sits above the selected node"
);
let PenNode::Path(p) = &children[path_idx] else {
unreachable!("path_idx points at the matched Path");
};
assert_eq!(
p.d.as_deref(),
Some("M3 9l9-7 9 7v11h-6v-7H9v7H3z"),
"the remote icon's d is baked (no fallback dot)"
);
assert_eq!(p.icon_id.as_deref(), Some("mdi:zwxq-home"));
}

View file

@ -658,7 +658,7 @@ fn relink_image<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
if let Some(jian_ops_schema::node::PenNode::Image(image)) =
op_editor_core::walkers::find_node_mut(state.active_children_mut(), &id)
{
image.src = url;
image.src = url.into();
}
state.editor_ui.image_panel.asset_check = None;
b.host_mut().mark_editor_state_dirty();

View file

@ -156,7 +156,7 @@ impl WidgetHost {
if let Some(PenNode::Image(image)) =
op_editor_core::walkers::find_node_mut(self.editor_state.active_children_mut(), &id)
{
image.src = src.to_string();
image.src = src.into();
}
self.mark_dirty();
}

View file

@ -789,7 +789,7 @@ fn image_to_payload(n: &ImageNode) -> NodePayload {
// draw the bitmap. `fill` stays at a neutral grey so the
// placeholder reads correctly when the bytes fail to decode
// (corrupt url / unsupported codec).
p.image_src = Some(n.src.clone());
p.image_src = Some(n.src.to_string());
p.image_fit = n.object_fit.as_ref().map(image_node_fit_to_payload);
p.image_adjustments = image_node_adjustments(n);
p.fill = Some([0.85, 0.86, 0.88, 1.0]);

View file

@ -106,7 +106,7 @@ fn first_image_fill(
None
} else {
Some((
body.url.clone(),
body.url.to_string(),
image_fill_mode_to_payload(body.mode.as_ref()),
image_fill_adjustments(body),
))

2
vendor/jian vendored

@ -1 +1 @@
Subproject commit d50a7157ece8e1f880f2b64dd396540de7eec1f3
Subproject commit 2623df52265bf124f08319691c57134d8a340a69