| 1 | //! Resolve optimistic native UTF-16 display positions against their own source |
| 2 | //! generation, never against an authority which may already include peer edits. |
| 3 | use crate::{operations, projection, structure}; |
| 4 | use serde_json::{json, Value}; |
| 5 | use yrs::{Doc, Transact}; |
| 6 | struct Span { node: String, start: u32, length: u32, atomic: bool } |
| 7 | fn spans(value: &Value, offset: &mut u32, output: &mut Vec<Span>) { |
| 8 | let tag = value["type"].as_str().unwrap_or(""); |
| 9 | if tag == "text" { |
| 10 | let length = value["runs"].as_array().map(|runs| runs.iter().map(|r| r["insert"].as_str().unwrap_or("").encode_utf16().count() as u32).sum()).unwrap_or(0); |
| 11 | output.push(Span { node: value["id"].as_str().unwrap_or("").to_owned(), start: *offset, length, atomic: false }); |
| 12 | *offset += length; return; |
| 13 | } |
| 14 | if ["hard_break", "image", "horizontal_rule"].contains(&tag) { |
| 15 | output.push(Span { node: value["id"].as_str().unwrap_or("").to_owned(), start: *offset, length: 1, atomic: true }); |
| 16 | *offset += if tag == "horizontal_rule" { 2 } else { 1 }; return; |
| 17 | } |
| 18 | let start = *offset; |
| 19 | if let Some(children) = value["children"].as_array() { for child in children { spans(child, offset, output); } } |
| 20 | if ["paragraph", "heading", "code_block"].contains(&tag) { |
| 21 | if *offset == start { output.push(Span { node: value["id"].as_str().unwrap_or("").to_owned(), start, length: 0, atomic: false }); } |
| 22 | *offset += 1; |
| 23 | } |
| 24 | } |
| 25 | pub(crate) fn locate(doc: &Doc, at: u32, length: u32) -> Result<(String,u32), &'static str> { |
| 26 | let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| 27 | let mut offset = 0; let mut ranges = Vec::new(); |
| 28 | for child in projection::children(&root, &doc.transact()) { spans(&child, &mut offset, &mut ranges); } |
| 29 | let end = at.checked_add(length).ok_or("invalid_range")?; |
| 30 | ranges.iter().find(|r| !r.atomic && at >= r.start && end <= r.start + r.length) |
| 31 | .or_else(|| ranges.iter().find(|r| r.atomic && at >= r.start && end <= r.start + r.length)) |
| 32 | .map(|r| (r.node.clone(), at-r.start)).ok_or("unsupported_range") |
| 33 | } |
| 34 | fn endpoint(doc: &Doc, at: u32) -> Result<(String,u32), &'static str> { |
| 35 | use yrs::{XmlFragment, XmlTextPrelim}; |
| 36 | let (id, local) = locate(doc,at,0)?; |
| 37 | let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| 38 | let mut txn = doc.transact_mut_with("native-edit"); |
| 39 | let inline_atom = projection::find(&root,&txn,&id).and_then(yrs::XmlOut::into_xml_element) |
| 40 | .is_some_and(|e| ["image","hard_break"].contains(&e.tag().as_ref())); |
| 41 | if inline_atom { |
| 42 | let (parent,index) = structure::parent(&root,&txn,&id).ok_or("missing_parent")?; |
| 43 | let text = parent.insert(&mut txn,index + if local == 1 { 1 } else { 0 },XmlTextPrelim::new("")); |
| 44 | return Ok((projection::node_id(&yrs::XmlOut::Text(text)),0)); |
| 45 | } |
| 46 | Ok((id,local)) |
| 47 | } |
| 48 | pub fn execute(doc: &Doc, request: &Value) -> Result<(), &'static str> { |
| 49 | let at = request["at"].as_u64().and_then(|v| u32::try_from(v).ok()).ok_or("invalid_range")?; |
| 50 | let mut length = request["length"].as_u64().and_then(|v| u32::try_from(v).ok()).ok_or("invalid_range")?; |
| 51 | let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| 52 | let mut display_length = 0; let mut ranges = Vec::new(); |
| 53 | for child in projection::children(&root, &doc.transact()) { spans(&child, &mut display_length, &mut ranges); } |
| 54 | let end = at.checked_add(length).ok_or("invalid_range")?; |
| 55 | if end > display_length { return Err("invalid_range"); } |
| 56 | // The final native paragraph terminator is presentation, not user content. |
| 57 | if length > 0 && end == display_length { length -= 1; } |
| 58 | let text = request["text"].as_str().ok_or("invalid_command")?; |
| 59 | let (node, local) = match locate(doc, at, length) { |
| 60 | Ok(value) => value, |
| 61 | Err(_) if length > 0 => { |
| 62 | let (start, local) = endpoint(doc, at)?; |
| 63 | let (end, end_at) = endpoint(doc, at.checked_add(length).ok_or("invalid_range")?)?; |
| 64 | structure::execute(doc, &json!({"op":"join_range","node":start,"at":local,"end_node":end,"end_at":end_at}))?; |
| 65 | return execute(doc, &json!({"op":"display_edit","at":at,"length":0,"text":text,"marks":request["marks"]})); |
| 66 | }, |
| 67 | Err(error) => return Err(error), |
| 68 | }; |
| 69 | let atom = projection::find(&root,&doc.transact(),&node).and_then(yrs::XmlOut::into_xml_element) |
| 70 | .filter(|element| ["image","hard_break","horizontal_rule"].contains(&element.tag().as_ref())); |
| 71 | if let Some(atom) = atom { |
| 72 | use yrs::{XmlFragment, XmlElementPrelim, XmlTextPrelim}; |
| 73 | if local > 1 || length > 1 || (length == 1 && local != 0) { return Err("invalid_range"); } |
| 74 | let mut txn = doc.transact_mut_with("native-edit"); |
| 75 | let (parent,index) = structure::parent(&root,&txn,&node).ok_or("missing_parent")?; |
| 76 | if atom.tag().as_ref() == "horizontal_rule" { |
| 77 | if length != 1 { return Err("unsupported_range"); } |
| 78 | parent.remove(&mut txn,index); |
| 79 | parent.insert(&mut txn,index,XmlElementPrelim::new("paragraph",[XmlTextPrelim::new("").into()])); |
| 80 | } else { |
| 81 | if length == 1 { parent.remove(&mut txn,index); } |
| 82 | parent.insert(&mut txn,index + if local == 1 { 1 } else { 0 },XmlTextPrelim::new("")); |
| 83 | } |
| 84 | drop(txn); |
| 85 | return execute(doc,&json!({"op":"display_edit","at":at,"length":0,"text":text,"marks":request["marks"]})); |
| 86 | } |
| 87 | let is_code = { |
| 88 | let txn = doc.transact(); |
| 89 | let block = match projection::find(&root,&txn,&node) { |
| 90 | Some(yrs::XmlOut::Element(element)) => Some(element), |
| 91 | Some(yrs::XmlOut::Text(_)) => structure::parent(&root,&txn,&node).and_then(|(parent,_)| projection::find(&root,&txn,&projection::node_id(&yrs::XmlOut::Fragment(parent))).and_then(yrs::XmlOut::into_xml_element)), |
| 92 | _ => None, |
| 93 | }; |
| 94 | block.is_some_and(|block| block.tag().as_ref() == "code_block") |
| 95 | }; |
| 96 | if is_code { return operations::execute(doc, &json!({"op":"replace_text","node":node,"at":local,"length":length,"text":text.replace('\u{2028}',"\n"),"marks":request["marks"]})); } |
| 97 | let mut parts = text.split(['\n', '\u{2028}']); |
| 98 | let first = parts.next().unwrap_or(""); |
| 99 | operations::execute(doc, &json!({"op":"replace_text","node":node,"at":local,"length":length,"text":first,"marks":request["marks"]}))?; |
| 100 | let mut cursor = at + first.encode_utf16().count() as u32; |
| 101 | for (separator, part) in text.chars().filter(|c| *c == '\n' || *c == '\u{2028}').zip(parts) { |
| 102 | let (node, local) = locate(doc, cursor, 0)?; |
| 103 | let op = if separator == '\n' { "split" } else { "hard_break" }; |
| 104 | structure::execute(doc, &json!({"op":op,"node":node,"at":local}))?; |
| 105 | cursor += 1; |
| 106 | let (node, local) = locate(doc, cursor, 0)?; |
| 107 | operations::execute(doc, &json!({"op":"replace_text","node":node,"at":local,"length":0,"text":part,"marks":request["marks"]}))?; |
| 108 | cursor += part.encode_utf16().count() as u32; |
| 109 | } |
| 110 | Ok(()) |
| 111 | } |
| 112 | |
| 113 | pub fn anchor(doc: &Doc, at: u32) -> Result<Value, &'static str> { |
| 114 | use yrs::{Assoc, IndexedSequence, Text, XmlOut}; |
| 115 | let Ok((id, local)) = locate(doc, at, 0) else { return Ok(json!({"fallback":at})); }; |
| 116 | let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| 117 | let mut txn = doc.transact_mut_with("selection"); |
| 118 | match projection::find(&root, &txn, &id).ok_or("missing_node")? { |
| 119 | XmlOut::Text(text) => { |
| 120 | let assoc = if local == text.len(&txn) { Assoc::Before } else { Assoc::After }; |
| 121 | let sticky = text.sticky_index(&mut txn, local, assoc).unwrap_or_else(|| yrs::StickyIndex::from_type(&txn,&text,Assoc::After)); |
| 122 | Ok(json!({"node":id,"sticky":sticky,"fallback":at})) |
| 123 | }, |
| 124 | _ => Ok(json!({"node":id,"offset":local,"fallback":at})), |
| 125 | } |
| 126 | } |
| 127 | pub fn resolve(doc: &Doc, anchor: &Value) -> Result<u32, &'static str> { |
| 128 | let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| 129 | let txn = doc.transact(); |
| 130 | let mut offset = 0; let mut ranges = Vec::new(); |
| 131 | for child in projection::children(&root, &txn) { spans(&child, &mut offset, &mut ranges); } |
| 132 | let local = if anchor["sticky"].is_null() { anchor["offset"].as_u64().unwrap_or(0) as u32 } else { |
| 133 | let sticky: yrs::StickyIndex = serde_json::from_value(anchor["sticky"].clone()).map_err(|_| "invalid_anchor")?; |
| 134 | sticky.get_offset(&txn).map(|value| value.index).unwrap_or(0) |
| 135 | }; |
| 136 | if let Some(range) = ranges.iter().find(|r| Some(r.node.as_str()) == anchor["node"].as_str()) { |
| 137 | return Ok(range.start + local.min(range.length)); |
| 138 | } |
| 139 | Ok((anchor["fallback"].as_u64().unwrap_or(0) as u32).min(offset.saturating_sub(1))) |
| 140 | } |
| 141 | |