| 1 | //! The deck's shape over yrs: root names, key names, the vocabularies the |
| 2 | //! spec closes, the text schema, and the small set of constructors and |
| 3 | //! accessors `ops` and `view` share. Spec §2 in code. |
| 4 | //! |
| 5 | //! What lives where (§2.1, §2.2, §2.4): |
| 6 | //! |
| 7 | //! - `meta` (Y.Map): `title`, `theme`, `accent`, `fonts`, `aspect` as |
| 8 | //! plain values (`fonts` is one `{heading, body}` object). |
| 9 | //! - `slides` (Y.Array of Y.Map): the SET of the deck's slides; their |
| 10 | //! order is the `order` key on each (see "reading order" below). Each |
| 11 | //! slide map holds `id`, `layout`, |
| 12 | //! `template`, `phone`, `transition` as strings, `background` as a |
| 13 | //! Y.Map `{kind, value}`, `notes` as a Y.XmlFragment, an optional |
| 14 | //! `order` (number), and `blocks` as a |
| 15 | //! Y.Array of block Y.Maps — the set of blocks; their reading order |
| 16 | //! is the `order` key on each, the same way. |
| 17 | //! - a block map holds `id`, `type`, `props` (Y.Map of plain values — |
| 18 | //! `columns.children` is a plain array inside it), `phone` (Y.Map of |
| 19 | //! the §2.5 overrides), an optional `order` (number), and then either |
| 20 | //! `region` (flow) or `box` + |
| 21 | //! `z` (canvas). `box` is one plain `{x, y, w, h}` value — the four |
| 22 | //! numbers move together, and merging two peers' drags coordinate by |
| 23 | //! coordinate would produce a box neither of them made. Text-bearing |
| 24 | //! types carry `text` (Y.XmlFragment); a `table` carries `cells` |
| 25 | //! (Y.Array of Y.XmlFragment, row-major, `rows * cols` long) as a key |
| 26 | //! of its own on the block map, which the view folds into |
| 27 | //! `props.cells`. |
| 28 | //! - `assets` (Y.Map): id to one plain `{ref, name, width, height, alt}` |
| 29 | //! value — an asset is registered once and never edited in place. |
| 30 | //! |
| 31 | //! The text schema (§2.6): an XmlFragment of `<paragraph>` elements, each |
| 32 | //! holding one XmlText. Marks are formatting attributes on the text's |
| 33 | //! runs: `b`, `i`, `c`, `h` (value `true`) and `href` (the one-key map |
| 34 | //! `{"href": url}`). The map is y-prosemirror's shape: it maps a mark to |
| 35 | //! the attribute `<mark name> = <mark attrs object>`, so the mark `href` |
| 36 | //! with attrs `{href}` is the attribute `href` with that object as its |
| 37 | //! value, and a plain string there would throw in `schema.mark` (which |
| 38 | //! makes y-prosemirror delete the text). The reader is more tolerant |
| 39 | //! than the writer — `href` reads as either the map or a plain string |
| 40 | //! (documents written before this shape), any truthy value counts as a |
| 41 | //! set mark (y-prosemirror writes `{}` for the attr-less marks), |
| 42 | //! several texts in one paragraph concatenate, and a bare text at |
| 43 | //! fragment level is its own paragraph — because updates reach the |
| 44 | //! server from browsers as well. |
| 45 | |
| 46 | use serde::Serialize; |
| 47 | use std::collections::HashMap; |
| 48 | use std::sync::Arc; |
| 49 | use yrs::types::text::YChange; |
| 50 | use yrs::types::xml::{XmlDeltaPrelim, XmlElementPrelim, XmlFragmentPrelim, XmlIn, XmlOut}; |
| 51 | use yrs::types::{Attrs, Delta}; |
| 52 | use yrs::{ |
| 53 | Any, Array, ArrayRef, Doc, GetString, In, Map, MapRef, Out, ReadTxn, Text, TransactionMut, |
| 54 | XmlFragment, XmlFragmentRef, |
| 55 | }; |
| 56 | |
| 57 | pub const ROOT_META: &str = "meta"; |
| 58 | pub const ROOT_SLIDES: &str = "slides"; |
| 59 | pub const ROOT_ASSETS: &str = "assets"; |
| 60 | |
| 61 | pub const LAYOUTS: &[&str] = &["flow", "canvas"]; |
| 62 | pub const TEMPLATES: &[&str] = &[ |
| 63 | "title", |
| 64 | "title-body", |
| 65 | "two-column", |
| 66 | "section", |
| 67 | "image-full", |
| 68 | "quote", |
| 69 | "blank", |
| 70 | ]; |
| 71 | pub const REGIONS: &[&str] = &["heading", "body", "aside", "media"]; |
| 72 | pub const BACKGROUND_KINDS: &[&str] = &["theme", "color", "image"]; |
| 73 | pub const SLIDE_PHONES: &[&str] = &["inherit", "reflow"]; |
| 74 | pub const TRANSITIONS: &[&str] = &["none", "fade"]; |
| 75 | pub const BLOCK_TYPES: &[&str] = &[ |
| 76 | "heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", |
| 77 | "spacer", |
| 78 | ]; |
| 79 | /// Types that carry a `text` fragment (§2.4's "Text: yes" column, plus |
| 80 | /// `shape`'s optional label). |
| 81 | pub const TEXT_TYPES: &[&str] = &["heading", "text", "list", "quote", "code", "shape"]; |
| 82 | pub const SIZES: &[&str] = &["s", "m", "l"]; |
| 83 | |
| 84 | pub const DEFAULT_THEME: &str = "paper"; |
| 85 | pub const DEFAULT_ASPECT: &str = "16:9"; |
| 86 | pub const DEFAULT_TEMPLATE: &str = "blank"; |
| 87 | pub const DEFAULT_TABLE_ROWS: i64 = 2; |
| 88 | pub const DEFAULT_TABLE_COLS: i64 = 2; |
| 89 | /// The most cells one table can hold. Not a product limit — a guard on |
| 90 | /// `set_block {props: {rows: 1000000}}` allocating a million fragments |
| 91 | /// inside one request. |
| 92 | pub const MAX_TABLE_SIDE: i64 = 100; |
| 93 | |
| 94 | pub fn is_text_type(kind: &str) -> bool { |
| 95 | TEXT_TYPES.contains(&kind) |
| 96 | } |
| 97 | |
| 98 | // --- text --- |
| 99 | |
| 100 | /// One formatted run of a paragraph. Serialises as `{t, b?, i?, c?, h?, |
| 101 | /// href?}` with false and absent marks omitted, which is the view's run |
| 102 | /// shape verbatim. |
| 103 | #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] |
| 104 | pub struct Run { |
| 105 | pub t: String, |
| 106 | #[serde(skip_serializing_if = "std::ops::Not::not")] |
| 107 | pub b: bool, |
| 108 | #[serde(skip_serializing_if = "std::ops::Not::not")] |
| 109 | pub i: bool, |
| 110 | #[serde(skip_serializing_if = "std::ops::Not::not")] |
| 111 | pub c: bool, |
| 112 | #[serde(skip_serializing_if = "std::ops::Not::not")] |
| 113 | pub h: bool, |
| 114 | #[serde(skip_serializing_if = "Option::is_none")] |
| 115 | pub href: Option<String>, |
| 116 | } |
| 117 | |
| 118 | impl Run { |
| 119 | pub fn same_marks(&self, other: &Run) -> bool { |
| 120 | self.b == other.b |
| 121 | && self.i == other.i |
| 122 | && self.c == other.c |
| 123 | && self.h == other.h |
| 124 | && self.href == other.href |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | pub type Paragraph = Vec<Run>; |
| 129 | |
| 130 | const MARK_KEYS: [&str; 4] = ["b", "i", "c", "h"]; |
| 131 | |
| 132 | fn run_attrs(run: &Run) -> Attrs { |
| 133 | let mut attrs = Attrs::new(); |
| 134 | for (key, on) in MARK_KEYS.iter().zip([run.b, run.i, run.c, run.h]) { |
| 135 | if on { |
| 136 | attrs.insert(Arc::from(*key), Any::Bool(true)); |
| 137 | } |
| 138 | } |
| 139 | if let Some(href) = &run.href { |
| 140 | attrs.insert(Arc::from("href"), href_any(href)); |
| 141 | } |
| 142 | attrs |
| 143 | } |
| 144 | |
| 145 | /// The `href` attribute's value: y-prosemirror's one-key map for the mark |
| 146 | /// `href` with attrs `{href}`. |
| 147 | fn href_any(href: &str) -> Any { |
| 148 | Any::Map(Arc::new(HashMap::from([( |
| 149 | "href".to_string(), |
| 150 | Any::from(href), |
| 151 | )]))) |
| 152 | } |
| 153 | |
| 154 | /// The URL an `href` attribute carries: the `{"href": url}` map this |
| 155 | /// module writes, or the plain string documents written before it carry. |
| 156 | fn href_of(v: &Any) -> Option<String> { |
| 157 | match v { |
| 158 | Any::String(s) => Some(s.to_string()), |
| 159 | Any::Map(m) => match m.get("href") { |
| 160 | Some(Any::String(s)) => Some(s.to_string()), |
| 161 | _ => None, |
| 162 | }, |
| 163 | _ => None, |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | fn paragraph_prelim(paragraph: &[Run]) -> XmlElementPrelim { |
| 168 | let delta: Vec<Delta<In>> = paragraph |
| 169 | .iter() |
| 170 | .filter(|run| !run.t.is_empty()) |
| 171 | .map(|run| { |
| 172 | let attrs = run_attrs(run); |
| 173 | let attrs = if attrs.is_empty() { |
| 174 | None |
| 175 | } else { |
| 176 | Some(Box::new(attrs)) |
| 177 | }; |
| 178 | Delta::Inserted(In::Any(Any::from(run.t.as_str())), attrs) |
| 179 | }) |
| 180 | .collect(); |
| 181 | let text = XmlDeltaPrelim { |
| 182 | attributes: HashMap::default(), |
| 183 | delta, |
| 184 | }; |
| 185 | XmlElementPrelim::new("paragraph", [XmlIn::Text(text)]) |
| 186 | } |
| 187 | |
| 188 | /// A fragment holding `text` in the §2.6 schema, ready to insert. |
| 189 | pub fn text_prelim(text: &[Paragraph]) -> XmlFragmentPrelim { |
| 190 | XmlFragmentPrelim::new::<_, ()>(text.iter().map(|p| XmlIn::Element(paragraph_prelim(p)))) |
| 191 | } |
| 192 | |
| 193 | /// Replaces the whole content of `frag` with `text`. |
| 194 | pub fn replace_text(txn: &mut TransactionMut, frag: &XmlFragmentRef, text: &[Paragraph]) { |
| 195 | let len = frag.len(txn); |
| 196 | if len > 0 { |
| 197 | frag.remove_range(txn, 0, len); |
| 198 | } |
| 199 | for p in text { |
| 200 | frag.push_back(txn, paragraph_prelim(p)); |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | fn truthy(v: &Any) -> bool { |
| 205 | !matches!(v, Any::Null | Any::Undefined | Any::Bool(false)) |
| 206 | } |
| 207 | |
| 208 | fn push_runs<T: ReadTxn>(txn: &T, text: &yrs::XmlTextRef, out: &mut Paragraph) { |
| 209 | for chunk in text.diff(txn, YChange::identity) { |
| 210 | let Out::Any(Any::String(s)) = &chunk.insert else { |
| 211 | continue; // an embed: nothing in this schema, nothing to show |
| 212 | }; |
| 213 | if s.is_empty() { |
| 214 | continue; |
| 215 | } |
| 216 | let mut run = Run { |
| 217 | t: s.to_string(), |
| 218 | ..Run::default() |
| 219 | }; |
| 220 | if let Some(attrs) = &chunk.attributes { |
| 221 | run.b = attrs.get("b").is_some_and(truthy); |
| 222 | run.i = attrs.get("i").is_some_and(truthy); |
| 223 | run.c = attrs.get("c").is_some_and(truthy); |
| 224 | run.h = attrs.get("h").is_some_and(truthy); |
| 225 | run.href = attrs.get("href").and_then(href_of); |
| 226 | } |
| 227 | match out.last_mut() { |
| 228 | Some(last) if last.same_marks(&run) => last.t.push_str(&run.t), |
| 229 | _ => out.push(run), |
| 230 | } |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /// The paragraphs of a text fragment. |
| 235 | pub fn read_text<T: ReadTxn>(txn: &T, frag: &XmlFragmentRef) -> Vec<Paragraph> { |
| 236 | let mut paragraphs = Vec::new(); |
| 237 | for child in frag.children(txn) { |
| 238 | match child { |
| 239 | XmlOut::Element(e) => { |
| 240 | let mut p = Paragraph::new(); |
| 241 | for inner in e.children(txn) { |
| 242 | match inner { |
| 243 | XmlOut::Text(t) => push_runs(txn, &t, &mut p), |
| 244 | // An element inside a paragraph is outside this |
| 245 | // schema; keep its plain text rather than lose it. |
| 246 | other => { |
| 247 | let s = other_string(txn, &other); |
| 248 | if !s.is_empty() { |
| 249 | p.push(Run { |
| 250 | t: s, |
| 251 | ..Run::default() |
| 252 | }); |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | paragraphs.push(p); |
| 258 | } |
| 259 | XmlOut::Text(t) => { |
| 260 | let mut p = Paragraph::new(); |
| 261 | push_runs(txn, &t, &mut p); |
| 262 | paragraphs.push(p); |
| 263 | } |
| 264 | XmlOut::Fragment(f) => paragraphs.extend(read_text(txn, &f)), |
| 265 | } |
| 266 | } |
| 267 | paragraphs |
| 268 | } |
| 269 | |
| 270 | fn other_string<T: ReadTxn>(txn: &T, node: &XmlOut) -> String { |
| 271 | match node { |
| 272 | XmlOut::Element(e) => e.get_string(txn), |
| 273 | XmlOut::Fragment(f) => f.get_string(txn), |
| 274 | XmlOut::Text(t) => t.get_string(txn), |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | // --- roots and lookups --- |
| 279 | |
| 280 | pub struct Roots { |
| 281 | pub meta: MapRef, |
| 282 | pub slides: ArrayRef, |
| 283 | pub assets: MapRef, |
| 284 | } |
| 285 | |
| 286 | /// The three root types. `get_or_insert_*` takes a write transaction of |
| 287 | /// its own internally, so this must run BEFORE any transaction the caller |
| 288 | /// opens (Docs' `snapshot.rs` records the self-deadlock otherwise). |
| 289 | pub fn roots(doc: &Doc) -> Roots { |
| 290 | Roots { |
| 291 | meta: doc.get_or_insert_map(ROOT_META), |
| 292 | slides: doc.get_or_insert_array(ROOT_SLIDES), |
| 293 | assets: doc.get_or_insert_map(ROOT_ASSETS), |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | pub fn get_string<T: ReadTxn>(txn: &T, map: &MapRef, key: &str) -> Option<String> { |
| 298 | match map.get(txn, key)? { |
| 299 | Out::Any(Any::String(s)) => Some(s.to_string()), |
| 300 | _ => None, |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | pub fn get_number<T: ReadTxn>(txn: &T, map: &MapRef, key: &str) -> Option<f64> { |
| 305 | match map.get(txn, key)? { |
| 306 | Out::Any(Any::Number(n)) => Some(n), |
| 307 | Out::Any(Any::BigInt(n)) => Some(n as f64), |
| 308 | _ => None, |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | pub fn get_map<T: ReadTxn>(txn: &T, map: &MapRef, key: &str) -> Option<MapRef> { |
| 313 | match map.get(txn, key)? { |
| 314 | Out::YMap(m) => Some(m), |
| 315 | _ => None, |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | pub fn get_array<T: ReadTxn>(txn: &T, map: &MapRef, key: &str) -> Option<ArrayRef> { |
| 320 | match map.get(txn, key)? { |
| 321 | Out::YArray(a) => Some(a), |
| 322 | _ => None, |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | pub fn get_fragment<T: ReadTxn>(txn: &T, map: &MapRef, key: &str) -> Option<XmlFragmentRef> { |
| 327 | match map.get(txn, key)? { |
| 328 | Out::YXmlFragment(f) => Some(f), |
| 329 | _ => None, |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | /// The maps of an array of maps, in order, with their indices. Entries |
| 334 | /// that are not maps (nothing this crate writes, but an update from a |
| 335 | /// browser could) are skipped rather than trapped on. |
| 336 | pub fn maps_of<T: ReadTxn>(txn: &T, array: &ArrayRef) -> Vec<(u32, MapRef)> { |
| 337 | array |
| 338 | .iter(txn) |
| 339 | .enumerate() |
| 340 | .filter_map(|(i, out)| match out { |
| 341 | Out::YMap(m) => Some((i as u32, m)), |
| 342 | _ => None, |
| 343 | }) |
| 344 | .collect() |
| 345 | } |
| 346 | |
| 347 | /// The map in `array` whose `id` is `id`, with its index. |
| 348 | pub fn find_by_id<T: ReadTxn>(txn: &T, array: &ArrayRef, id: &str) -> Option<(u32, MapRef)> { |
| 349 | maps_of(txn, array) |
| 350 | .into_iter() |
| 351 | .find(|(_, m)| get_string(txn, m, "id").as_deref() == Some(id)) |
| 352 | } |
| 353 | |
| 354 | // --- reading order --- |
| 355 | // |
| 356 | // Both of the document's ordered arrays — the deck's `slides` and a |
| 357 | // slide's `blocks` — are SETS, and the order is a key, `order`, on each |
| 358 | // map in them (spec §2.2, §2.3, rulings 18 and 21). The array's own |
| 359 | // order is only the tie-break. The reason is what a reorder costs under |
| 360 | // concurrency: moving an entry in a Y.Array means deleting the map and |
| 361 | // inserting a copy, and every edit another peer made to the original — |
| 362 | // a sentence typed into its text — then addresses a deleted type and is |
| 363 | // lost on merge. Writing a number onto the map it already is leaves the |
| 364 | // map, and the edits in flight against it, alone. That is codex 4, and |
| 365 | // it is the same finding at both levels, so it has one answer. |
| 366 | // |
| 367 | // Absent `order` ranks as the array index, so a document written |
| 368 | // before the key existed reads exactly as it did, and a slide nobody |
| 369 | // has reordered carries no `order` at all. An op that changes the |
| 370 | // order of a slide's blocks (move_block, canvas-to-flow) or of the |
| 371 | // deck's slides (move_slide) writes a dense 0..n-1 onto every entry; |
| 372 | // add_block / add_slide / duplicate_slide on an array that already |
| 373 | // carries explicit orders renumbers so the new entry lands where |
| 374 | // `after` asked. |
| 375 | |
| 376 | /// The rank an entry sorts by: its `order` when that is a finite |
| 377 | /// number, else its array index. |
| 378 | fn rank<T: ReadTxn>(txn: &T, index: u32, m: &MapRef) -> f64 { |
| 379 | match get_number(txn, m, "order") { |
| 380 | Some(n) if n.is_finite() => n, |
| 381 | _ => index as f64, |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | /// The maps of an ordered array (the deck's `slides`, a slide's |
| 386 | /// `blocks`) in reading order: by `order`, then by array index for ties |
| 387 | /// and for entries that carry none. The returned index is each map's |
| 388 | /// array index, not its rank. |
| 389 | pub fn ordered_maps<T: ReadTxn>(txn: &T, array: &ArrayRef) -> Vec<(u32, MapRef)> { |
| 390 | let mut maps = maps_of(txn, array); |
| 391 | maps.sort_by(|a, b| { |
| 392 | rank(txn, a.0, &a.1) |
| 393 | .partial_cmp(&rank(txn, b.0, &b.1)) |
| 394 | .unwrap_or(std::cmp::Ordering::Equal) |
| 395 | .then(a.0.cmp(&b.0)) |
| 396 | }); |
| 397 | maps |
| 398 | } |
| 399 | |
| 400 | /// Whether any entry in `array` carries an explicit `order`. |
| 401 | pub fn has_explicit_order<T: ReadTxn>(txn: &T, array: &ArrayRef) -> bool { |
| 402 | maps_of(txn, array) |
| 403 | .iter() |
| 404 | .any(|(_, m)| get_number(txn, m, "order").is_some_and(f64::is_finite)) |
| 405 | } |
| 406 | |
| 407 | /// Writes `order` 0..n-1 onto `maps` in the order given. |
| 408 | pub fn renumber(txn: &mut TransactionMut, maps: &[MapRef]) { |
| 409 | for (k, m) in maps.iter().enumerate() { |
| 410 | m.insert(txn, "order", Any::Number(k as f64)); |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | // --- JSON <-> yrs values --- |
| 415 | |
| 416 | /// A plain JSON value as a yrs `Any`. Objects and arrays become plain |
| 417 | /// nested values, never shared types: this is for the leaves of the |
| 418 | /// schema (`props` entries, `box`, `fonts`, an asset), which are edited |
| 419 | /// whole. |
| 420 | pub fn json_to_any(v: &serde_json::Value) -> Any { |
| 421 | match v { |
| 422 | serde_json::Value::Null => Any::Null, |
| 423 | serde_json::Value::Bool(b) => Any::Bool(*b), |
| 424 | serde_json::Value::Number(n) => { |
| 425 | if let Some(i) = n.as_i64() { |
| 426 | if i.unsigned_abs() < (1u64 << 53) { |
| 427 | Any::Number(i as f64) |
| 428 | } else { |
| 429 | Any::BigInt(i) |
| 430 | } |
| 431 | } else { |
| 432 | Any::Number(n.as_f64().unwrap_or(0.0)) |
| 433 | } |
| 434 | } |
| 435 | serde_json::Value::String(s) => Any::from(s.as_str()), |
| 436 | serde_json::Value::Array(items) => { |
| 437 | Any::Array(items.iter().map(json_to_any).collect::<Vec<_>>().into()) |
| 438 | } |
| 439 | serde_json::Value::Object(entries) => Any::Map(Arc::new( |
| 440 | entries |
| 441 | .iter() |
| 442 | .map(|(k, v)| (k.clone(), json_to_any(v))) |
| 443 | .collect(), |
| 444 | )), |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | /// A JSON number for `f`: an integer when it is one (so `level: 1` reads |
| 449 | /// back as `1`, not `1.0`), a float otherwise, null for NaN/inf. |
| 450 | pub fn number_json(f: f64) -> serde_json::Value { |
| 451 | if f.fract() == 0.0 && f.abs() < (1u64 << 53) as f64 { |
| 452 | serde_json::Value::from(f as i64) |
| 453 | } else { |
| 454 | serde_json::Number::from_f64(f) |
| 455 | .map(serde_json::Value::Number) |
| 456 | .unwrap_or(serde_json::Value::Null) |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | pub fn any_to_json(v: &Any) -> serde_json::Value { |
| 461 | match v { |
| 462 | Any::Null | Any::Undefined => serde_json::Value::Null, |
| 463 | Any::Bool(b) => serde_json::Value::Bool(*b), |
| 464 | Any::Number(n) => number_json(*n), |
| 465 | Any::BigInt(n) => serde_json::Value::from(*n), |
| 466 | Any::String(s) => serde_json::Value::String(s.to_string()), |
| 467 | Any::Buffer(b) => { |
| 468 | serde_json::Value::Array(b.iter().map(|x| serde_json::Value::from(*x)).collect()) |
| 469 | } |
| 470 | Any::Array(items) => serde_json::Value::Array(items.iter().map(any_to_json).collect()), |
| 471 | Any::Map(entries) => serde_json::Value::Object( |
| 472 | entries |
| 473 | .iter() |
| 474 | .map(|(k, v)| (k.clone(), any_to_json(v))) |
| 475 | .collect(), |
| 476 | ), |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | /// Any value the document holds, as JSON. Shared types are rendered by |
| 481 | /// content — a map as an object, an array as an array, a text fragment |
| 482 | /// as its paragraphs — so the view need not know which of a key's |
| 483 | /// possible representations a peer chose. |
| 484 | pub fn out_to_json<T: ReadTxn>(txn: &T, out: &Out) -> serde_json::Value { |
| 485 | match out { |
| 486 | Out::Any(a) => any_to_json(a), |
| 487 | Out::YMap(m) => map_to_json(txn, m), |
| 488 | Out::YArray(a) => { |
| 489 | serde_json::Value::Array(a.iter(txn).map(|item| out_to_json(txn, &item)).collect()) |
| 490 | } |
| 491 | Out::YXmlFragment(f) => serde_json::to_value(read_text(txn, f)).unwrap_or_default(), |
| 492 | Out::YText(t) => serde_json::Value::String(t.get_string(txn)), |
| 493 | Out::YXmlText(t) => serde_json::Value::String(t.get_string(txn)), |
| 494 | Out::YXmlElement(e) => serde_json::Value::String(e.get_string(txn)), |
| 495 | _ => serde_json::Value::Null, |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | pub fn map_to_json<T: ReadTxn>(txn: &T, map: &MapRef) -> serde_json::Value { |
| 500 | serde_json::Value::Object( |
| 501 | map.iter(txn) |
| 502 | .map(|(k, v)| (k.to_string(), out_to_json(txn, &v))) |
| 503 | .collect(), |
| 504 | ) |
| 505 | } |
| 506 | |
| 507 | /// `Some(json)` when the value is a map (shared or plain), else `None`. |
| 508 | pub fn get_object<T: ReadTxn>(txn: &T, map: &MapRef, key: &str) -> Option<serde_json::Value> { |
| 509 | match map.get(txn, key)? { |
| 510 | Out::YMap(m) => Some(map_to_json(txn, &m)), |
| 511 | Out::Any(a @ Any::Map(_)) => Some(any_to_json(&a)), |
| 512 | _ => None, |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | // --- constructors --- |
| 517 | |
| 518 | pub fn string_in(s: &str) -> In { |
| 519 | In::Any(Any::from(s)) |
| 520 | } |
| 521 | |
| 522 | pub fn number_in(n: f64) -> In { |
| 523 | In::Any(Any::Number(n)) |
| 524 | } |
| 525 | |
| 526 | /// A Y.Map prelim whose entries are plain values from a JSON object. |
| 527 | pub fn plain_map_prelim(entries: &serde_json::Map<String, serde_json::Value>) -> yrs::MapPrelim { |
| 528 | entries |
| 529 | .iter() |
| 530 | .map(|(k, v)| (k.as_str(), In::Any(json_to_any(v)))) |
| 531 | .collect() |
| 532 | } |
| 533 | |
| 534 | /// A `{kind, value}` background as a Y.Map prelim. |
| 535 | pub fn background_prelim(kind: &str, value: &str) -> yrs::MapPrelim { |
| 536 | [("kind", string_in(kind)), ("value", string_in(value))] |
| 537 | .into_iter() |
| 538 | .collect() |
| 539 | } |
| 540 | |
| 541 | /// An array of `n` empty text fragments — a table's cells. |
| 542 | pub fn cells_prelim(n: usize) -> yrs::ArrayPrelim { |
| 543 | (0..n) |
| 544 | .map(|_| In::XmlFragment(XmlFragmentPrelim::default())) |
| 545 | .collect() |
| 546 | } |
| 547 | |
| 548 | /// A block map prelim. `text` is written for text-bearing types only, |
| 549 | /// `cells` for tables only; flow blocks get `region`, canvas blocks |
| 550 | /// `box` and `z` — the caller has already decided which. |
| 551 | #[allow(clippy::too_many_arguments)] |
| 552 | pub fn block_prelim( |
| 553 | id: &str, |
| 554 | kind: &str, |
| 555 | props: &serde_json::Map<String, serde_json::Value>, |
| 556 | region: Option<&str>, |
| 557 | r#box: Option<&serde_json::Value>, |
| 558 | z: Option<f64>, |
| 559 | text: Option<&[Paragraph]>, |
| 560 | cells: Option<usize>, |
| 561 | ) -> yrs::MapPrelim { |
| 562 | let mut m: yrs::MapPrelim = [ |
| 563 | ("id", string_in(id)), |
| 564 | ("type", string_in(kind)), |
| 565 | ("props", In::Map(plain_map_prelim(props))), |
| 566 | ("phone", In::Map(yrs::MapPrelim::default())), |
| 567 | ] |
| 568 | .into_iter() |
| 569 | .collect(); |
| 570 | if let Some(r) = region { |
| 571 | m.insert(Arc::from("region"), string_in(r)); |
| 572 | } |
| 573 | if let Some(b) = r#box { |
| 574 | m.insert(Arc::from("box"), In::Any(json_to_any(b))); |
| 575 | } |
| 576 | if let Some(z) = z { |
| 577 | m.insert(Arc::from("z"), number_in(z)); |
| 578 | } |
| 579 | if let Some(t) = text { |
| 580 | m.insert(Arc::from("text"), In::XmlFragment(text_prelim(t))); |
| 581 | } |
| 582 | if let Some(n) = cells { |
| 583 | m.insert(Arc::from("cells"), In::Array(cells_prelim(n))); |
| 584 | } |
| 585 | m |
| 586 | } |
| 587 | |
| 588 | /// A slide map prelim with no blocks and empty notes. |
| 589 | pub fn slide_prelim(id: &str, layout: &str, template: &str) -> yrs::MapPrelim { |
| 590 | [ |
| 591 | ("id", string_in(id)), |
| 592 | ("layout", string_in(layout)), |
| 593 | ("template", string_in(template)), |
| 594 | ("phone", string_in("inherit")), |
| 595 | ("transition", string_in("none")), |
| 596 | ("background", In::Map(background_prelim("theme", ""))), |
| 597 | ("notes", In::XmlFragment(XmlFragmentPrelim::default())), |
| 598 | ("blocks", In::Array(yrs::ArrayPrelim::default())), |
| 599 | ] |
| 600 | .into_iter() |
| 601 | .collect() |
| 602 | } |
| 603 | |
| 604 | /// Rounds a box coordinate to the four decimals §2.3 specifies. |
| 605 | pub fn round4(f: f64) -> f64 { |
| 606 | (f * 10_000.0).round() / 10_000.0 |
| 607 | } |
| 608 | |
| 609 | pub fn box_json(x: f64, y: f64, w: f64, h: f64) -> serde_json::Value { |
| 610 | serde_json::json!({ |
| 611 | "x": round4(x), |
| 612 | "y": round4(y), |
| 613 | "w": round4(w), |
| 614 | "h": round4(h), |
| 615 | }) |
| 616 | } |
| 617 | |
| 618 | #[cfg(test)] |
| 619 | mod tests { |
| 620 | use super::*; |
| 621 | use crate::new_doc; |
| 622 | use yrs::Transact; |
| 623 | |
| 624 | fn linked(t: &str, href: &str) -> Run { |
| 625 | Run { |
| 626 | t: t.to_string(), |
| 627 | href: Some(href.to_string()), |
| 628 | ..Run::default() |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /// Writes one paragraph holding one XmlText formatted with `attrs`, |
| 633 | /// then reads the fragment back the way the view does. |
| 634 | fn read_back_written_attrs(attrs: Attrs) -> Vec<Paragraph> { |
| 635 | let doc = new_doc("test-href", 1); |
| 636 | let frag = doc.get_or_insert_xml_fragment("t"); |
| 637 | let mut txn = doc.transact_mut(); |
| 638 | let el = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph")); |
| 639 | let text = el.push_back(&mut txn, yrs::XmlTextPrelim::new("")); |
| 640 | text.insert_with_attributes(&mut txn, 0, "x", attrs); |
| 641 | read_text(&txn, &frag) |
| 642 | } |
| 643 | |
| 644 | #[test] |
| 645 | fn href_is_written_as_a_one_key_map_and_reads_back() { |
| 646 | let doc = new_doc("test-href", 1); |
| 647 | let frag = doc.get_or_insert_xml_fragment("t"); |
| 648 | let mut txn = doc.transact_mut(); |
| 649 | replace_text(&mut txn, &frag, &[vec![linked("t", "https://x")]]); |
| 650 | |
| 651 | // The stored attribute is y-prosemirror's shape, not a bare string. |
| 652 | let Some(XmlOut::Element(el)) = frag.get(&txn, 0) else { |
| 653 | panic!("the paragraph is an element"); |
| 654 | }; |
| 655 | let Some(XmlOut::Text(text)) = el.get(&txn, 0) else { |
| 656 | panic!("the paragraph holds a text"); |
| 657 | }; |
| 658 | let chunks = text.diff(&txn, YChange::identity); |
| 659 | let attrs = chunks[0].attributes.as_ref().expect("attributes"); |
| 660 | assert_eq!(attrs.get("href"), Some(&href_any("https://x"))); |
| 661 | |
| 662 | assert_eq!(read_text(&txn, &frag), vec![vec![linked("t", "https://x")]]); |
| 663 | } |
| 664 | |
| 665 | #[test] |
| 666 | fn href_written_as_a_plain_string_still_reads_back() { |
| 667 | // What documents written before the map shape carry. |
| 668 | let attrs = Attrs::from([(Arc::from("href"), Any::from("https://old"))]); |
| 669 | assert_eq!( |
| 670 | read_back_written_attrs(attrs), |
| 671 | vec![vec![linked("x", "https://old")]] |
| 672 | ); |
| 673 | } |
| 674 | |
| 675 | #[test] |
| 676 | fn an_empty_map_reads_as_a_set_mark() { |
| 677 | // y-prosemirror writes `{}` for a mark with no attributes. |
| 678 | let attrs = Attrs::from([(Arc::from("b"), Any::Map(Arc::new(HashMap::new())))]); |
| 679 | assert_eq!( |
| 680 | read_back_written_attrs(attrs), |
| 681 | vec![vec![Run { |
| 682 | t: "x".to_string(), |
| 683 | b: true, |
| 684 | ..Run::default() |
| 685 | }]] |
| 686 | ); |
| 687 | } |
| 688 | |
| 689 | #[test] |
| 690 | fn an_href_map_without_a_string_url_reads_as_no_link() { |
| 691 | let inner = HashMap::from([("href".to_string(), Any::Null)]); |
| 692 | let attrs = Attrs::from([(Arc::from("href"), Any::Map(Arc::new(inner)))]); |
| 693 | assert_eq!( |
| 694 | read_back_written_attrs(attrs), |
| 695 | vec![vec![Run { |
| 696 | t: "x".to_string(), |
| 697 | ..Run::default() |
| 698 | }]] |
| 699 | ); |
| 700 | } |
| 701 | } |
| 702 | |