//! Layout conversion between a slide's two modes (spec §2.3). //! //! A `flow` slide places blocks by `region` (`heading`, `body`, `aside`, //! `media`); a `canvas` slide places them by an absolute `box` and `z`. //! Switching a slide's `layout` (`ops::apply_one`'s `SetSlide` arm) calls //! [`to_canvas`] or [`to_flow`] here to carry blocks across, lossily in //! both directions as the spec says. Everything else in `ops.rs` — op //! parsing, the validation model, id rewriting, mutation — stays there; //! this module is just the geometry. use crate::ops::blocks_of; use crate::schema; use std::collections::HashMap; use yrs::{Any, Map, MapRef, ReadTxn, TransactionMut}; /// The region a block lands in when nobody said: headings in `heading`, /// images in `media`, the rest in `body`. pub(crate) fn default_region(kind: &str) -> &'static str { match kind { "heading" => "heading", "image" => "media", _ => "body", } } /// The frame fraction each region occupies: `(x, y, w, h)`. `body` /// narrows to make room when the slide has an `aside`. pub(crate) fn region_rect(region: &str, has_aside: bool) -> (f64, f64, f64, f64) { match region { "heading" => (0.06, 0.08, 0.88, 0.18), "aside" => (0.54, 0.30, 0.40, 0.62), "media" => (0.50, 0.08, 0.44, 0.84), _ if has_aside => (0.06, 0.30, 0.44, 0.62), _ => (0.06, 0.30, 0.88, 0.62), } } fn region_of(txn: &T, block: &MapRef) -> String { match schema::get_string(txn, block, "region") { Some(r) if schema::REGIONS.contains(&r.as_str()) => r, _ => { default_region(&schema::get_string(txn, block, "type").unwrap_or_default()).to_string() } } } /// Flow to canvas: every block gets the box its region gave it, blocks /// sharing a region split its height equally in reading order, `z` is /// the reading-order index, and `region` goes. pub(crate) fn to_canvas(txn: &mut TransactionMut, slide: &MapRef) { let blocks = blocks_of(txn, slide); let maps = schema::ordered_maps(txn, &blocks); let regions: Vec = maps.iter().map(|(_, m)| region_of(txn, m)).collect(); let has_aside = regions.iter().any(|r| r == "aside"); let mut counts: HashMap<&str, usize> = HashMap::new(); for r in ®ions { *counts.entry(r.as_str()).or_default() += 1; } let mut seen: HashMap<&str, usize> = HashMap::new(); for (i, ((_, m), region)) in maps.iter().zip(®ions).enumerate() { let n = counts[region.as_str()] as f64; let k = seen.entry(region.as_str()).or_default(); let (x, y, w, h) = region_rect(region, has_aside); let slot = h / n; let bx = schema::box_json(x, y + slot * (*k as f64), w, slot); *k += 1; m.insert(txn, "box", schema::json_to_any(&bx)); m.insert(txn, "z", Any::Number(i as f64)); m.remove(txn, "region"); } } /// Canvas to flow: boxes go, regions come from position (a heading in /// the top quarter is `heading`, an image is `media`, the rest `body`), /// and the reading order becomes top to bottom, left to right. /// /// The reorder is written as `order` on each block map (schema.rs, /// "block order") and never by rebuilding the array. Rebuilding it — /// clone, clear, insert the copies — is what this did once, and a peer /// typing into a block at that moment lost the sentence: their edit /// addressed the deleted original and the merge kept the copy. The /// maps stay where they are; only a number on each changes (codex 4). pub(crate) fn to_flow(txn: &mut TransactionMut, slide: &MapRef) { let blocks = blocks_of(txn, slide); let maps = schema::ordered_maps(txn, &blocks); let mut order: Vec<(f64, f64, usize)> = Vec::with_capacity(maps.len()); for (pos, (_, m)) in maps.iter().enumerate() { let kind = schema::get_string(txn, m, "type").unwrap_or_default(); let bx = schema::get_object(txn, m, "box"); let coord = |k: &str, default: f64| { bx.as_ref() .and_then(|b| b.get(k)) .and_then(serde_json::Value::as_f64) .unwrap_or(default) }; let (x, y) = (coord("x", 0.06), coord("y", 0.30)); let region = match kind.as_str() { "heading" if y < 0.25 => "heading", "image" => "media", _ => "body", }; m.insert(txn, "region", Any::from(region)); m.remove(txn, "box"); m.remove(txn, "z"); order.push((y, x, pos)); } order.sort_by(|a, b| { a.0.partial_cmp(&b.0) .unwrap_or(std::cmp::Ordering::Equal) .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) }); if order.iter().enumerate().all(|(i, (_, _, pos))| i == *pos) { return; } let sorted: Vec = order .iter() .map(|(_, _, pos)| maps[*pos].1.clone()) .collect(); schema::renumber(txn, &sorted); } #[cfg(test)] mod tests { use super::*; use yrs::{Array, Doc, Options, Transact, XmlFragment}; fn doc() -> Doc { Doc::with_options(Options::with_guid_and_client_id("layout-test".into(), 1)) } /// A slide with `blocks` built through `schema::block_prelim`, so /// these tests exercise the same shapes `ops.rs` writes. #[allow(clippy::type_complexity)] fn slide_with_blocks( txn: &mut TransactionMut, root: &yrs::ArrayRef, blocks: Vec<( &str, &str, Option<&str>, Option<(f64, f64, f64, f64)>, Option, )>, ) -> MapRef { let slide = root.push_back(txn, yrs::MapPrelim::default()); let arr = slide.insert(txn, "blocks", yrs::ArrayPrelim::default()); let empty_props = serde_json::Map::new(); for (id, kind, region, bx, z) in blocks { let box_json = bx.map(|(x, y, w, h)| schema::box_json(x, y, w, h)); let prelim = schema::block_prelim( id, kind, &empty_props, region, box_json.as_ref(), z, None, None, ); arr.push_back(txn, prelim); } slide } // --- the box table (§2.3) --- #[test] fn heading_region_rect() { assert_eq!(region_rect("heading", false), (0.06, 0.08, 0.88, 0.18)); assert_eq!(region_rect("heading", true), (0.06, 0.08, 0.88, 0.18)); } #[test] fn aside_region_rect() { assert_eq!(region_rect("aside", false), (0.54, 0.30, 0.40, 0.62)); assert_eq!(region_rect("aside", true), (0.54, 0.30, 0.40, 0.62)); } #[test] fn media_region_rect() { assert_eq!(region_rect("media", false), (0.50, 0.08, 0.44, 0.84)); assert_eq!(region_rect("media", true), (0.50, 0.08, 0.44, 0.84)); } #[test] fn body_region_rect_narrows_for_an_aside() { assert_eq!(region_rect("body", false), (0.06, 0.30, 0.88, 0.62)); assert_eq!(region_rect("body", true), (0.06, 0.30, 0.44, 0.62)); } #[test] fn default_region_by_kind() { assert_eq!(default_region("heading"), "heading"); assert_eq!(default_region("image"), "media"); assert_eq!(default_region("text"), "body"); assert_eq!(default_region("columns"), "body"); } // --- flow to canvas --- #[test] fn to_canvas_splits_a_shared_region_equally_and_sets_z_by_index() { let d = doc(); let root = d.get_or_insert_array("slides"); let mut txn = d.transact_mut(); let slide = slide_with_blocks( &mut txn, &root, vec![ ("b1", "heading", Some("heading"), None, None), ("b2", "text", Some("body"), None, None), ("b3", "text", Some("body"), None, None), ], ); to_canvas(&mut txn, &slide); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let maps = schema::maps_of(&txn, &blocks); let heading_box = schema::get_object(&txn, &maps[0].1, "box").unwrap(); assert_eq!(heading_box, schema::box_json(0.06, 0.08, 0.88, 0.18)); assert_eq!(schema::get_number(&txn, &maps[0].1, "z"), Some(0.0)); assert!(schema::get_string(&txn, &maps[0].1, "region").is_none()); // Two blocks share `body` (h=0.62): each gets half, stacked. let b2_box = schema::get_object(&txn, &maps[1].1, "box").unwrap(); assert_eq!(b2_box, schema::box_json(0.06, 0.30, 0.88, 0.31)); assert_eq!(schema::get_number(&txn, &maps[1].1, "z"), Some(1.0)); let b3_box = schema::get_object(&txn, &maps[2].1, "box").unwrap(); assert_eq!(b3_box, schema::box_json(0.06, 0.61, 0.88, 0.31)); assert_eq!(schema::get_number(&txn, &maps[2].1, "z"), Some(2.0)); } #[test] fn to_canvas_narrows_body_when_an_aside_is_present() { let d = doc(); let root = d.get_or_insert_array("slides"); let mut txn = d.transact_mut(); let slide = slide_with_blocks( &mut txn, &root, vec![ ("b1", "text", Some("body"), None, None), ("b2", "text", Some("aside"), None, None), ], ); to_canvas(&mut txn, &slide); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let maps = schema::maps_of(&txn, &blocks); let body_box = schema::get_object(&txn, &maps[0].1, "box").unwrap(); assert_eq!(body_box, schema::box_json(0.06, 0.30, 0.44, 0.62)); } // --- canvas to flow --- #[test] fn to_flow_classifies_by_position_and_kind() { let d = doc(); let root = d.get_or_insert_array("slides"); let mut txn = d.transact_mut(); let slide = slide_with_blocks( &mut txn, &root, vec![ ( "b1", "heading", None, Some((0.06, 0.08, 0.88, 0.18)), Some(0.0), ), ("b2", "image", None, Some((0.5, 0.5, 0.2, 0.2)), Some(1.0)), ("b3", "text", None, Some((0.06, 0.6, 0.5, 0.3)), Some(2.0)), ], ); to_flow(&mut txn, &slide); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let maps = schema::maps_of(&txn, &blocks); assert_eq!( schema::get_string(&txn, &maps[0].1, "region").as_deref(), Some("heading") ); assert_eq!( schema::get_string(&txn, &maps[1].1, "region").as_deref(), Some("media") ); assert_eq!( schema::get_string(&txn, &maps[2].1, "region").as_deref(), Some("body") ); for (_, m) in &maps { assert!(schema::get_object(&txn, m, "box").is_none()); assert!(schema::get_number(&txn, m, "z").is_none()); } } #[test] fn to_flow_orders_y_then_x() { let d = doc(); let root = d.get_or_insert_array("slides"); let mut txn = d.transact_mut(); // Written in an order the y-then-x sort must undo. let slide = slide_with_blocks( &mut txn, &root, vec![ ( "bottom-right", "text", None, Some((0.6, 0.6, 0.2, 0.2)), Some(0.0), ), ( "top-right", "text", None, Some((0.6, 0.1, 0.2, 0.2)), Some(1.0), ), ( "top-left", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(2.0), ), ( "bottom-left", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(3.0), ), ], ); to_flow(&mut txn, &slide); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let ids: Vec = schema::ordered_maps(&txn, &blocks) .iter() .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) .collect(); assert_eq!( ids, vec!["top-left", "top-right", "bottom-left", "bottom-right"] ); // The array itself was not touched: the maps sit where they // were and carry the new order as a key (codex 4). let array_ids: Vec = schema::maps_of(&txn, &blocks) .iter() .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) .collect(); assert_eq!( array_ids, vec!["bottom-right", "top-right", "top-left", "bottom-left"] ); let orders: Vec = schema::maps_of(&txn, &blocks) .iter() .map(|(_, m)| schema::get_number(&txn, m, "order").unwrap()) .collect(); assert_eq!(orders, vec![3.0, 1.0, 0.0, 2.0]); } /// Codex 4, reproduced then fixed: one peer converts a canvas slide /// whose blocks are out of reading order while another types into /// one of them. After both updates are exchanged, both documents /// hold the typed text — a block that was reordered is the same /// shared map it was, not a copy. #[test] fn to_flow_keeps_a_concurrent_text_edit() { use crate::{apply_ops, apply_update, state_bytes}; use yrs::updates::decoder::Decode; use yrs::{GetString, ReadTxn, Update}; let a = crate::new_doc("codex-4", 1); apply_ops( &a, r#"[ {"op":"add_slide","id":"sld","layout":"canvas"}, {"op":"add_block","slide":"sld","id":"low","type":"text","markdown":"below", "box":{"x":0.1,"y":0.6,"w":0.3,"h":0.2}}, {"op":"add_block","slide":"sld","id":"high","type":"text","markdown":"above", "box":{"x":0.1,"y":0.1,"w":0.3,"h":0.2}} ]"#, ) .unwrap(); let b = crate::new_doc("codex-4", 2); apply_update(&b, &state_bytes(&a)).unwrap(); // A converts (which reorders: "high" now reads first). let sv_a = a.transact().state_vector(); apply_ops(&a, r#"[{"op":"set_slide","id":"sld","layout":"flow"}]"#).unwrap(); let from_a = a.transact().encode_state_as_update_v1(&sv_a); // B, concurrently, appends to "low"'s text directly, the way a // keystroke through y-prosemirror does. let sv_b = b.transact().state_vector(); { let roots = schema::roots(&b); let mut txn = b.transact_mut(); let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let (_, low) = schema::find_by_id(&txn, &blocks, "low").unwrap(); let text = schema::get_fragment(&txn, &low, "text").unwrap(); let yrs::types::xml::XmlOut::Element(p) = text.get(&txn, 0).unwrap() else { panic!("a paragraph") }; let yrs::types::xml::XmlOut::Text(t) = p.get(&txn, 0).unwrap() else { panic!("a text") }; let end = t.get_string(&txn).chars().count() as u32; yrs::Text::insert(&t, &mut txn, end, " and more"); } let from_b = b.transact().encode_state_as_update_v1(&sv_b); a.transact_mut() .apply_update(Update::decode_v1(&from_b).unwrap()) .unwrap(); b.transact_mut() .apply_update(Update::decode_v1(&from_a).unwrap()) .unwrap(); for (name, d) in [("a", &a), ("b", &b)] { let v: serde_json::Value = serde_json::from_str(&crate::view_json(d)).unwrap(); let blocks = v["slides"][0]["blocks"].as_array().unwrap(); let ids: Vec<&str> = blocks.iter().map(|b| b["id"].as_str().unwrap()).collect(); assert_eq!(ids, vec!["high", "low"], "{name}: reading order"); let low = blocks.iter().find(|b| b["id"] == "low").unwrap(); assert_eq!( low["text"], serde_json::json!([[{"t": "below and more"}]]), "{name}: the concurrent edit survived the conversion" ); assert_eq!(low["region"], "body"); } assert_eq!(crate::view_json(&a), crate::view_json(&b)); } #[test] fn to_flow_is_a_no_op_when_already_in_order() { let d = doc(); let root = d.get_or_insert_array("slides"); let mut txn = d.transact_mut(); let slide = slide_with_blocks( &mut txn, &root, vec![ ("a", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(0.0)), ("b", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(1.0)), ], ); to_flow(&mut txn, &slide); let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); let ids: Vec = schema::maps_of(&txn, &blocks) .iter() .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) .collect(); assert_eq!(ids, vec!["a", "b"]); } }