| 1 | //! Layout conversion between a slide's two modes (spec §2.3). |
| 2 | //! |
| 3 | //! A `flow` slide places blocks by `region` (`heading`, `body`, `aside`, |
| 4 | //! `media`); a `canvas` slide places them by an absolute `box` and `z`. |
| 5 | //! Switching a slide's `layout` (`ops::apply_one`'s `SetSlide` arm) calls |
| 6 | //! [`to_canvas`] or [`to_flow`] here to carry blocks across, lossily in |
| 7 | //! both directions as the spec says. Everything else in `ops.rs` — op |
| 8 | //! parsing, the validation model, id rewriting, mutation — stays there; |
| 9 | //! this module is just the geometry. |
| 10 | |
| 11 | use crate::ops::blocks_of; |
| 12 | use crate::schema; |
| 13 | use std::collections::HashMap; |
| 14 | use yrs::{Any, Map, MapRef, ReadTxn, TransactionMut}; |
| 15 | |
| 16 | /// The region a block lands in when nobody said: headings in `heading`, |
| 17 | /// images in `media`, the rest in `body`. |
| 18 | pub(crate) fn default_region(kind: &str) -> &'static str { |
| 19 | match kind { |
| 20 | "heading" => "heading", |
| 21 | "image" => "media", |
| 22 | _ => "body", |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /// The frame fraction each region occupies: `(x, y, w, h)`. `body` |
| 27 | /// narrows to make room when the slide has an `aside`. |
| 28 | pub(crate) fn region_rect(region: &str, has_aside: bool) -> (f64, f64, f64, f64) { |
| 29 | match region { |
| 30 | "heading" => (0.06, 0.08, 0.88, 0.18), |
| 31 | "aside" => (0.54, 0.30, 0.40, 0.62), |
| 32 | "media" => (0.50, 0.08, 0.44, 0.84), |
| 33 | _ if has_aside => (0.06, 0.30, 0.44, 0.62), |
| 34 | _ => (0.06, 0.30, 0.88, 0.62), |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | fn region_of<T: ReadTxn>(txn: &T, block: &MapRef) -> String { |
| 39 | match schema::get_string(txn, block, "region") { |
| 40 | Some(r) if schema::REGIONS.contains(&r.as_str()) => r, |
| 41 | _ => { |
| 42 | default_region(&schema::get_string(txn, block, "type").unwrap_or_default()).to_string() |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Flow to canvas: every block gets the box its region gave it, blocks |
| 48 | /// sharing a region split its height equally in reading order, `z` is |
| 49 | /// the reading-order index, and `region` goes. |
| 50 | pub(crate) fn to_canvas(txn: &mut TransactionMut, slide: &MapRef) { |
| 51 | let blocks = blocks_of(txn, slide); |
| 52 | let maps = schema::ordered_maps(txn, &blocks); |
| 53 | let regions: Vec<String> = maps.iter().map(|(_, m)| region_of(txn, m)).collect(); |
| 54 | let has_aside = regions.iter().any(|r| r == "aside"); |
| 55 | let mut counts: HashMap<&str, usize> = HashMap::new(); |
| 56 | for r in ®ions { |
| 57 | *counts.entry(r.as_str()).or_default() += 1; |
| 58 | } |
| 59 | let mut seen: HashMap<&str, usize> = HashMap::new(); |
| 60 | for (i, ((_, m), region)) in maps.iter().zip(®ions).enumerate() { |
| 61 | let n = counts[region.as_str()] as f64; |
| 62 | let k = seen.entry(region.as_str()).or_default(); |
| 63 | let (x, y, w, h) = region_rect(region, has_aside); |
| 64 | let slot = h / n; |
| 65 | let bx = schema::box_json(x, y + slot * (*k as f64), w, slot); |
| 66 | *k += 1; |
| 67 | m.insert(txn, "box", schema::json_to_any(&bx)); |
| 68 | m.insert(txn, "z", Any::Number(i as f64)); |
| 69 | m.remove(txn, "region"); |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Canvas to flow: boxes go, regions come from position (a heading in |
| 74 | /// the top quarter is `heading`, an image is `media`, the rest `body`), |
| 75 | /// and the reading order becomes top to bottom, left to right. |
| 76 | /// |
| 77 | /// The reorder is written as `order` on each block map (schema.rs, |
| 78 | /// "block order") and never by rebuilding the array. Rebuilding it — |
| 79 | /// clone, clear, insert the copies — is what this did once, and a peer |
| 80 | /// typing into a block at that moment lost the sentence: their edit |
| 81 | /// addressed the deleted original and the merge kept the copy. The |
| 82 | /// maps stay where they are; only a number on each changes (codex 4). |
| 83 | pub(crate) fn to_flow(txn: &mut TransactionMut, slide: &MapRef) { |
| 84 | let blocks = blocks_of(txn, slide); |
| 85 | let maps = schema::ordered_maps(txn, &blocks); |
| 86 | let mut order: Vec<(f64, f64, usize)> = Vec::with_capacity(maps.len()); |
| 87 | for (pos, (_, m)) in maps.iter().enumerate() { |
| 88 | let kind = schema::get_string(txn, m, "type").unwrap_or_default(); |
| 89 | let bx = schema::get_object(txn, m, "box"); |
| 90 | let coord = |k: &str, default: f64| { |
| 91 | bx.as_ref() |
| 92 | .and_then(|b| b.get(k)) |
| 93 | .and_then(serde_json::Value::as_f64) |
| 94 | .unwrap_or(default) |
| 95 | }; |
| 96 | let (x, y) = (coord("x", 0.06), coord("y", 0.30)); |
| 97 | let region = match kind.as_str() { |
| 98 | "heading" if y < 0.25 => "heading", |
| 99 | "image" => "media", |
| 100 | _ => "body", |
| 101 | }; |
| 102 | m.insert(txn, "region", Any::from(region)); |
| 103 | m.remove(txn, "box"); |
| 104 | m.remove(txn, "z"); |
| 105 | order.push((y, x, pos)); |
| 106 | } |
| 107 | order.sort_by(|a, b| { |
| 108 | a.0.partial_cmp(&b.0) |
| 109 | .unwrap_or(std::cmp::Ordering::Equal) |
| 110 | .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) |
| 111 | }); |
| 112 | if order.iter().enumerate().all(|(i, (_, _, pos))| i == *pos) { |
| 113 | return; |
| 114 | } |
| 115 | let sorted: Vec<MapRef> = order |
| 116 | .iter() |
| 117 | .map(|(_, _, pos)| maps[*pos].1.clone()) |
| 118 | .collect(); |
| 119 | schema::renumber(txn, &sorted); |
| 120 | } |
| 121 | |
| 122 | #[cfg(test)] |
| 123 | mod tests { |
| 124 | use super::*; |
| 125 | use yrs::{Array, Doc, Options, Transact, XmlFragment}; |
| 126 | |
| 127 | fn doc() -> Doc { |
| 128 | Doc::with_options(Options::with_guid_and_client_id("layout-test".into(), 1)) |
| 129 | } |
| 130 | |
| 131 | /// A slide with `blocks` built through `schema::block_prelim`, so |
| 132 | /// these tests exercise the same shapes `ops.rs` writes. |
| 133 | #[allow(clippy::type_complexity)] |
| 134 | fn slide_with_blocks( |
| 135 | txn: &mut TransactionMut, |
| 136 | root: &yrs::ArrayRef, |
| 137 | blocks: Vec<( |
| 138 | &str, |
| 139 | &str, |
| 140 | Option<&str>, |
| 141 | Option<(f64, f64, f64, f64)>, |
| 142 | Option<f64>, |
| 143 | )>, |
| 144 | ) -> MapRef { |
| 145 | let slide = root.push_back(txn, yrs::MapPrelim::default()); |
| 146 | let arr = slide.insert(txn, "blocks", yrs::ArrayPrelim::default()); |
| 147 | let empty_props = serde_json::Map::new(); |
| 148 | for (id, kind, region, bx, z) in blocks { |
| 149 | let box_json = bx.map(|(x, y, w, h)| schema::box_json(x, y, w, h)); |
| 150 | let prelim = schema::block_prelim( |
| 151 | id, |
| 152 | kind, |
| 153 | &empty_props, |
| 154 | region, |
| 155 | box_json.as_ref(), |
| 156 | z, |
| 157 | None, |
| 158 | None, |
| 159 | ); |
| 160 | arr.push_back(txn, prelim); |
| 161 | } |
| 162 | slide |
| 163 | } |
| 164 | |
| 165 | // --- the box table (§2.3) --- |
| 166 | |
| 167 | #[test] |
| 168 | fn heading_region_rect() { |
| 169 | assert_eq!(region_rect("heading", false), (0.06, 0.08, 0.88, 0.18)); |
| 170 | assert_eq!(region_rect("heading", true), (0.06, 0.08, 0.88, 0.18)); |
| 171 | } |
| 172 | |
| 173 | #[test] |
| 174 | fn aside_region_rect() { |
| 175 | assert_eq!(region_rect("aside", false), (0.54, 0.30, 0.40, 0.62)); |
| 176 | assert_eq!(region_rect("aside", true), (0.54, 0.30, 0.40, 0.62)); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn media_region_rect() { |
| 181 | assert_eq!(region_rect("media", false), (0.50, 0.08, 0.44, 0.84)); |
| 182 | assert_eq!(region_rect("media", true), (0.50, 0.08, 0.44, 0.84)); |
| 183 | } |
| 184 | |
| 185 | #[test] |
| 186 | fn body_region_rect_narrows_for_an_aside() { |
| 187 | assert_eq!(region_rect("body", false), (0.06, 0.30, 0.88, 0.62)); |
| 188 | assert_eq!(region_rect("body", true), (0.06, 0.30, 0.44, 0.62)); |
| 189 | } |
| 190 | |
| 191 | #[test] |
| 192 | fn default_region_by_kind() { |
| 193 | assert_eq!(default_region("heading"), "heading"); |
| 194 | assert_eq!(default_region("image"), "media"); |
| 195 | assert_eq!(default_region("text"), "body"); |
| 196 | assert_eq!(default_region("columns"), "body"); |
| 197 | } |
| 198 | |
| 199 | // --- flow to canvas --- |
| 200 | |
| 201 | #[test] |
| 202 | fn to_canvas_splits_a_shared_region_equally_and_sets_z_by_index() { |
| 203 | let d = doc(); |
| 204 | let root = d.get_or_insert_array("slides"); |
| 205 | let mut txn = d.transact_mut(); |
| 206 | let slide = slide_with_blocks( |
| 207 | &mut txn, |
| 208 | &root, |
| 209 | vec![ |
| 210 | ("b1", "heading", Some("heading"), None, None), |
| 211 | ("b2", "text", Some("body"), None, None), |
| 212 | ("b3", "text", Some("body"), None, None), |
| 213 | ], |
| 214 | ); |
| 215 | to_canvas(&mut txn, &slide); |
| 216 | |
| 217 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 218 | let maps = schema::maps_of(&txn, &blocks); |
| 219 | |
| 220 | let heading_box = schema::get_object(&txn, &maps[0].1, "box").unwrap(); |
| 221 | assert_eq!(heading_box, schema::box_json(0.06, 0.08, 0.88, 0.18)); |
| 222 | assert_eq!(schema::get_number(&txn, &maps[0].1, "z"), Some(0.0)); |
| 223 | assert!(schema::get_string(&txn, &maps[0].1, "region").is_none()); |
| 224 | |
| 225 | // Two blocks share `body` (h=0.62): each gets half, stacked. |
| 226 | let b2_box = schema::get_object(&txn, &maps[1].1, "box").unwrap(); |
| 227 | assert_eq!(b2_box, schema::box_json(0.06, 0.30, 0.88, 0.31)); |
| 228 | assert_eq!(schema::get_number(&txn, &maps[1].1, "z"), Some(1.0)); |
| 229 | |
| 230 | let b3_box = schema::get_object(&txn, &maps[2].1, "box").unwrap(); |
| 231 | assert_eq!(b3_box, schema::box_json(0.06, 0.61, 0.88, 0.31)); |
| 232 | assert_eq!(schema::get_number(&txn, &maps[2].1, "z"), Some(2.0)); |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn to_canvas_narrows_body_when_an_aside_is_present() { |
| 237 | let d = doc(); |
| 238 | let root = d.get_or_insert_array("slides"); |
| 239 | let mut txn = d.transact_mut(); |
| 240 | let slide = slide_with_blocks( |
| 241 | &mut txn, |
| 242 | &root, |
| 243 | vec![ |
| 244 | ("b1", "text", Some("body"), None, None), |
| 245 | ("b2", "text", Some("aside"), None, None), |
| 246 | ], |
| 247 | ); |
| 248 | to_canvas(&mut txn, &slide); |
| 249 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 250 | let maps = schema::maps_of(&txn, &blocks); |
| 251 | let body_box = schema::get_object(&txn, &maps[0].1, "box").unwrap(); |
| 252 | assert_eq!(body_box, schema::box_json(0.06, 0.30, 0.44, 0.62)); |
| 253 | } |
| 254 | |
| 255 | // --- canvas to flow --- |
| 256 | |
| 257 | #[test] |
| 258 | fn to_flow_classifies_by_position_and_kind() { |
| 259 | let d = doc(); |
| 260 | let root = d.get_or_insert_array("slides"); |
| 261 | let mut txn = d.transact_mut(); |
| 262 | let slide = slide_with_blocks( |
| 263 | &mut txn, |
| 264 | &root, |
| 265 | vec![ |
| 266 | ( |
| 267 | "b1", |
| 268 | "heading", |
| 269 | None, |
| 270 | Some((0.06, 0.08, 0.88, 0.18)), |
| 271 | Some(0.0), |
| 272 | ), |
| 273 | ("b2", "image", None, Some((0.5, 0.5, 0.2, 0.2)), Some(1.0)), |
| 274 | ("b3", "text", None, Some((0.06, 0.6, 0.5, 0.3)), Some(2.0)), |
| 275 | ], |
| 276 | ); |
| 277 | to_flow(&mut txn, &slide); |
| 278 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 279 | let maps = schema::maps_of(&txn, &blocks); |
| 280 | assert_eq!( |
| 281 | schema::get_string(&txn, &maps[0].1, "region").as_deref(), |
| 282 | Some("heading") |
| 283 | ); |
| 284 | assert_eq!( |
| 285 | schema::get_string(&txn, &maps[1].1, "region").as_deref(), |
| 286 | Some("media") |
| 287 | ); |
| 288 | assert_eq!( |
| 289 | schema::get_string(&txn, &maps[2].1, "region").as_deref(), |
| 290 | Some("body") |
| 291 | ); |
| 292 | for (_, m) in &maps { |
| 293 | assert!(schema::get_object(&txn, m, "box").is_none()); |
| 294 | assert!(schema::get_number(&txn, m, "z").is_none()); |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn to_flow_orders_y_then_x() { |
| 300 | let d = doc(); |
| 301 | let root = d.get_or_insert_array("slides"); |
| 302 | let mut txn = d.transact_mut(); |
| 303 | // Written in an order the y-then-x sort must undo. |
| 304 | let slide = slide_with_blocks( |
| 305 | &mut txn, |
| 306 | &root, |
| 307 | vec![ |
| 308 | ( |
| 309 | "bottom-right", |
| 310 | "text", |
| 311 | None, |
| 312 | Some((0.6, 0.6, 0.2, 0.2)), |
| 313 | Some(0.0), |
| 314 | ), |
| 315 | ( |
| 316 | "top-right", |
| 317 | "text", |
| 318 | None, |
| 319 | Some((0.6, 0.1, 0.2, 0.2)), |
| 320 | Some(1.0), |
| 321 | ), |
| 322 | ( |
| 323 | "top-left", |
| 324 | "text", |
| 325 | None, |
| 326 | Some((0.1, 0.1, 0.2, 0.2)), |
| 327 | Some(2.0), |
| 328 | ), |
| 329 | ( |
| 330 | "bottom-left", |
| 331 | "text", |
| 332 | None, |
| 333 | Some((0.1, 0.6, 0.2, 0.2)), |
| 334 | Some(3.0), |
| 335 | ), |
| 336 | ], |
| 337 | ); |
| 338 | to_flow(&mut txn, &slide); |
| 339 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 340 | let ids: Vec<String> = schema::ordered_maps(&txn, &blocks) |
| 341 | .iter() |
| 342 | .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) |
| 343 | .collect(); |
| 344 | assert_eq!( |
| 345 | ids, |
| 346 | vec!["top-left", "top-right", "bottom-left", "bottom-right"] |
| 347 | ); |
| 348 | // The array itself was not touched: the maps sit where they |
| 349 | // were and carry the new order as a key (codex 4). |
| 350 | let array_ids: Vec<String> = schema::maps_of(&txn, &blocks) |
| 351 | .iter() |
| 352 | .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) |
| 353 | .collect(); |
| 354 | assert_eq!( |
| 355 | array_ids, |
| 356 | vec!["bottom-right", "top-right", "top-left", "bottom-left"] |
| 357 | ); |
| 358 | let orders: Vec<f64> = schema::maps_of(&txn, &blocks) |
| 359 | .iter() |
| 360 | .map(|(_, m)| schema::get_number(&txn, m, "order").unwrap()) |
| 361 | .collect(); |
| 362 | assert_eq!(orders, vec![3.0, 1.0, 0.0, 2.0]); |
| 363 | } |
| 364 | |
| 365 | /// Codex 4, reproduced then fixed: one peer converts a canvas slide |
| 366 | /// whose blocks are out of reading order while another types into |
| 367 | /// one of them. After both updates are exchanged, both documents |
| 368 | /// hold the typed text — a block that was reordered is the same |
| 369 | /// shared map it was, not a copy. |
| 370 | #[test] |
| 371 | fn to_flow_keeps_a_concurrent_text_edit() { |
| 372 | use crate::{apply_ops, apply_update, state_bytes}; |
| 373 | use yrs::updates::decoder::Decode; |
| 374 | use yrs::{GetString, ReadTxn, Update}; |
| 375 | |
| 376 | let a = crate::new_doc("codex-4", 1); |
| 377 | apply_ops( |
| 378 | &a, |
| 379 | r#"[ |
| 380 | {"op":"add_slide","id":"sld","layout":"canvas"}, |
| 381 | {"op":"add_block","slide":"sld","id":"low","type":"text","markdown":"below", |
| 382 | "box":{"x":0.1,"y":0.6,"w":0.3,"h":0.2}}, |
| 383 | {"op":"add_block","slide":"sld","id":"high","type":"text","markdown":"above", |
| 384 | "box":{"x":0.1,"y":0.1,"w":0.3,"h":0.2}} |
| 385 | ]"#, |
| 386 | ) |
| 387 | .unwrap(); |
| 388 | let b = crate::new_doc("codex-4", 2); |
| 389 | apply_update(&b, &state_bytes(&a)).unwrap(); |
| 390 | |
| 391 | // A converts (which reorders: "high" now reads first). |
| 392 | let sv_a = a.transact().state_vector(); |
| 393 | apply_ops(&a, r#"[{"op":"set_slide","id":"sld","layout":"flow"}]"#).unwrap(); |
| 394 | let from_a = a.transact().encode_state_as_update_v1(&sv_a); |
| 395 | |
| 396 | // B, concurrently, appends to "low"'s text directly, the way a |
| 397 | // keystroke through y-prosemirror does. |
| 398 | let sv_b = b.transact().state_vector(); |
| 399 | { |
| 400 | let roots = schema::roots(&b); |
| 401 | let mut txn = b.transact_mut(); |
| 402 | let (_, slide) = schema::find_by_id(&txn, &roots.slides, "sld").unwrap(); |
| 403 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 404 | let (_, low) = schema::find_by_id(&txn, &blocks, "low").unwrap(); |
| 405 | let text = schema::get_fragment(&txn, &low, "text").unwrap(); |
| 406 | let yrs::types::xml::XmlOut::Element(p) = text.get(&txn, 0).unwrap() else { |
| 407 | panic!("a paragraph") |
| 408 | }; |
| 409 | let yrs::types::xml::XmlOut::Text(t) = p.get(&txn, 0).unwrap() else { |
| 410 | panic!("a text") |
| 411 | }; |
| 412 | let end = t.get_string(&txn).chars().count() as u32; |
| 413 | yrs::Text::insert(&t, &mut txn, end, " and more"); |
| 414 | } |
| 415 | let from_b = b.transact().encode_state_as_update_v1(&sv_b); |
| 416 | |
| 417 | a.transact_mut() |
| 418 | .apply_update(Update::decode_v1(&from_b).unwrap()) |
| 419 | .unwrap(); |
| 420 | b.transact_mut() |
| 421 | .apply_update(Update::decode_v1(&from_a).unwrap()) |
| 422 | .unwrap(); |
| 423 | |
| 424 | for (name, d) in [("a", &a), ("b", &b)] { |
| 425 | let v: serde_json::Value = serde_json::from_str(&crate::view_json(d)).unwrap(); |
| 426 | let blocks = v["slides"][0]["blocks"].as_array().unwrap(); |
| 427 | let ids: Vec<&str> = blocks.iter().map(|b| b["id"].as_str().unwrap()).collect(); |
| 428 | assert_eq!(ids, vec!["high", "low"], "{name}: reading order"); |
| 429 | let low = blocks.iter().find(|b| b["id"] == "low").unwrap(); |
| 430 | assert_eq!( |
| 431 | low["text"], |
| 432 | serde_json::json!([[{"t": "below and more"}]]), |
| 433 | "{name}: the concurrent edit survived the conversion" |
| 434 | ); |
| 435 | assert_eq!(low["region"], "body"); |
| 436 | } |
| 437 | assert_eq!(crate::view_json(&a), crate::view_json(&b)); |
| 438 | } |
| 439 | |
| 440 | #[test] |
| 441 | fn to_flow_is_a_no_op_when_already_in_order() { |
| 442 | let d = doc(); |
| 443 | let root = d.get_or_insert_array("slides"); |
| 444 | let mut txn = d.transact_mut(); |
| 445 | let slide = slide_with_blocks( |
| 446 | &mut txn, |
| 447 | &root, |
| 448 | vec![ |
| 449 | ("a", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(0.0)), |
| 450 | ("b", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(1.0)), |
| 451 | ], |
| 452 | ); |
| 453 | to_flow(&mut txn, &slide); |
| 454 | let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| 455 | let ids: Vec<String> = schema::maps_of(&txn, &blocks) |
| 456 | .iter() |
| 457 | .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) |
| 458 | .collect(); |
| 459 | assert_eq!(ids, vec!["a", "b"]); |
| 460 | } |
| 461 | } |
| 462 | |