//! The deck's shape over yrs: root names, key names, the vocabularies the //! spec closes, the text schema, and the small set of constructors and //! accessors `ops` and `view` share. Spec §2 in code. //! //! What lives where (§2.1, §2.2, §2.4): //! //! - `meta` (Y.Map): `title`, `theme`, `accent`, `fonts`, `aspect` as //! plain values (`fonts` is one `{heading, body}` object). //! - `slides` (Y.Array of Y.Map): the SET of the deck's slides; their //! order is the `order` key on each (see "reading order" below). Each //! slide map holds `id`, `layout`, //! `template`, `phone`, `transition` as strings, `background` as a //! Y.Map `{kind, value}`, `notes` as a Y.XmlFragment, an optional //! `order` (number), and `blocks` as a //! Y.Array of block Y.Maps — the set of blocks; their reading order //! is the `order` key on each, the same way. //! - a block map holds `id`, `type`, `props` (Y.Map of plain values — //! `columns.children` is a plain array inside it), `phone` (Y.Map of //! the §2.5 overrides), an optional `order` (number), and then either //! `region` (flow) or `box` + //! `z` (canvas). `box` is one plain `{x, y, w, h}` value — the four //! numbers move together, and merging two peers' drags coordinate by //! coordinate would produce a box neither of them made. Text-bearing //! types carry `text` (Y.XmlFragment); a `table` carries `cells` //! (Y.Array of Y.XmlFragment, row-major, `rows * cols` long) as a key //! of its own on the block map, which the view folds into //! `props.cells`. //! - `assets` (Y.Map): id to one plain `{ref, name, width, height, alt}` //! value — an asset is registered once and never edited in place. //! //! The text schema (§2.6): an XmlFragment of `` elements, each //! holding one XmlText. Marks are formatting attributes on the text's //! runs: `b`, `i`, `c`, `h` (value `true`) and `href` (the one-key map //! `{"href": url}`). The map is y-prosemirror's shape: it maps a mark to //! the attribute ` = `, so the mark `href` //! with attrs `{href}` is the attribute `href` with that object as its //! value, and a plain string there would throw in `schema.mark` (which //! makes y-prosemirror delete the text). The reader is more tolerant //! than the writer — `href` reads as either the map or a plain string //! (documents written before this shape), any truthy value counts as a //! set mark (y-prosemirror writes `{}` for the attr-less marks), //! several texts in one paragraph concatenate, and a bare text at //! fragment level is its own paragraph — because updates reach the //! server from browsers as well. use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; use yrs::types::text::YChange; use yrs::types::xml::{XmlDeltaPrelim, XmlElementPrelim, XmlFragmentPrelim, XmlIn, XmlOut}; use yrs::types::{Attrs, Delta}; use yrs::{ Any, Array, ArrayRef, Doc, GetString, In, Map, MapRef, Out, ReadTxn, Text, TransactionMut, XmlFragment, XmlFragmentRef, }; pub const ROOT_META: &str = "meta"; pub const ROOT_SLIDES: &str = "slides"; pub const ROOT_ASSETS: &str = "assets"; pub const LAYOUTS: &[&str] = &["flow", "canvas"]; pub const TEMPLATES: &[&str] = &[ "title", "title-body", "two-column", "section", "image-full", "quote", "blank", ]; pub const REGIONS: &[&str] = &["heading", "body", "aside", "media"]; pub const BACKGROUND_KINDS: &[&str] = &["theme", "color", "image"]; pub const SLIDE_PHONES: &[&str] = &["inherit", "reflow"]; pub const TRANSITIONS: &[&str] = &["none", "fade"]; pub const BLOCK_TYPES: &[&str] = &[ "heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", "spacer", ]; /// Types that carry a `text` fragment (§2.4's "Text: yes" column, plus /// `shape`'s optional label). pub const TEXT_TYPES: &[&str] = &["heading", "text", "list", "quote", "code", "shape"]; pub const SIZES: &[&str] = &["s", "m", "l"]; pub const DEFAULT_THEME: &str = "paper"; pub const DEFAULT_ASPECT: &str = "16:9"; pub const DEFAULT_TEMPLATE: &str = "blank"; pub const DEFAULT_TABLE_ROWS: i64 = 2; pub const DEFAULT_TABLE_COLS: i64 = 2; /// The most cells one table can hold. Not a product limit — a guard on /// `set_block {props: {rows: 1000000}}` allocating a million fragments /// inside one request. pub const MAX_TABLE_SIDE: i64 = 100; pub fn is_text_type(kind: &str) -> bool { TEXT_TYPES.contains(&kind) } // --- text --- /// One formatted run of a paragraph. Serialises as `{t, b?, i?, c?, h?, /// href?}` with false and absent marks omitted, which is the view's run /// shape verbatim. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] pub struct Run { pub t: String, #[serde(skip_serializing_if = "std::ops::Not::not")] pub b: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] pub i: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] pub c: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] pub h: bool, #[serde(skip_serializing_if = "Option::is_none")] pub href: Option, } impl Run { pub fn same_marks(&self, other: &Run) -> bool { self.b == other.b && self.i == other.i && self.c == other.c && self.h == other.h && self.href == other.href } } pub type Paragraph = Vec; const MARK_KEYS: [&str; 4] = ["b", "i", "c", "h"]; fn run_attrs(run: &Run) -> Attrs { let mut attrs = Attrs::new(); for (key, on) in MARK_KEYS.iter().zip([run.b, run.i, run.c, run.h]) { if on { attrs.insert(Arc::from(*key), Any::Bool(true)); } } if let Some(href) = &run.href { attrs.insert(Arc::from("href"), href_any(href)); } attrs } /// The `href` attribute's value: y-prosemirror's one-key map for the mark /// `href` with attrs `{href}`. fn href_any(href: &str) -> Any { Any::Map(Arc::new(HashMap::from([( "href".to_string(), Any::from(href), )]))) } /// The URL an `href` attribute carries: the `{"href": url}` map this /// module writes, or the plain string documents written before it carry. fn href_of(v: &Any) -> Option { match v { Any::String(s) => Some(s.to_string()), Any::Map(m) => match m.get("href") { Some(Any::String(s)) => Some(s.to_string()), _ => None, }, _ => None, } } fn paragraph_prelim(paragraph: &[Run]) -> XmlElementPrelim { let delta: Vec> = paragraph .iter() .filter(|run| !run.t.is_empty()) .map(|run| { let attrs = run_attrs(run); let attrs = if attrs.is_empty() { None } else { Some(Box::new(attrs)) }; Delta::Inserted(In::Any(Any::from(run.t.as_str())), attrs) }) .collect(); let text = XmlDeltaPrelim { attributes: HashMap::default(), delta, }; XmlElementPrelim::new("paragraph", [XmlIn::Text(text)]) } /// A fragment holding `text` in the §2.6 schema, ready to insert. pub fn text_prelim(text: &[Paragraph]) -> XmlFragmentPrelim { XmlFragmentPrelim::new::<_, ()>(text.iter().map(|p| XmlIn::Element(paragraph_prelim(p)))) } /// Replaces the whole content of `frag` with `text`. pub fn replace_text(txn: &mut TransactionMut, frag: &XmlFragmentRef, text: &[Paragraph]) { let len = frag.len(txn); if len > 0 { frag.remove_range(txn, 0, len); } for p in text { frag.push_back(txn, paragraph_prelim(p)); } } fn truthy(v: &Any) -> bool { !matches!(v, Any::Null | Any::Undefined | Any::Bool(false)) } fn push_runs(txn: &T, text: &yrs::XmlTextRef, out: &mut Paragraph) { for chunk in text.diff(txn, YChange::identity) { let Out::Any(Any::String(s)) = &chunk.insert else { continue; // an embed: nothing in this schema, nothing to show }; if s.is_empty() { continue; } let mut run = Run { t: s.to_string(), ..Run::default() }; if let Some(attrs) = &chunk.attributes { run.b = attrs.get("b").is_some_and(truthy); run.i = attrs.get("i").is_some_and(truthy); run.c = attrs.get("c").is_some_and(truthy); run.h = attrs.get("h").is_some_and(truthy); run.href = attrs.get("href").and_then(href_of); } match out.last_mut() { Some(last) if last.same_marks(&run) => last.t.push_str(&run.t), _ => out.push(run), } } } /// The paragraphs of a text fragment. pub fn read_text(txn: &T, frag: &XmlFragmentRef) -> Vec { let mut paragraphs = Vec::new(); for child in frag.children(txn) { match child { XmlOut::Element(e) => { let mut p = Paragraph::new(); for inner in e.children(txn) { match inner { XmlOut::Text(t) => push_runs(txn, &t, &mut p), // An element inside a paragraph is outside this // schema; keep its plain text rather than lose it. other => { let s = other_string(txn, &other); if !s.is_empty() { p.push(Run { t: s, ..Run::default() }); } } } } paragraphs.push(p); } XmlOut::Text(t) => { let mut p = Paragraph::new(); push_runs(txn, &t, &mut p); paragraphs.push(p); } XmlOut::Fragment(f) => paragraphs.extend(read_text(txn, &f)), } } paragraphs } fn other_string(txn: &T, node: &XmlOut) -> String { match node { XmlOut::Element(e) => e.get_string(txn), XmlOut::Fragment(f) => f.get_string(txn), XmlOut::Text(t) => t.get_string(txn), } } // --- roots and lookups --- pub struct Roots { pub meta: MapRef, pub slides: ArrayRef, pub assets: MapRef, } /// The three root types. `get_or_insert_*` takes a write transaction of /// its own internally, so this must run BEFORE any transaction the caller /// opens (Docs' `snapshot.rs` records the self-deadlock otherwise). pub fn roots(doc: &Doc) -> Roots { Roots { meta: doc.get_or_insert_map(ROOT_META), slides: doc.get_or_insert_array(ROOT_SLIDES), assets: doc.get_or_insert_map(ROOT_ASSETS), } } pub fn get_string(txn: &T, map: &MapRef, key: &str) -> Option { match map.get(txn, key)? { Out::Any(Any::String(s)) => Some(s.to_string()), _ => None, } } pub fn get_number(txn: &T, map: &MapRef, key: &str) -> Option { match map.get(txn, key)? { Out::Any(Any::Number(n)) => Some(n), Out::Any(Any::BigInt(n)) => Some(n as f64), _ => None, } } pub fn get_map(txn: &T, map: &MapRef, key: &str) -> Option { match map.get(txn, key)? { Out::YMap(m) => Some(m), _ => None, } } pub fn get_array(txn: &T, map: &MapRef, key: &str) -> Option { match map.get(txn, key)? { Out::YArray(a) => Some(a), _ => None, } } pub fn get_fragment(txn: &T, map: &MapRef, key: &str) -> Option { match map.get(txn, key)? { Out::YXmlFragment(f) => Some(f), _ => None, } } /// The maps of an array of maps, in order, with their indices. Entries /// that are not maps (nothing this crate writes, but an update from a /// browser could) are skipped rather than trapped on. pub fn maps_of(txn: &T, array: &ArrayRef) -> Vec<(u32, MapRef)> { array .iter(txn) .enumerate() .filter_map(|(i, out)| match out { Out::YMap(m) => Some((i as u32, m)), _ => None, }) .collect() } /// The map in `array` whose `id` is `id`, with its index. pub fn find_by_id(txn: &T, array: &ArrayRef, id: &str) -> Option<(u32, MapRef)> { maps_of(txn, array) .into_iter() .find(|(_, m)| get_string(txn, m, "id").as_deref() == Some(id)) } // --- reading order --- // // Both of the document's ordered arrays — the deck's `slides` and a // slide's `blocks` — are SETS, and the order is a key, `order`, on each // map in them (spec §2.2, §2.3, rulings 18 and 21). The array's own // order is only the tie-break. The reason is what a reorder costs under // concurrency: moving an entry in a Y.Array means deleting the map and // inserting a copy, and every edit another peer made to the original — // a sentence typed into its text — then addresses a deleted type and is // lost on merge. Writing a number onto the map it already is leaves the // map, and the edits in flight against it, alone. That is codex 4, and // it is the same finding at both levels, so it has one answer. // // Absent `order` ranks as the array index, so a document written // before the key existed reads exactly as it did, and a slide nobody // has reordered carries no `order` at all. An op that changes the // order of a slide's blocks (move_block, canvas-to-flow) or of the // deck's slides (move_slide) writes a dense 0..n-1 onto every entry; // add_block / add_slide / duplicate_slide on an array that already // carries explicit orders renumbers so the new entry lands where // `after` asked. /// The rank an entry sorts by: its `order` when that is a finite /// number, else its array index. fn rank(txn: &T, index: u32, m: &MapRef) -> f64 { match get_number(txn, m, "order") { Some(n) if n.is_finite() => n, _ => index as f64, } } /// The maps of an ordered array (the deck's `slides`, a slide's /// `blocks`) in reading order: by `order`, then by array index for ties /// and for entries that carry none. The returned index is each map's /// array index, not its rank. pub fn ordered_maps(txn: &T, array: &ArrayRef) -> Vec<(u32, MapRef)> { let mut maps = maps_of(txn, array); maps.sort_by(|a, b| { rank(txn, a.0, &a.1) .partial_cmp(&rank(txn, b.0, &b.1)) .unwrap_or(std::cmp::Ordering::Equal) .then(a.0.cmp(&b.0)) }); maps } /// Whether any entry in `array` carries an explicit `order`. pub fn has_explicit_order(txn: &T, array: &ArrayRef) -> bool { maps_of(txn, array) .iter() .any(|(_, m)| get_number(txn, m, "order").is_some_and(f64::is_finite)) } /// Writes `order` 0..n-1 onto `maps` in the order given. pub fn renumber(txn: &mut TransactionMut, maps: &[MapRef]) { for (k, m) in maps.iter().enumerate() { m.insert(txn, "order", Any::Number(k as f64)); } } // --- JSON <-> yrs values --- /// A plain JSON value as a yrs `Any`. Objects and arrays become plain /// nested values, never shared types: this is for the leaves of the /// schema (`props` entries, `box`, `fonts`, an asset), which are edited /// whole. pub fn json_to_any(v: &serde_json::Value) -> Any { match v { serde_json::Value::Null => Any::Null, serde_json::Value::Bool(b) => Any::Bool(*b), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { if i.unsigned_abs() < (1u64 << 53) { Any::Number(i as f64) } else { Any::BigInt(i) } } else { Any::Number(n.as_f64().unwrap_or(0.0)) } } serde_json::Value::String(s) => Any::from(s.as_str()), serde_json::Value::Array(items) => { Any::Array(items.iter().map(json_to_any).collect::>().into()) } serde_json::Value::Object(entries) => Any::Map(Arc::new( entries .iter() .map(|(k, v)| (k.clone(), json_to_any(v))) .collect(), )), } } /// A JSON number for `f`: an integer when it is one (so `level: 1` reads /// back as `1`, not `1.0`), a float otherwise, null for NaN/inf. pub fn number_json(f: f64) -> serde_json::Value { if f.fract() == 0.0 && f.abs() < (1u64 << 53) as f64 { serde_json::Value::from(f as i64) } else { serde_json::Number::from_f64(f) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null) } } pub fn any_to_json(v: &Any) -> serde_json::Value { match v { Any::Null | Any::Undefined => serde_json::Value::Null, Any::Bool(b) => serde_json::Value::Bool(*b), Any::Number(n) => number_json(*n), Any::BigInt(n) => serde_json::Value::from(*n), Any::String(s) => serde_json::Value::String(s.to_string()), Any::Buffer(b) => { serde_json::Value::Array(b.iter().map(|x| serde_json::Value::from(*x)).collect()) } Any::Array(items) => serde_json::Value::Array(items.iter().map(any_to_json).collect()), Any::Map(entries) => serde_json::Value::Object( entries .iter() .map(|(k, v)| (k.clone(), any_to_json(v))) .collect(), ), } } /// Any value the document holds, as JSON. Shared types are rendered by /// content — a map as an object, an array as an array, a text fragment /// as its paragraphs — so the view need not know which of a key's /// possible representations a peer chose. pub fn out_to_json(txn: &T, out: &Out) -> serde_json::Value { match out { Out::Any(a) => any_to_json(a), Out::YMap(m) => map_to_json(txn, m), Out::YArray(a) => { serde_json::Value::Array(a.iter(txn).map(|item| out_to_json(txn, &item)).collect()) } Out::YXmlFragment(f) => serde_json::to_value(read_text(txn, f)).unwrap_or_default(), Out::YText(t) => serde_json::Value::String(t.get_string(txn)), Out::YXmlText(t) => serde_json::Value::String(t.get_string(txn)), Out::YXmlElement(e) => serde_json::Value::String(e.get_string(txn)), _ => serde_json::Value::Null, } } pub fn map_to_json(txn: &T, map: &MapRef) -> serde_json::Value { serde_json::Value::Object( map.iter(txn) .map(|(k, v)| (k.to_string(), out_to_json(txn, &v))) .collect(), ) } /// `Some(json)` when the value is a map (shared or plain), else `None`. pub fn get_object(txn: &T, map: &MapRef, key: &str) -> Option { match map.get(txn, key)? { Out::YMap(m) => Some(map_to_json(txn, &m)), Out::Any(a @ Any::Map(_)) => Some(any_to_json(&a)), _ => None, } } // --- constructors --- pub fn string_in(s: &str) -> In { In::Any(Any::from(s)) } pub fn number_in(n: f64) -> In { In::Any(Any::Number(n)) } /// A Y.Map prelim whose entries are plain values from a JSON object. pub fn plain_map_prelim(entries: &serde_json::Map) -> yrs::MapPrelim { entries .iter() .map(|(k, v)| (k.as_str(), In::Any(json_to_any(v)))) .collect() } /// A `{kind, value}` background as a Y.Map prelim. pub fn background_prelim(kind: &str, value: &str) -> yrs::MapPrelim { [("kind", string_in(kind)), ("value", string_in(value))] .into_iter() .collect() } /// An array of `n` empty text fragments — a table's cells. pub fn cells_prelim(n: usize) -> yrs::ArrayPrelim { (0..n) .map(|_| In::XmlFragment(XmlFragmentPrelim::default())) .collect() } /// A block map prelim. `text` is written for text-bearing types only, /// `cells` for tables only; flow blocks get `region`, canvas blocks /// `box` and `z` — the caller has already decided which. #[allow(clippy::too_many_arguments)] pub fn block_prelim( id: &str, kind: &str, props: &serde_json::Map, region: Option<&str>, r#box: Option<&serde_json::Value>, z: Option, text: Option<&[Paragraph]>, cells: Option, ) -> yrs::MapPrelim { let mut m: yrs::MapPrelim = [ ("id", string_in(id)), ("type", string_in(kind)), ("props", In::Map(plain_map_prelim(props))), ("phone", In::Map(yrs::MapPrelim::default())), ] .into_iter() .collect(); if let Some(r) = region { m.insert(Arc::from("region"), string_in(r)); } if let Some(b) = r#box { m.insert(Arc::from("box"), In::Any(json_to_any(b))); } if let Some(z) = z { m.insert(Arc::from("z"), number_in(z)); } if let Some(t) = text { m.insert(Arc::from("text"), In::XmlFragment(text_prelim(t))); } if let Some(n) = cells { m.insert(Arc::from("cells"), In::Array(cells_prelim(n))); } m } /// A slide map prelim with no blocks and empty notes. pub fn slide_prelim(id: &str, layout: &str, template: &str) -> yrs::MapPrelim { [ ("id", string_in(id)), ("layout", string_in(layout)), ("template", string_in(template)), ("phone", string_in("inherit")), ("transition", string_in("none")), ("background", In::Map(background_prelim("theme", ""))), ("notes", In::XmlFragment(XmlFragmentPrelim::default())), ("blocks", In::Array(yrs::ArrayPrelim::default())), ] .into_iter() .collect() } /// Rounds a box coordinate to the four decimals §2.3 specifies. pub fn round4(f: f64) -> f64 { (f * 10_000.0).round() / 10_000.0 } pub fn box_json(x: f64, y: f64, w: f64, h: f64) -> serde_json::Value { serde_json::json!({ "x": round4(x), "y": round4(y), "w": round4(w), "h": round4(h), }) } #[cfg(test)] mod tests { use super::*; use crate::new_doc; use yrs::Transact; fn linked(t: &str, href: &str) -> Run { Run { t: t.to_string(), href: Some(href.to_string()), ..Run::default() } } /// Writes one paragraph holding one XmlText formatted with `attrs`, /// then reads the fragment back the way the view does. fn read_back_written_attrs(attrs: Attrs) -> Vec { let doc = new_doc("test-href", 1); let frag = doc.get_or_insert_xml_fragment("t"); let mut txn = doc.transact_mut(); let el = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph")); let text = el.push_back(&mut txn, yrs::XmlTextPrelim::new("")); text.insert_with_attributes(&mut txn, 0, "x", attrs); read_text(&txn, &frag) } #[test] fn href_is_written_as_a_one_key_map_and_reads_back() { let doc = new_doc("test-href", 1); let frag = doc.get_or_insert_xml_fragment("t"); let mut txn = doc.transact_mut(); replace_text(&mut txn, &frag, &[vec![linked("t", "https://x")]]); // The stored attribute is y-prosemirror's shape, not a bare string. let Some(XmlOut::Element(el)) = frag.get(&txn, 0) else { panic!("the paragraph is an element"); }; let Some(XmlOut::Text(text)) = el.get(&txn, 0) else { panic!("the paragraph holds a text"); }; let chunks = text.diff(&txn, YChange::identity); let attrs = chunks[0].attributes.as_ref().expect("attributes"); assert_eq!(attrs.get("href"), Some(&href_any("https://x"))); assert_eq!(read_text(&txn, &frag), vec![vec![linked("t", "https://x")]]); } #[test] fn href_written_as_a_plain_string_still_reads_back() { // What documents written before the map shape carry. let attrs = Attrs::from([(Arc::from("href"), Any::from("https://old"))]); assert_eq!( read_back_written_attrs(attrs), vec![vec![linked("x", "https://old")]] ); } #[test] fn an_empty_map_reads_as_a_set_mark() { // y-prosemirror writes `{}` for a mark with no attributes. let attrs = Attrs::from([(Arc::from("b"), Any::Map(Arc::new(HashMap::new())))]); assert_eq!( read_back_written_attrs(attrs), vec![vec![Run { t: "x".to_string(), b: true, ..Run::default() }]] ); } #[test] fn an_href_map_without_a_string_url_reads_as_no_link() { let inner = HashMap::from([("href".to_string(), Any::Null)]); let attrs = Attrs::from([(Arc::from("href"), Any::Map(Arc::new(inner)))]); assert_eq!( read_back_written_attrs(attrs), vec![vec![Run { t: "x".to_string(), ..Run::default() }]] ); } }