oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

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
46use serde::Serialize;
47use std::collections::HashMap;
48use std::sync::Arc;
49use yrs::types::text::YChange;
50use yrs::types::xml::{XmlDeltaPrelim, XmlElementPrelim, XmlFragmentPrelim, XmlIn, XmlOut};
51use yrs::types::{Attrs, Delta};
52use yrs::{
53 Any, Array, ArrayRef, Doc, GetString, In, Map, MapRef, Out, ReadTxn, Text, TransactionMut,
54 XmlFragment, XmlFragmentRef,
55};
56
57pub const ROOT_META: &str = "meta";
58pub const ROOT_SLIDES: &str = "slides";
59pub const ROOT_ASSETS: &str = "assets";
60
61pub const LAYOUTS: &[&str] = &["flow", "canvas"];
62pub const TEMPLATES: &[&str] = &[
63 "title",
64 "title-body",
65 "two-column",
66 "section",
67 "image-full",
68 "quote",
69 "blank",
70];
71pub const REGIONS: &[&str] = &["heading", "body", "aside", "media"];
72pub const BACKGROUND_KINDS: &[&str] = &["theme", "color", "image"];
73pub const SLIDE_PHONES: &[&str] = &["inherit", "reflow"];
74pub const TRANSITIONS: &[&str] = &["none", "fade"];
75pub 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).
81pub const TEXT_TYPES: &[&str] = &["heading", "text", "list", "quote", "code", "shape"];
82pub const SIZES: &[&str] = &["s", "m", "l"];
83
84pub const DEFAULT_THEME: &str = "paper";
85pub const DEFAULT_ASPECT: &str = "16:9";
86pub const DEFAULT_TEMPLATE: &str = "blank";
87pub const DEFAULT_TABLE_ROWS: i64 = 2;
88pub 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.
92pub const MAX_TABLE_SIDE: i64 = 100;
93
94pub 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)]
104pub 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
118impl 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
128pub type Paragraph = Vec<Run>;
129
130const MARK_KEYS: [&str; 4] = ["b", "i", "c", "h"];
131
132fn 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}`.
147fn 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.
156fn 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
167fn 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.
189pub 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`.
194pub 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
204fn truthy(v: &Any) -> bool {
205 !matches!(v, Any::Null | Any::Undefined | Any::Bool(false))
206}
207
208fn 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.
235pub 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
270fn 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
280pub 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).
289pub 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
297pub 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
304pub 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
312pub 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
319pub 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
326pub 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.
336pub 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.
348pub 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.
378fn 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.
389pub 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`.
401pub 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.
408pub 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.
420pub 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.
450pub 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
460pub 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.
484pub 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
499pub 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`.
508pub 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
518pub fn string_in(s: &str) -> In {
519 In::Any(Any::from(s))
520}
521
522pub 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.
527pub 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.
535pub 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.
542pub 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)]
552pub 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.
589pub 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.
605pub fn round4(f: f64) -> f64 {
606 (f * 10_000.0).round() / 10_000.0
607}
608
609pub 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)]
619mod 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