@@ -258,6 +258,82 @@ pub fn set_text_content(html: &str, pointer: &str, text: &str) -> Option<String>
258258 Some ( serialize_element ( & root) )
259259}
260260
261+ /// Set or replace a plain attribute on the element addressed by the JSON
262+ /// pointer (into the transpiled scenario), returning the rewritten HTML. Used
263+ /// by the studio inspector for component root fields (`<rm-counter from=…>`).
264+ /// Attributes are strings; the transpiler's coercion re-types them on load.
265+ /// An EMPTY `value` REMOVES the attribute (unset, not empty-string — the
266+ /// transpiler skips empty attributes anyway). `anim`, `style` and every other
267+ /// attribute are preserved (mirrors [`set_inline_style`]). Returns `None` if
268+ /// the pointer doesn't resolve to an element.
269+ pub fn set_attribute ( html : & str , pointer : & str , name : & str , value : & str ) -> Option < String > {
270+ let dom = parse_fragment_dom ( html) ;
271+ let root = find_element ( & dom. document , "rustmotion" ) ?;
272+ let target = resolve_pointer ( & root, pointer) ?;
273+ set_attr ( & target, name, value) ?;
274+ Some ( serialize_element ( & root) )
275+ }
276+
277+ /// Remove one inline `style` property from the element addressed by the JSON
278+ /// pointer, returning the rewritten HTML (an emptied inspector control unsets
279+ /// the declaration). Other declarations and attributes are preserved. Returns
280+ /// `None` if the pointer doesn't resolve to an element.
281+ pub fn remove_inline_style ( html : & str , pointer : & str , prop : & str ) -> Option < String > {
282+ let dom = parse_fragment_dom ( html) ;
283+ let root = find_element ( & dom. document , "rustmotion" ) ?;
284+ let target = resolve_pointer ( & root, pointer) ?;
285+ remove_style_decl ( & target, prop) ?;
286+ Some ( serialize_element ( & root) )
287+ }
288+
289+ /// Upsert (or, for an empty value, remove) a plain attribute on an element.
290+ fn set_attr ( handle : & Handle , name : & str , value : & str ) -> Option < ( ) > {
291+ let NodeData :: Element { attrs, .. } = & handle. data else {
292+ return None ;
293+ } ;
294+ let mut attrs = attrs. borrow_mut ( ) ;
295+ if value. is_empty ( ) {
296+ attrs. retain ( |a| a. name . local . as_ref ( ) != name) ;
297+ return Some ( ( ) ) ;
298+ }
299+ if let Some ( a) = attrs. iter_mut ( ) . find ( |a| a. name . local . as_ref ( ) == name) {
300+ a. value = value. into ( ) ;
301+ } else {
302+ attrs. push ( Attribute {
303+ name : QualName :: new ( None , ns ! ( ) , name. into ( ) ) ,
304+ value : value. into ( ) ,
305+ } ) ;
306+ }
307+ Some ( ( ) )
308+ }
309+
310+ /// Drop one `prop: value` declaration from an element's `style` attribute.
311+ fn remove_style_decl ( handle : & Handle , prop : & str ) -> Option < ( ) > {
312+ let NodeData :: Element { attrs, .. } = & handle. data else {
313+ return None ;
314+ } ;
315+ let mut attrs = attrs. borrow_mut ( ) ;
316+ let Some ( a) = attrs. iter_mut ( ) . find ( |a| a. name . local . as_ref ( ) == "style" ) else {
317+ return Some ( ( ) ) ; // no style attribute → nothing to remove
318+ } ;
319+ let kept: Vec < String > = a
320+ . value
321+ . split ( ';' )
322+ . filter_map ( |decl| {
323+ let decl = decl. trim ( ) ;
324+ let ( k, v) = decl. split_once ( ':' ) ?;
325+ let k = k. trim ( ) ;
326+ if k == prop || k. is_empty ( ) {
327+ None
328+ } else {
329+ Some ( format ! ( "{k}:{}" , v. trim( ) ) )
330+ }
331+ } )
332+ . collect ( ) ;
333+ a. value = kept. join ( "; " ) . as_str ( ) . into ( ) ;
334+ Some ( ( ) )
335+ }
336+
261337/// Replace an element's children with a single text node.
262338fn set_text ( handle : & Handle , text : & str ) -> Option < ( ) > {
263339 if !matches ! ( handle. data, NodeData :: Element { .. } ) {
@@ -448,6 +524,51 @@ mod lib_tests {
448524 assert ! ( crate :: html_to_scenario_value( "<div>no root</div>" ) . is_err( ) ) ;
449525 }
450526
527+ // --- set_attribute (studio schema inspector) ---
528+
529+ #[ test]
530+ fn set_attribute_updates_counter_from_and_retypes_on_transpile ( ) {
531+ let html = r##"<rustmotion width="100" height="100"><scene duration="2"><rm-counter from="0" to="100" anim="fade-in" style="font-size:64; color:#fff"></rm-counter></scene></rustmotion>"## ;
532+ let out = crate :: set_attribute ( html, "/scenes/0/children/0" , "from" , "250" ) . unwrap ( ) ;
533+ let v = crate :: html_to_scenario_value ( & out) . unwrap ( ) ;
534+ let child = & v[ "scenes" ] [ 0 ] [ "children" ] [ 0 ] ;
535+ // The attribute string is re-typed to a number by the transpiler.
536+ assert_eq ! ( child[ "from" ] , json!( 250 ) ) ;
537+ assert ! ( child[ "from" ] . is_number( ) ) ;
538+ // Other attributes are preserved: to, anim (→ style.animation), style.
539+ assert_eq ! ( child[ "to" ] , json!( 100 ) ) ;
540+ assert_eq ! ( child[ "style" ] [ "font-size" ] , json!( 64 ) ) ;
541+ assert_eq ! ( child[ "style" ] [ "color" ] , json!( "#fff" ) ) ;
542+ assert_eq ! ( child[ "style" ] [ "animation" ] [ 0 ] [ "name" ] , json!( "fade_in" ) ) ;
543+ }
544+
545+ #[ test]
546+ fn set_attribute_inserts_when_absent ( ) {
547+ let html = r##"<rustmotion width="100" height="100"><scene duration="2"><rm-counter from="0" to="10"></rm-counter></scene></rustmotion>"## ;
548+ let out = crate :: set_attribute ( html, "/scenes/0/children/0" , "suffix" , "%" ) . unwrap ( ) ;
549+ let v = crate :: html_to_scenario_value ( & out) . unwrap ( ) ;
550+ assert_eq ! ( v[ "scenes" ] [ 0 ] [ "children" ] [ 0 ] [ "suffix" ] , json!( "%" ) ) ;
551+ }
552+
553+ #[ test]
554+ fn set_attribute_empty_value_removes_the_attribute ( ) {
555+ let html = r##"<rustmotion width="100" height="100"><scene duration="2"><rm-counter from="0" to="10" suffix="%"></rm-counter></scene></rustmotion>"## ;
556+ let out = crate :: set_attribute ( html, "/scenes/0/children/0" , "suffix" , "" ) . unwrap ( ) ;
557+ assert ! ( !out. contains( "suffix" ) , "attribute removed: {out}" ) ;
558+ let v = crate :: html_to_scenario_value ( & out) . unwrap ( ) ;
559+ assert ! ( v[ "scenes" ] [ 0 ] [ "children" ] [ 0 ] . get( "suffix" ) . is_none( ) ) ;
560+ }
561+
562+ #[ test]
563+ fn remove_inline_style_drops_only_that_declaration ( ) {
564+ let html = r##"<rustmotion width="100" height="100"><scene duration="2"><h1 style="font-size:96; color:#fff">Hi</h1></scene></rustmotion>"## ;
565+ let out = crate :: remove_inline_style ( html, "/scenes/0/children/0" , "color" ) . unwrap ( ) ;
566+ let v = crate :: html_to_scenario_value ( & out) . unwrap ( ) ;
567+ let style = & v[ "scenes" ] [ 0 ] [ "children" ] [ 0 ] [ "style" ] ;
568+ assert ! ( style. get( "color" ) . is_none( ) , "color removed" ) ;
569+ assert_eq ! ( style[ "font-size" ] , json!( 96 ) , "other declarations kept" ) ;
570+ }
571+
451572 // --- anim round-trip (studio) ---
452573
453574 #[ test]
0 commit comments