oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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
11use crate::ops::blocks_of;
12use crate::schema;
13use std::collections::HashMap;
14use 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`.
18pub(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`.
28pub(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
38fn 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.
50pub(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 &regions {
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(&regions).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).
83pub(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)]
123mod 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