diff --git a/rust/DESIGN.md b/rust/DESIGN.md index a0d84103a2d..10ae85e5a14 100644 --- a/rust/DESIGN.md +++ b/rust/DESIGN.md @@ -86,8 +86,12 @@ Names may change as packages land; this table is updated with them. - **Nodes.** JS's class hierarchy is the `LexicalNode` trait plus the data each base class adds (`ElementData`, `TextData`). A node is a value behind an `Rc` in its editor state's node map; `getWritable` clones it unless - nothing else shares it (`utils::with_writable`), so an earlier editor state - never sees a change. Code outside a node reads it through `NodeRef`, a key + nothing else shares it (`utils::get_writable`), so an earlier editor state + never sees a change. The `Rc`'s uniqueness stands in for JS's + `_cloneNotNeeded`. `getWritable` also marks the node dirty, as in JS, and + helpers that take "writable" nodes expect their callers to have called it + in JS's order, because update listeners see nodes in the order they were + marked. Code outside a node reads it through `NodeRef`, a key that resolves the latest version on every call, as JS node methods call `getLatest()`. Base class behavior a subclass overrides with a `super` call is a free function in the base class's module. @@ -111,7 +115,20 @@ Names may change as packages land; this table is updated with them. from Swift (PARITY.md). - **Errors.** A JS `invariant` a document or a caller can break returns a `LexicalError` with the same message; one only a bug in a node - implementation can break panics, as `RefCell` does. + implementation can break panics, as `RefCell` does. A failed update + returns its error from `Editor::update` (JS reports it to `onError`) after + discarding the update and notifying listeners, as JS does. +- **The update cycle** (`updates.rs`) follows `$beginUpdate` and + `$commitPendingUpdates` line by line: nested and queued updates, + normalization, transforms (leaves first, the root last), GC, the + selection check, discarding a failed update, and the text content and + update listeners. The dirty sets are insertion-ordered like JS's `Map` and + `Set` (`ordered.rs`), including iteration that sees entries added + meanwhile. Without a DOM nothing reconciles, so mutation listeners and + selection-change notifications never fire, as in JS's headless editor. + JS commits a non-discrete update in a microtask; with no event loop, Rust + commits it at the next `read` or discrete update, which JS also does when + one comes first. Update closures that may be queued are `'static`. ## Conformance @@ -153,7 +170,7 @@ Each milestone ends with green CI and a report to the Space. | Milestone | Scope | Vectors that pass when done | Status | | --- | --- | --- | --- | | M0 | The `rust/` workspace, JSON as JS writes it, the core nodes (root, paragraph, text, tab, line break), `parseEditorState` and `toJSON` in both forms, the conformance runner, CI | `meta`, `serialization/core`, the core-only cases of `property/documents` (JSON and text) | done | -| M1 | The core model: `editor.update` and the commit (dirty nodes, normalization, transforms, listeners, commands, tags), node creation and methods (`splitText`, `mergeWithSibling`, `spliceText`, `insertAfter`, `remove`, `replace`...) with UTF-16 offsets, `RangeSelection` and `NodeSelection` editing, carets, NodeState, slots, the schema facility, `DecoratorNode`, the extension API (`buildEditorFromExtensions`), the parity ledger | `scripts/*`, `fuzz/core`, `file/*` | | +| M1 | The core model: `editor.update` and the commit (dirty nodes, normalization, transforms, listeners, commands, tags), node creation and methods (`splitText`, `mergeWithSibling`, `spliceText`, `insertAfter`, `remove`, `replace`...) with UTF-16 offsets, `RangeSelection` and `NodeSelection` editing, carets, NodeState, slots, the schema facility, `DecoratorNode`, the extension API (`buildEditorFromExtensions`), the parity ledger | `scripts/*`, `fuzz/core`, `file/*` | in progress: the update cycle, `setEditorState`, commands, transforms, listeners and the node methods are done (`scripts/updates`, `commands` pass; `listeners`, `transforms` pass but for the cases that type); next, `RangeSelection` editing and carets | | M2 | Node packages: `rich-text`, `list`, `link`, `code`, `overflow`, `hashtag`, with `selection` and `utils` | `serialization/*`, `property/documents` (JSON and text), `entities/hashtag-*` | | | M3 | `history`, and the commands `property/sequences` dispatches | `property/sequences` | | | M4 | `html`: a DOM abstraction over an HTML parser, CSS declarations, export and import | `html/*`, `css/*`, `property/documents` (HTML) | | diff --git a/rust/PARITY.md b/rust/PARITY.md index a91c83e2a2c..286c618b77b 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -14,11 +14,16 @@ updated with each milestone. | Export form | `LexicalSerializedExport.ts` | `serialized_export` | `serialization/core` | | Generated JSON of the core nodes | `LexicalGeneratedJSON.ts` | `generated_json` | `serialization/core`, `property/documents` | | Core nodes | `nodes/LexicalRootNode.ts`, `LexicalParagraphNode.ts`, `LexicalTextNode.ts`, `LexicalTabNode.ts`, `LexicalLineBreakNode.ts`, `LexicalElementNode.ts` (fields, `getTextContent`, `shouldSerializeTextStyles`) | `nodes::*` | `serialization/core`, `property/documents` | -| Node references and `getWritable` | `LexicalNode.ts` (`getLatest`, `getParent`, siblings, children) | `node`, `utils` | through the nodes | -| Appending children | `ElementNode.splice` (insertion only), `RootNode.splice`, `caret/LexicalCaretTree.ts` | `caret_tree` | through parsing | -| Editor state | `LexicalEditorState.ts` (`toJSON`, `read`, `isEmpty`) | `editor_state` | `serialization/core` | -| Parsing | `parseEditorState`, `$parseSerializedNodeImpl` (`LexicalUpdates.ts`) | `updates` | `serialization/core`, `property/documents` | -| Editor | `createEditor` (node registration), `getEditorState`, `read` | `editor` | | +| Node methods | `LexicalNode.ts` (`getLatest`, `getWritable`, `isAttached`, `getIndexWithinParent`, `remove`/`$removeNode`, `replace`, `insertBefore`, `insertAfter`, `select*`, `markDirty`) | `node`, `utils` | `scripts/updates`, `scripts/transforms` | +| Element methods | `LexicalElementNode.ts` (`splice` with selection repair, `append`, `clear`, `select`, children and descendants, the setters), `RootNode.splice` and its other overrides | `nodes::element`, `node` | `scripts/updates` | +| Text methods | `LexicalTextNode.ts` (`splitText`, `mergeWithSibling`, `spliceText`, `setTextContent`, `select`, the format, detail, mode and style setters and toggles), `toggleTextFormatType`, `TabNode`'s overrides | `nodes::text` | `scripts/updates`, `scripts/listeners`, `scripts/transforms` | +| Tree surgery | `caret/LexicalCaretTree.ts` (`$insertNodeBetween`, `$detachNode`, `$detachSiblingRange`, `$detachNodeWithSelection`, `$insertSibling`, `$selectAdjacentNode`) | `caret_tree` | `scripts/updates` | +| Selection model | `LexicalSelection.ts`: points, `RangeSelection` and `NodeSelection` fields, `$getSelection`, `$setSelection`, `$createRangeSelection`, and the helpers node methods use to keep a selection valid | `selection` | `scripts/updates` | +| Update cycle | `LexicalUpdates.ts` (`$beginUpdate`, nested and queued updates, `$applyAllTransforms`, `$commitPendingUpdates`, `$discardPendingUpdate`, listeners, `triggerCommandListeners`, `updateEditorSync`), `LexicalNormalization.ts` (`$normalizeTextNode`), `LexicalGC.ts` (nodes) | `updates`, `normalization`, `gc` | `scripts/updates`, `scripts/listeners`, `scripts/transforms`, `scripts/commands` | +| Editor state | `LexicalEditorState.ts` (`toJSON`, `read`, `isEmpty`, `_parsed`) | `editor_state` | `serialization/core` | +| Parsing | `parseEditorState`, `$parseSerializedNode` (`LexicalUpdates.ts`) | `updates` | `serialization/core`, `property/documents` | +| Editor | `createEditor` (node registration), `update`, `read`, `getEditorState`, `setEditorState`, `registerNodeTransform`, `registerUpdateListener`, `registerTextContentListener`, `registerCommand`, `dispatchCommand`, `createCommand` | `editor` | `scripts/*` | +| UTF-16 offsets | `String#length`, `String#slice` | `utf16` | through the text methods; unit tests against Node | | Upstream version | `meta.json` | `UPSTREAM_VERSION` | `meta` | ## Not ported yet @@ -28,10 +33,16 @@ touches: - **NodeState** (`"$"`) and **slots** (`"$slots"`): a document that carries either fails to parse with an error naming it, rather than losing them. -- `editor.update`, the commit, normalization (JS's `setEditorState` merges - adjacent simple text nodes; the runner compares text content, which that - doesn't change), selection, transforms, commands and listeners. -- `RootNode.__cachedText`, which only the DOM reconciler fills. +- `RangeSelection` editing (`insertText`, `insertParagraph`, + `deleteCharacter`, `formatText`, ...), `$selectAll`, `$insertNodes`, and + ordering points on different nodes (`isBefore`, which needs carets): a + case that reaches these fails with an error naming the caret port rather + than guessing. `scripts/selection` and `scripts/caret` cases, and the one + case each of `scripts/listeners` and `scripts/transforms` that types + text, are counted as waiting on them. +- Composition, the DOM reconciler, mutation listeners, selection-change + notifications and `$onUpdate` callbacks (none of which a headless editor + runs, except `$onUpdate`), and `RootNode.__cachedText`. ## Documented exceptions diff --git a/rust/conformance/src/suites/mod.rs b/rust/conformance/src/suites/mod.rs index 36a3b070b02..87d531d3d0b 100644 --- a/rust/conformance/src/suites/mod.rs +++ b/rust/conformance/src/suites/mod.rs @@ -3,6 +3,7 @@ mod meta; mod property; +mod scripts; mod serialization; use crate::Suite; @@ -19,6 +20,36 @@ pub fn all() -> Vec { variant: None, check: serialization::check, }, + Suite { + file: "scripts/updates", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/transforms", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/listeners", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/commands", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/selection", + variant: None, + check: scripts::check, + }, + Suite { + file: "scripts/caret", + variant: None, + check: scripts::check, + }, Suite { file: "property/documents", variant: Some("JSON and text"), diff --git a/rust/conformance/src/suites/scripts.rs b/rust/conformance/src/suites/scripts.rs new file mode 100644 index 00000000000..07d81a3af90 --- /dev/null +++ b/rust/conformance/src/suites/scripts.rs @@ -0,0 +1,564 @@ +//! `scripts/*.json`: editor sessions in the script language of +//! `packages/lexical-conformance/src/ops.ts`, interpreted here as `ops.ts` +//! interprets them for JS. Each step is one discrete update or one command +//! dispatch; after each, the editor state, the selection, the text and a +//! log of what listeners, transforms and command handlers saw must match +//! what JS recorded. +//! +//! A script that uses an action the port can't run yet (selection editing, +//! carets) is counted as waiting on that part of `lexical`, never as +//! passing. + +use crate::case::{Case, Outcome, same}; +use lexical::json::Value; +use lexical::{ + COMMAND_PRIORITY_EDITOR, CreateEditorArgs, Direction, Editor, LexicalCommand, LexicalError, + NodeRef, PointType, UpdateOptions, Which, create_command, create_editor, + create_range_selection, get_node_by_key, get_root, get_selection, parse_serialized_node, + reset_random_key, set_selection, utf16, +}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +/// What `ops.ts`'s `Context` holds besides the editor. +#[derive(Default)] +struct Ctx { + log: RefCell>, + commands: RefCell>>, +} + +impl Ctx { + fn log(&self, entry: Vec) { + self.log.borrow_mut().push(Value::Array(entry)); + } + + /// `command(ctx, name)`: one command per name. + fn command(&self, name: &str) -> LexicalCommand { + self.commands + .borrow_mut() + .entry(name.to_owned()) + .or_insert_with(|| create_command(name)) + .clone() + } +} + +/// The part of `lexical` an action needs that isn't ported yet, if any. +fn unported_action(action: &Value) -> Option<&'static str> { + match action.get("do").and_then(Value::as_str) { + Some("append" | "select" | "clearSelection" | "node" | "dispatch") => None, + Some("selection" | "selectAll" | "selectNodes" | "insertNodes") => { + Some("lexical (selection editing)") + } + Some("logCaretRange" | "removeTextFromCaretRange" | "logNormalizeCaret") => { + Some("lexical (carets)") + } + _ => Some("unknown"), + } +} + +fn unported(script: &Value) -> Option<&'static str> { + let actions = |v: Option<&Value>| -> Vec { + v.and_then(Value::as_array).cloned().unwrap_or_default() + }; + let steps = actions(script.get("steps")); + let handlers = actions(script.get("handlers")); + steps + .iter() + .flat_map(|s| actions(s.get("update"))) + .chain(handlers.iter().flat_map(|h| actions(h.get("actions")))) + .find_map(|a| unported_action(&a)) +} + +fn err(message: impl Into) -> LexicalError { + LexicalError::new(message) +} + +/// `$node(key)`. +fn node(key: &str) -> Result { + get_node_by_key(key).ok_or_else(|| err(format!("no node {key}"))) +} + +fn text(node: NodeRef) -> Result { + if node.is_text() { + Ok(node) + } else { + Err(err(format!("{} is not a text node", node.get_key()))) + } +} + +fn element(node: NodeRef) -> Result { + if node.is_element() { + Ok(node) + } else { + Err(err(format!("{} is not an element", node.get_key()))) + } +} + +/// `$parse(nodes)`. +fn parse(nodes: &Value) -> Result, LexicalError> { + nodes + .as_array() + .ok_or_else(|| err("expected an array of nodes"))? + .iter() + .map(parse_serialized_node) + .collect() +} + +/// `arg(args, i)`: the argument, or `null`. +fn arg(args: Option<&Value>, i: usize) -> Value { + args.and_then(Value::as_array) + .and_then(|a| a.get(i)) + .cloned() + .unwrap_or(Value::Null) +} + +fn number(v: &Value) -> Result { + v.as_f64() + .ok_or_else(|| err(format!("expected a number, got {v}"))) +} + +fn string(v: &Value) -> Result { + v.as_str() + .map(str::to_owned) + .ok_or_else(|| err(format!("expected a string, got {v}"))) +} + +/// An optional offset (`undefined` for `null`). +fn offset(v: &Value) -> Result, LexicalError> { + if v.is_null() { + Ok(None) + } else { + Ok(Some(number(v)? as usize)) + } +} + +fn flag(v: &Value, default: bool) -> bool { + v.as_bool().unwrap_or(default) +} + +fn point_type(v: &Value) -> Result { + match v.as_str() { + Some("text") => Ok(PointType::Text), + Some("element") => Ok(PointType::Element), + _ => Err(err(format!("bad point type {v}"))), + } +} + +/// `NODE_METHODS[method](node, args, ctx)`. +fn run_node_method( + n: NodeRef, + method: &str, + args: Option<&Value>, + ctx: &Ctx, +) -> Result<(), LexicalError> { + let a = |i| arg(args, i); + match method { + "append" => element(n)?.append(&parse(&a(0))?)?, + "clear" => element(n)?.clear()?, + "insertAfter" => { + n.insert_after(&parse_serialized_node(&a(0))?, flag(&a(1), true))?; + } + "insertBefore" => { + n.insert_before(&parse_serialized_node(&a(0))?, flag(&a(1), true))?; + } + "markDirty" => n.mark_dirty(), + "mergeWithSibling" => { + let target = text(node(&string(&a(0))?)?)?; + text(n)?.merge_with_sibling(&target)?; + } + "remove" => n.remove(flag(&a(0), false))?, + "replace" => { + n.replace(&parse_serialized_node(&a(0))?, flag(&a(1), false))?; + } + "select" => { + n.select(offset(&a(0))?, offset(&a(1))?)?; + } + "selectEnd" => { + n.select_end()?; + } + "selectNext" => { + n.select_next(offset(&a(0))?, offset(&a(1))?)?; + } + "selectPrevious" => { + n.select_previous(offset(&a(0))?, offset(&a(1))?)?; + } + "selectStart" => { + n.select_start()?; + } + "setDetail" => { + let n = text(n)?; + match a(0) { + Value::String(name) => n.set_detail_type(&name)?, + v => n.set_detail(number(&v)?)?, + } + } + "setDirection" => element(n)?.set_direction(match a(0).as_str() { + Some("ltr") => Some(Direction::Ltr), + Some("rtl") => Some(Direction::Rtl), + _ => None, + }), + "setFormat" if n.is_text() => match a(0) { + Value::String(name) => n.set_format_type(&name), + v => n.set_format(number(&v)?), + }, + "setFormat" => element(n)?.set_element_format(a(0).as_str().unwrap_or("")), + "setIndent" => element(n)?.set_indent(number(&a(0))?), + "setMode" => text(n)?.set_mode(&string(&a(0))?)?, + "setStyle" if n.is_text() => n.set_style(&string(&a(0))?), + "setStyle" => element(n)?.set_element_style(a(0).as_str().unwrap_or("")), + "setTextContent" => text(n)?.set_text_content(&string(&a(0))?), + "setTextFormat" => element(n)?.set_text_format(number(&a(0))?), + "setTextStyle" => element(n)?.set_text_style(&string(&a(0))?), + "splice" => element(n)?.splice( + number(&a(0))? as usize, + number(&a(1))? as usize, + &parse(&a(2))?, + )?, + "spliceText" => { + text(n)?.splice_text( + number(&a(0))? as i64, + number(&a(1))? as i64, + &string(&a(2))?, + flag(&a(3), false), + )?; + } + "splitText" => { + let offsets: Vec = args + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| number(v).map(|n| n as i64)).collect()) + .transpose()? + .unwrap_or_default(); + let parts = text(n)?.split_text(&offsets)?; + ctx.log(vec![ + "splitText".into(), + Value::Array(parts.iter().map(|p| p.get_key().into()).collect()), + ]); + } + "toggleDirectionless" => text(n)?.toggle_directionless(), + "toggleFormat" => text(n)?.toggle_format(&string(&a(0))?), + "toggleUnmergeable" => text(n)?.toggle_unmergeable(), + _ => return Err(err(format!("unknown node method {method}"))), + } + Ok(()) +} + +/// `$runAction(action, ctx)`. +fn run_action(action: &Value, ctx: &Ctx, editor: &Editor) -> Result<(), LexicalError> { + match action.get("do").and_then(Value::as_str) { + Some("append") => { + let key = action.get("key").and_then(Value::as_str).unwrap_or(""); + element(node(key)?)?.append(&parse(action.get("nodes").unwrap_or(&Value::Null))?) + } + Some("select") => { + let selection = create_range_selection(); + let anchor = action.get("anchor").cloned().unwrap_or(Value::Null); + let focus = action + .get("focus") + .cloned() + .unwrap_or_else(|| anchor.clone()); + for (which, spec) in [(Which::Anchor, &anchor), (Which::Focus, &focus)] { + selection.point_set( + which, + &string(&arg(Some(spec), 0))?, + number(&arg(Some(spec), 1))? as usize, + point_type(&arg(Some(spec), 2))?, + )?; + } + set_selection(Some(selection)); + Ok(()) + } + Some("clearSelection") => { + set_selection(None); + Ok(()) + } + Some("node") => { + let key = action.get("key").and_then(Value::as_str).unwrap_or(""); + let method = action.get("method").and_then(Value::as_str).unwrap_or(""); + run_node_method(node(key)?, method, action.get("args"), ctx) + } + Some("dispatch") => { + let name = action.get("command").and_then(Value::as_str).unwrap_or(""); + let payload = action.get("payload").cloned().unwrap_or(Value::Null); + let handled = editor.dispatch_command(&ctx.command(name), payload)?; + ctx.log(vec!["dispatched".into(), name.into(), handled.into()]); + Ok(()) + } + other => Err(err(format!("unsupported action {other:?}"))), + } +} + +/// `$selectionSnapshot()`. +fn selection_snapshot() -> Value { + let Some(selection) = get_selection() else { + return Value::Null; + }; + let point = |which| { + let p = selection.point(which).expect("a range selection"); + Value::Array(vec![ + p.key.as_str().into(), + (p.offset as f64).into(), + p.point_type.as_str().into(), + ]) + }; + match selection.range() { + Some(range) => Value::Array(vec![ + "range".into(), + point(Which::Anchor), + point(Which::Focus), + range.format.into(), + range.style.as_str().into(), + ]), + None => { + let keys = selection.node_keys(); + Value::Array(vec![ + "node".into(), + Value::Array(keys.into_iter().map(Value::from).collect()), + ]) + } + } +} + +/// `TRANSFORMS[name](ctx)`. +fn register_transform(editor: &Editor, name: &'static str, ctx: &Rc) -> Result<(), String> { + let ctx = ctx.clone(); + let log = move |node: &NodeRef| { + ctx.log(vec!["transform".into(), name.into(), node.get_key().into()]); + }; + // Registered for the whole session; dropping the unregister function + // doesn't call it. + let _unregister = match name { + "paragraph:log" => editor.register_node_transform("paragraph", move |node| { + log(node); + Ok(()) + }), + "paragraph:remove-empty" => editor.register_node_transform("paragraph", move |node| { + log(node); + if node.is_empty() && node.get_next_sibling().is_some() { + node.remove(false)?; + } + Ok(()) + }), + "root:log" => editor.register_node_transform("root", move |node| { + log(node); + Ok(()) + }), + "text:bold-hash" => editor.register_node_transform("text", move |node| { + log(node); + if node.is_simple_text() + && node.get_text_content().starts_with('#') + && !node.has_format("bold") + { + node.toggle_format("bold"); + } + Ok(()) + }), + "text:split-hash" => editor.register_node_transform("text", move |node| { + log(node); + let content = node.get_text_content(); + // `indexOf`, in UTF-16 code units. + let index = content.find('#').map(|i| utf16::len(&content[..i])); + if node.is_simple_text() + && let Some(index) = index.filter(|&i| i > 0) + { + let parts = node.split_text(&[index as i64])?; + parts[1].toggle_unmergeable(); + } + Ok(()) + }), + "text:upper-a" => editor.register_node_transform("text", move |node| { + log(node); + let content = node.get_text_content(); + if content.contains('a') { + node.set_text_content(&content.replace('a', "A")); + } + Ok(()) + }), + _ => return Err(format!("unknown transform {name}")), + }; + Ok(()) +} + +/// The transform names `ops.ts` defines, as `'static` strings. +fn transform_name(name: &str) -> Option<&'static str> { + [ + "paragraph:log", + "paragraph:remove-empty", + "root:log", + "text:bold-hash", + "text:split-hash", + "text:upper-a", + ] + .into_iter() + .find(|n| *n == name) +} + +/// `startSession(script)`. +fn start_session(script: &Value) -> Result<(Editor, Rc), String> { + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + let ctx = Rc::new(Ctx::default()); + for name in script + .get("transforms") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let name = name.as_str().unwrap_or(""); + let name = transform_name(name).ok_or_else(|| format!("unknown transform {name}"))?; + register_transform(&editor, name, &ctx)?; + } + for handler in script + .get("handlers") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = handler.str("id")?.to_owned(); + let command = ctx.command(handler.str("command")?); + let priority = handler + .get("priority") + .and_then(Value::as_f64) + .map_or(COMMAND_PRIORITY_EDITOR, |p| p as u8); + let result = handler + .get("result") + .and_then(Value::as_bool) + .unwrap_or(false); + let actions = handler + .get("actions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let ctx = ctx.clone(); + let _unregister = editor.register_command( + &command, + move |payload: &Value, editor: &Editor| { + ctx.log(vec!["handler".into(), id.as_str().into(), payload.clone()]); + for action in &actions { + run_action(action, &ctx, editor)?; + } + Ok(result) + }, + priority, + ); + } + if script.get("listen").and_then(Value::as_bool) == Some(true) { + let log = ctx.clone(); + let _unregister = editor.register_update_listener(move |payload| { + let elements = payload + .dirty_elements + .iter() + .map(|(k, v)| Value::Array(vec![k.as_str().into(), (*v).into()])) + .collect(); + let leaves = payload + .dirty_leaves + .iter() + .map(|k| k.as_str().into()) + .collect(); + let mut tags = payload.tags.clone(); + tags.sort(); + log.log(vec![ + "update".into(), + Value::Array(elements), + Value::Array(leaves), + Value::Array(tags.into_iter().map(Value::from).collect()), + ]); + }); + // Mutation listeners only hear from the DOM reconciler, which a + // headless editor doesn't run, so JS logs no `mutation` entries. + let log = ctx.clone(); + let _unregister = editor.register_text_content_listener(move |text| { + log.log(vec!["textContent".into(), text.into()]); + }); + } + Ok((editor, ctx)) +} + +/// `session.run(step, snapshot)`, compared with what JS recorded. +fn run_step( + editor: &Editor, + ctx: &Rc, + step: &Value, + expected: &Value, + i: usize, +) -> Result<(), String> { + ctx.log.borrow_mut().clear(); + let outcome = if let Some(actions) = step.get("update").and_then(Value::as_array) { + let tags = step + .get("tag") + .and_then(Value::as_array) + .map(|t| { + t.iter() + .filter_map(|t| t.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + let (actions, update_ctx, update_editor) = (actions.clone(), ctx.clone(), editor.clone()); + editor.update( + move || { + for action in &actions { + run_action(action, &update_ctx, &update_editor)?; + } + Ok(()) + }, + UpdateOptions { + discrete: true, + tags, + ..UpdateOptions::default() + }, + ) + } else { + let name = step.str("dispatch")?; + let payload = step.get("payload").cloned().unwrap_or(Value::Null); + editor + .dispatch_command(&ctx.command(name), payload) + .map(|handled| ctx.log(vec!["dispatched".into(), name.into(), handled.into()])) + }; + // A dispatch outside an update commits at the next read. + let (selection, text) = editor + .read(|| (selection_snapshot(), get_root().get_text_content())) + .map_err(|e| format!("step {i}: committing: {e}"))?; + let log = Value::Array(ctx.log.borrow().clone()); + + let at = |what: &str| format!("step {i} {what}"); + same( + at("error"), + outcome.as_ref().err().map(|e| e.to_string()).is_some(), + expected.get("error").is_some(), + ) + .map_err(|e| match &outcome { + Err(error) => format!("{e} ({error})"), + Ok(()) => e, + })?; + same(at("log"), &log, expected.field("log")?)?; + same(at("selection"), &selection, expected.field("selection")?)?; + same(at("text"), text.as_str(), expected.str("text")?)?; + if let Some(state) = expected.get("state").and_then(Value::as_str) { + let current = editor.get_editor_state(); + same( + at("state"), + current.to_json(false).to_string().as_str(), + state, + )?; + same( + at("compact"), + current.to_json(true).to_string().as_str(), + expected.str("compact")?, + )?; + } + Ok(()) +} + +pub fn check(case: &Value, _file: &Value) -> Result { + if let Some(part) = unported(case) { + return Ok(Outcome::NotPorted(part)); + } + let (editor, ctx) = start_session(case)?; + let steps = case.arr("steps")?; + let results = case.arr("results")?; + same("steps recorded", results.len(), steps.len())?; + for (i, (step, expected)) in steps.iter().zip(results).enumerate() { + run_step(&editor, &ctx, step, expected, i)?; + } + Ok(Outcome::Pass) +} diff --git a/rust/conformance/tests/conformance.rs b/rust/conformance/tests/conformance.rs index 2decb0f006d..6223b43c650 100644 --- a/rust/conformance/tests/conformance.rs +++ b/rust/conformance/tests/conformance.rs @@ -79,3 +79,42 @@ fn doctored_vectors_fail() { report.markdown() ); } + +/// The script suites compare what listeners saw after each step: in a copy +/// of `scripts/listeners.json` where one dirty element's flag and the tags +/// of the failed update's notification differ from what JS recorded, those +/// two cases fail, and only those. +#[test] +fn doctored_scripts_fail() { + let Some(vectors) = vectors_or_skip() else { + return; + }; + let text = std::fs::read_to_string(vectors.join("scripts/listeners.json")).unwrap(); + let flag = ("[\"1\",false]", "[\"1\",true]"); + let tags = ("[\"update\",[],[],[]]", "[\"update\",[],[],[\"tag\"]]"); + for (from, _) in [flag, tags] { + assert!(text.contains(from), "no {from} to doctor"); + } + let doctored = text.replacen(flag.0, flag.1, 1).replacen(tags.0, tags.1, 1); + + let dir = std::env::temp_dir().join(format!( + "lexical-conformance-doctored-scripts-{}", + std::process::id() + )); + std::fs::create_dir_all(dir.join("scripts")).unwrap(); + std::fs::write(dir.join("scripts/listeners.json"), doctored).unwrap(); + let report = lexical_conformance::run_in(&dir, Some("scripts/listeners")); + std::fs::remove_dir_all(&dir).unwrap(); + let report = report.unwrap(); + let f = report + .files + .iter() + .find(|f| f.file == "scripts/listeners") + .unwrap(); + assert_eq!( + (f.failed, f.failures.len()), + (2, 2), + "{}", + report.markdown() + ); +} diff --git a/rust/lexical-editor/src/caret_tree.rs b/rust/lexical-editor/src/caret_tree.rs index 6abbcca96a2..b27a5b13fe2 100644 --- a/rust/lexical-editor/src/caret_tree.rs +++ b/rust/lexical-editor/src/caret_tree.rs @@ -1,85 +1,288 @@ -//! `caret/LexicalCaretTree.ts`: linking children into their parent. +//! `caret/LexicalCaretTree.ts`: linking nodes into and out of their parent, +//! with the selection repairs that go with it. +//! +//! As in JS, the helpers that take "writable" nodes expect the caller to +//! have called `getWritable` on them already ([`get_writable`]), in the +//! order JS does: that order is the order update listeners see the nodes +//! marked dirty in. use crate::LexicalError; use crate::error::invariant; use crate::node::{NodeKey, NodeRef}; -use crate::utils::with_writable; +use crate::selection::{ + PointType, SelectionRef, Which, get_selection, selection_touches_element, + update_element_selection_on_create_delete_node, +}; +use crate::updates::error_on_read_only; +use crate::utils::{get_writable, mutate}; -/// `$linkSiblings`. -fn link_siblings(parent: &str, previous: Option, next: Option) { - match &previous { - None => with_writable(parent, |p| element_of(p).first = next.clone()), - Some(prev) => with_writable(prev, |p| p.base_mut().next = next.clone()), - } - match &next { - None => with_writable(parent, |p| element_of(p).last = previous.clone()), - Some(n) => with_writable(n, |n| n.base_mut().prev = previous.clone()), - } +fn set_first(parent: &str, key: Option) { + mutate(parent, |p| { + p.element_mut().expect("a parent is an element").first = key + }); } -fn element_of(node: &mut dyn crate::LexicalNode) -> &mut crate::ElementData { - node.element_mut().expect("a parent is an element") +fn set_last(parent: &str, key: Option) { + mutate(parent, |p| { + p.element_mut().expect("a parent is an element").last = key + }); +} + +pub(crate) fn add_size(parent: &str, delta: isize) { + mutate(parent, |p| { + let element = p.element_mut().expect("a parent is an element"); + element.size = element.size.checked_add_signed(delta).expect("size >= 0"); + }); } -/// `$insertNodeBetween`. -fn insert_node_between(parent: &str, node: &str, previous: Option, next: Option) { +/// `$linkSiblings(writableParent, writablePrevious, writableNext)`. +fn link_siblings(parent: &str, previous: Option<&str>, next: Option<&str>) { + match previous { + None => set_first(parent, next.map(str::to_owned)), + Some(p) => mutate(p, |p| p.base_mut().next = next.map(str::to_owned)), + } + match next { + None => set_last(parent, previous.map(str::to_owned)), + Some(n) => mutate(n, |n| n.base_mut().prev = previous.map(str::to_owned)), + } +} + +/// `$insertNodeBetween(writableParent, writableNode, writablePrevious, +/// writableNext)`. +pub(crate) fn insert_node_between( + parent: &str, + node: &str, + previous: Option<&str>, + next: Option<&str>, +) { let key = Some(node.to_owned()); - match &previous { - None => with_writable(parent, |p| element_of(p).first = key.clone()), - Some(prev) => with_writable(prev, |p| p.base_mut().next = key.clone()), + match previous { + None => set_first(parent, key.clone()), + Some(p) => mutate(p, |p| p.base_mut().next = key.clone()), } - match &next { - None => with_writable(parent, |p| element_of(p).last = key.clone()), - Some(n) => with_writable(n, |n| n.base_mut().prev = key.clone()), + match next { + None => set_last(parent, key), + Some(n) => mutate(n, |n| n.base_mut().prev = Some(node.to_owned())), } - with_writable(node, |n| { + mutate(node, |n| { let base = n.base_mut(); - base.prev = previous; - base.next = next; + base.prev = previous.map(str::to_owned); + base.next = next.map(str::to_owned); base.parent = Some(parent.to_owned()); }); } -/// `$detachNode`. -fn detach_node(node: &NodeRef) { - let latest = node.get_latest(); - let Some(parent) = latest.base().parent.clone() else { - return; - }; - link_siblings( - &parent, - latest.base().prev.clone(), - latest.base().next.clone(), - ); - with_writable(node.get_key(), |n| { +fn clear_links(node: &str) { + mutate(node, |n| { let base = n.base_mut(); base.prev = None; base.next = None; base.parent = None; }); - with_writable(&parent, |p| element_of(p).size -= 1); } -/// `parent.append(...nodes)`: `ElementNode.splice(size, 0, nodes)`, with -/// `RootNode.splice`'s check. A node that is already a child moves to the -/// end. The deleting half of `splice`, which also repairs the selection, -/// comes with the update API. -pub(crate) fn append(parent: &NodeRef, nodes: &[NodeRef]) -> Result<(), LexicalError> { - if parent.is_root() { - for node in nodes { - // DecoratorNode is allowed too, once it is ported. - invariant!( - node.is_element(), - "rootNode.splice: Only element or decorator nodes can be inserted to the root node" - ); - } +/// `$detachNode(writableNode)`: unlinks a writable node from its parent. +pub(crate) fn detach_node(node: &NodeRef) { + let Some(parent) = node.get_parent() else { + return; + }; + get_writable(parent.get_key()); + let previous = node.get_previous_sibling(); + let next = node.get_next_sibling(); + if let Some(p) = &previous { + get_writable(p.get_key()); } - for node in nodes { - invariant!(node != parent, "append: attempting to append self"); - detach_node(node); - let last = parent.get_last_child().map(|n| n.get_key().to_owned()); - insert_node_between(parent.get_key(), node.get_key(), last, None); - with_writable(parent.get_key(), |p| element_of(p).size += 1); + if let Some(n) = &next { + get_writable(n.get_key()); } + link_siblings( + parent.get_key(), + previous.as_ref().map(NodeRef::get_key), + next.as_ref().map(NodeRef::get_key), + ); + clear_links(node.get_key()); + add_size(parent.get_key(), -1); +} + +/// `$detachSiblingRange(writableParent, first, count, before)`: unlinks +/// `count` siblings from `first` on, and returns their keys. +pub(crate) fn detach_sibling_range( + parent: &NodeRef, + first: Option, + count: usize, + before: Option<&NodeRef>, +) -> Result, LexicalError> { + let mut nodes = Vec::with_capacity(count); + let mut node = first; + for _ in 0..count { + let Some(n) = node else { + return Err(LexicalError::new("splice: sibling not found")); + }; + let next = n.get_next_sibling(); + get_writable(n.get_key()); + nodes.push(n); + node = next; + } + if let Some(b) = before { + get_writable(b.get_key()); + } + if let Some(n) = &node { + get_writable(n.get_key()); + } + link_siblings( + parent.get_key(), + before.map(NodeRef::get_key), + node.as_ref().map(NodeRef::get_key), + ); + add_size(parent.get_key(), -(nodes.len() as isize)); + Ok(nodes + .into_iter() + .map(|n| { + clear_links(n.get_key()); + n.get_key().to_owned() + }) + .collect()) +} + +/// `$detachNodeWithSelection(node, selection)`: detaches a writable node, +/// shifting element points on its parent past it. Returns the points that +/// were just after the node, which `insertAfter` puts after it again. +pub(crate) fn detach_node_with_selection( + node: &NodeRef, + selection: Option<&SelectionRef>, +) -> Result>, LexicalError> { + let parent = selection.and_then(|_| node.get_parent()); + // Keep bulk moves linear when no element point observes the index. + let index = match (selection, &parent) { + (Some(s), Some(p)) if selection_touches_element(s, p) => node.get_index_within_parent(), + _ => None, + }; + let mut points = None; + if let (Some(selection), Some(parent), Some(index)) = (selection, &parent, index) { + points = Some( + [Which::Anchor, Which::Focus] + .into_iter() + .filter(|&which| { + let point = selection.point(which).expect("a range selection"); + point.point_type == PointType::Element + && point.key == parent.get_key() + && point.offset == index + 1 + }) + .collect(), + ); + // Offset repair needs direction while both points still share a + // tree. + selection.is_backward()?; + } + detach_node(node); + if let (Some(selection), Some(parent), Some(index)) = (selection, &parent, index) { + update_element_selection_on_create_delete_node(selection, parent, index, -1)?; + } + if let Some(selection) = selection { + // Reinsertion can reverse text-only ranges without changing either + // point. + selection.with_range(|r| r.cached_is_backward = None); + } + Ok(points) +} + +/// `errorOnInsertTextNodeOnRoot(node, insertNode)`. +pub(crate) fn error_on_insert_text_node_on_root( + node: &NodeRef, + insert_node: &NodeRef, +) -> Result<(), LexicalError> { + invariant!( + !node.get_parent().is_some_and(|p| p.is_root()) || insert_node.is_element(), + "Only element or decorator nodes can be inserted in to the root node" + ); Ok(()) } + +/// `$insertSibling(origin, direction, node, restoreSelection)`: the shared +/// body of `insertBefore` and `insertAfter`. +pub(crate) fn insert_sibling( + origin: &NodeRef, + is_next: bool, + node: &NodeRef, + restore_selection: bool, +) -> Result { + error_on_read_only(); + error_on_insert_text_node_on_root(origin, node)?; + get_writable(origin.get_key()); + get_writable(node.get_key()); + origin.get_parent_or_throw()?; + let selection = get_selection().filter(|s| restore_selection && s.is_range()); + let points = detach_node_with_selection(node, selection.as_ref())?; + let parent = origin.get_parent_or_throw()?; + get_writable(parent.get_key()); + // Before insertion this is the new node's index in either direction. + let index = match &selection { + Some(s) + if (is_next && points.as_ref().is_some_and(|p| !p.is_empty())) + || selection_touches_element(s, &parent) => + { + origin + .get_index_within_parent() + .map(|i| i + usize::from(is_next)) + } + _ => None, + }; + let sibling = if is_next { + origin.get_next_sibling() + } else { + origin.get_previous_sibling() + }; + if let Some(s) = &sibling { + get_writable(s.get_key()); + } + let sibling_key = sibling.as_ref().map(NodeRef::get_key); + let (previous, next) = if is_next { + (Some(origin.get_key()), sibling_key) + } else { + (sibling_key, Some(origin.get_key())) + }; + insert_node_between(parent.get_key(), node.get_key(), previous, next); + add_size(parent.get_key(), 1); + if let (Some(selection), Some(index)) = (&selection, index) { + update_element_selection_on_create_delete_node(selection, &parent, index, 1)?; + if is_next && let Some(points) = points { + for which in points { + selection.point_set(which, parent.get_key(), index + 1, PointType::Element)?; + } + } + } + Ok(node.clone()) +} + +/// `$selectAdjacentNode(caret, anchorOffset, focusOffset)`: selects next to +/// `origin`, in the sibling on that side or in the parent. +pub(crate) fn select_adjacent_node( + origin: &NodeRef, + is_next: bool, + anchor_offset: Option, + focus_offset: Option, +) -> Result { + error_on_read_only(); + let sibling = if is_next { + origin.get_next_sibling() + } else { + origin.get_previous_sibling() + }; + let parent = origin.get_parent_or_throw()?; + match sibling { + None if is_next => parent.select(None, None), + None => parent.select(Some(0), Some(0)), + Some(sibling) if sibling.is_element() => { + if is_next { + sibling.select(Some(0), Some(0)) + } else { + sibling.select(None, None) + } + } + Some(sibling) if sibling.is_text() => sibling.select(anchor_offset, focus_offset), + Some(sibling) => { + let index = sibling.get_index_within_parent().unwrap_or(0) + usize::from(!is_next); + parent.select(Some(index), Some(index)) + } + } +} diff --git a/rust/lexical-editor/src/editor.rs b/rust/lexical-editor/src/editor.rs index 0abe87c5820..3f1128654af 100644 --- a/rust/lexical-editor/src/editor.rs +++ b/rust/lexical-editor/src/editor.rs @@ -1,18 +1,26 @@ -//! `LexicalEditor.ts`: creating an editor and its registered nodes. +//! `LexicalEditor.ts`: creating an editor, its registered nodes, and the +//! registration of transforms, listeners and commands. -use crate::editor_state::create_empty_editor_state; +use crate::editor_state::{clone_editor_state, create_empty_editor_state}; +use crate::error::invariant; use crate::json::{self, Object, Value}; -use crate::node::{LexicalNode, NodeRef}; +use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::nodes::{ line_break::LineBreakNode, paragraph::ParagraphNode, root::RootNode, tab::TabNode, text::TextNode, }; -use crate::updates::{parse_editor_state, read_editor_state}; +use crate::ordered::{OrderedMap, OrderedSet}; +use crate::updates::{ + begin_update, commit_pending_updates, parse_editor_state, read_editor_state, + trigger_command_listeners, update_editor_sync, +}; use crate::utils::set_node_key; use crate::{EditorState, LexicalError}; -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::marker::PhantomData; +use std::rc::{Rc, Weak}; /// A node class an editor registers: its type and how it imports JSON (the /// static `importJSON`). @@ -56,20 +64,151 @@ impl NodeClass { } } +/// `HISTORY_MERGE_TAG` (`LexicalUpdateTags.ts`): the update joins the +/// previous history entry. +pub const HISTORY_MERGE_TAG: &str = "history-merge"; + /// `CreateEditorArgs`: the node classes to register besides the core ones. #[derive(Clone, Default)] pub struct CreateEditorArgs { pub nodes: Vec, } +/// `EditorUpdateOptions`. `onUpdate` and `event` come with their users. +#[derive(Clone, Debug, Default)] +pub struct UpdateOptions { + /// Commit at the end of the update instead of at the next read. + pub discrete: bool, + /// Update tags (`tag`). + pub tags: Vec, + pub skip_transforms: bool, +} + +/// What an update listener receives (`UpdateListenerPayload`). The dirty +/// nodes are in the order they were marked; each dirty element says whether +/// it was marked itself (`true`) or only because a descendant was. +#[derive(Clone, Debug)] +pub struct UpdateListenerPayload { + pub dirty_elements: Vec<(NodeKey, bool)>, + pub dirty_leaves: Vec, + pub normalized_nodes: Vec, + pub tags: Vec, + pub editor_state: EditorState, + pub prev_editor_state: EditorState, +} + +/// A node transform (`Transform`). +pub type Transform = Rc Result<(), LexicalError>>; + +/// Calling it removes what was registered, as the function JS's `register*` +/// methods return. Dropping it without calling it keeps the registration. +pub type Unregister = Box; + +/// A command (`createCommand`), compared by identity: two commands made +/// with the same name are different commands. +pub struct LexicalCommand

{ + id: u64, + name: Rc, + _payload: PhantomData, +} + +impl

Clone for LexicalCommand

{ + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + _payload: PhantomData, + } + } +} + +impl

LexicalCommand

{ + pub fn name(&self) -> &str { + &self.name + } + pub(crate) fn id(&self) -> u64 { + self.id + } +} + +thread_local! { + static NEXT_COMMAND_ID: Cell = const { Cell::new(1) }; +} + +/// `createCommand(type)`. +pub fn create_command

(name: &str) -> LexicalCommand

{ + let id = NEXT_COMMAND_ID.with(|c| c.replace(c.get() + 1)); + LexicalCommand { + id, + name: name.into(), + _payload: PhantomData, + } +} + +pub const COMMAND_PRIORITY_EDITOR: u8 = 0; +pub const COMMAND_PRIORITY_LOW: u8 = 1; +pub const COMMAND_PRIORITY_NORMAL: u8 = 2; +pub const COMMAND_PRIORITY_HIGH: u8 = 3; +pub const COMMAND_PRIORITY_CRITICAL: u8 = 4; + +/// A command listener: it returns whether it handled the command, which +/// stops the ones after it. +pub(crate) type CommandListener

= Rc Result>; + +/// `NO_DIRTY_NODES`, `HAS_DIRTY_NODES`, `FULL_RECONCILE`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum DirtyType { + #[default] + None, + HasDirtyNodes, + FullReconcile, +} + +/// The editor's record of what an update changed: `_dirtyType`, +/// `_dirtyElements`, `_dirtyLeaves` and `_normalizedNodes`. +#[derive(Default)] +pub(crate) struct Dirty { + pub(crate) dirty_type: DirtyType, + pub(crate) elements: OrderedMap, + pub(crate) leaves: OrderedSet, + pub(crate) normalized: OrderedSet, +} + +/// An update queued while the editor was updating (`_updates`). +pub(crate) struct QueuedUpdate { + pub(crate) id: u64, + pub(crate) update_fn: Box Result<(), LexicalError>>, + pub(crate) options: UpdateOptions, +} + /// A Lexical editor. A handle; cloning it shares the same editor. Without a -/// DOM it is what JS calls a headless editor. +/// DOM it is what JS calls a headless editor. It is neither `Send` nor +/// `Sync`. #[derive(Clone)] -pub struct Editor(Rc); +pub struct Editor(pub(crate) Rc); + +type Listeners = RefCell)>>; +type UpdateListener = Rc; +type TextContentListener = Rc; +/// A command's listeners by priority, each a `CommandListener

` of the +/// command's payload type. +type CommandListeners = [Vec<(u64, Rc)>; 5]; -struct EditorInner { +pub(crate) struct EditorInner { nodes: HashMap<&'static str, NodeClass>, - editor_state: RefCell, + pub(crate) editor_state: RefCell, + pub(crate) pending: RefCell>, + pub(crate) dirty: RefCell, + pub(crate) tags: RefCell, + pub(crate) updating: Cell, + pub(crate) dispatching_commit_listeners: Cell, + pub(crate) updates: RefCell>, + pub(crate) cascade_count: Cell, + transforms: RefCell>>, + update_listeners: Listeners, + text_content_listeners: Listeners, + commands: RefCell>, + next_id: Cell, } /// `createEditor(config)`: registers the core nodes (root, text, line @@ -90,6 +229,18 @@ pub fn create_editor(args: CreateEditorArgs) -> Editor { Editor(Rc::new(EditorInner { nodes, editor_state: RefCell::new(create_empty_editor_state()), + pending: RefCell::new(None), + dirty: RefCell::new(Dirty::default()), + tags: RefCell::new(OrderedSet::new()), + updating: Cell::new(false), + dispatching_commit_listeners: Cell::new(false), + updates: RefCell::new(VecDeque::new()), + cascade_count: Cell::new(0), + transforms: RefCell::new(HashMap::new()), + update_listeners: RefCell::new(Vec::new()), + text_content_listeners: RefCell::new(Vec::new()), + commands: RefCell::new(HashMap::new()), + next_id: Cell::new(1), })) } @@ -98,11 +249,27 @@ impl Editor { &self.0.nodes } - /// `getEditorState()`. + pub(crate) fn ptr_eq(&self, other: &Editor) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + pub(crate) fn next_id(&self) -> u64 { + self.0.next_id.replace(self.0.next_id.get() + 1) + } + + fn weak(&self) -> Weak { + Rc::downgrade(&self.0) + } + + /// `getEditorState()`: the committed state. pub fn get_editor_state(&self) -> EditorState { self.0.editor_state.borrow().clone() } + pub(crate) fn pending(&self) -> Option { + self.0.pending.borrow().clone() + } + /// `parseEditorState(json)` from the JSON text. pub fn parse_editor_state(&self, json: &str) -> Result { let value = json::parse(json).map_err(|e| LexicalError::new(e.to_string()))?; @@ -114,9 +281,316 @@ impl Editor { parse_editor_state(json, self) } - /// `read(callbackFn)`: runs `f` with the editor's state active, - /// read-only. - pub fn read(&self, f: impl FnOnce() -> R) -> R { - read_editor_state(&self.get_editor_state(), f) + /// `update(updateFn, options)`. Inside another update of this editor (or + /// a listener of its commit) the update is queued and runs after it, as + /// in JS, which is why `update_fn` is `'static`. + /// + /// A non-discrete update is committed by the next [`read`](Self::read) + /// or discrete update, where JS commits it in a microtask. An update + /// that fails is discarded, the editor keeps its previous state, and the + /// error is returned (JS reports it to `onError`). + pub fn update( + &self, + update_fn: impl FnOnce() -> Result<(), LexicalError> + 'static, + options: UpdateOptions, + ) -> Result<(), LexicalError> { + if self.0.updating.get() { + let id = self.next_id(); + self.0.updates.borrow_mut().push_back(QueuedUpdate { + id, + update_fn: Box::new(update_fn), + options, + }); + Ok(()) + } else { + begin_update(self, update_fn, &options) + } + } + + /// `setEditorState(editorState, {tag})`: makes `editor_state` the + /// editor's state, through an update, so a state from + /// [`parse_editor_state`](Self::parse_editor_state) has its text + /// normalized and its transforms run, as in JS. The editor keeps its + /// state and this returns the error when the update fails, such as for + /// a state with nothing but the root (an invariant in JS's DEV build). + pub fn set_editor_state( + &self, + editor_state: &EditorState, + tag: Option<&str>, + ) -> Result<(), LexicalError> { + let is_empty_editor_state = editor_state.is_empty(); + // A writable state, so transforms can run on it. + let writable_editor_state = if editor_state.0.read_only.get() { + let writable = clone_editor_state(editor_state); + *writable.0.selection.borrow_mut() = + editor_state.selection().map(|s| s.clone_selection()); + writable + } else { + editor_state.clone() + }; + let tags: Vec = tag.map(str::to_owned).into_iter().collect(); + if let Some(pending) = self.pending() + && !pending.is_empty() + { + self.add_tags(&tags); + commit_pending_updates(self)?; + } + *self.0.pending.borrow_mut() = Some(writable_editor_state.clone()); + { + let mut dirty = self.0.dirty.borrow_mut(); + dirty.dirty_type = DirtyType::FullReconcile; + dirty.elements.set("root", false); + } + let parsed = editor_state.0.parsed.get(); + let options = UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }; + let editor = self.clone(); + update_editor_sync( + self, + move || { + editor.add_tags(&tags); + invariant!( + !is_empty_editor_state, + "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty." + ); + if parsed { + // Every node is dirty, so normalization and transforms + // run on a state that arrived from outside. + let node_map = writable_editor_state.0.node_map.borrow(); + let mut dirty = editor.0.dirty.borrow_mut(); + for key in writable_editor_state.keys_in_creation_order() { + if node_map[&key].element().is_some() { + dirty.elements.set(&key, true); + } else { + dirty.leaves.add(&key); + } + } + } + Ok(()) + }, + (!self.0.updating.get()).then_some(&options), + ) + } + + /// `read(callbackFn)`: commits any pending update, then runs `f` with + /// the editor's state active, read-only. Fails when committing runs a + /// queued update that fails. + pub fn read(&self, f: impl FnOnce() -> R) -> Result { + if !self.0.dispatching_commit_listeners.get() { + commit_pending_updates(self)?; + } + Ok(read_editor_state(Some(self), &self.get_editor_state(), f)) + } + + /// `registerNodeTransform(klass, listener)`, by the node type. Nodes of + /// that type already in the editor are marked dirty, so the transform + /// runs on them with the next update. + pub fn register_node_transform( + &self, + node_type: &'static str, + transform: impl Fn(&NodeRef) -> Result<(), LexicalError> + 'static, + ) -> Unregister { + let id = self.next_id(); + self.0 + .transforms + .borrow_mut() + .entry(node_type) + .or_default() + .push((id, Rc::new(transform))); + mark_nodes_with_types_as_dirty(self, &[node_type]); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() + && let Some(list) = inner.transforms.borrow_mut().get_mut(node_type) + { + list.retain(|(i, _)| *i != id); + } + }) + } + + pub(crate) fn transforms_of(&self, node_type: &str) -> Vec { + self.0 + .transforms + .borrow() + .get(node_type) + .map(|list| list.iter().map(|(_, t)| t.clone()).collect()) + .unwrap_or_default() + } + + /// `registerUpdateListener(listener)`. + pub fn register_update_listener( + &self, + listener: impl Fn(&UpdateListenerPayload) + 'static, + ) -> Unregister { + let id = self.next_id(); + self.0 + .update_listeners + .borrow_mut() + .push((id, Rc::new(listener))); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() { + inner + .update_listeners + .borrow_mut() + .retain(|(i, _)| *i != id); + } + }) + } + + /// `registerTextContentListener(listener)`. + pub fn register_text_content_listener(&self, listener: impl Fn(&str) + 'static) -> Unregister { + let id = self.next_id(); + self.0 + .text_content_listeners + .borrow_mut() + .push((id, Rc::new(listener))); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() { + inner + .text_content_listeners + .borrow_mut() + .retain(|(i, _)| *i != id); + } + }) + } + + pub(crate) fn update_listeners(&self) -> Vec { + self.0 + .update_listeners + .borrow() + .iter() + .map(|(_, l)| l.clone()) + .collect() + } + + pub(crate) fn text_content_listeners(&self) -> Vec { + self.0 + .text_content_listeners + .borrow() + .iter() + .map(|(_, l)| l.clone()) + .collect() + } + + /// `registerCommand(command, listener, priority)`, `priority` being one + /// of the `COMMAND_PRIORITY_*` constants (0 to 4). + pub fn register_command( + &self, + command: &LexicalCommand

, + listener: impl Fn(&P, &Editor) -> Result + 'static, + priority: u8, + ) -> Unregister { + let priority = usize::from(priority.min(COMMAND_PRIORITY_CRITICAL)); + let id = self.next_id(); + let listener: CommandListener

= Rc::new(listener); + let command_id = command.id(); + self.0.commands.borrow_mut().entry(command_id).or_default()[priority] + .push((id, Rc::new(listener) as Rc)); + let weak = self.weak(); + Box::new(move || { + if let Some(inner) = weak.upgrade() + && let Some(lists) = inner.commands.borrow_mut().get_mut(&command_id) + { + lists[priority].retain(|(i, _)| *i != id); + } + }) + } + + pub(crate) fn command_listeners( + &self, + command: &LexicalCommand

, + priority: usize, + ) -> Vec> { + self.0 + .commands + .borrow() + .get(&command.id()) + .map(|lists| { + lists[priority] + .iter() + .filter_map(|(_, l)| l.downcast_ref::>().cloned()) + .collect() + }) + .unwrap_or_default() + } + + /// `dispatchCommand(command, payload)`: runs the command's listeners, by + /// priority, until one handles it, and returns whether one did. + pub fn dispatch_command( + &self, + command: &LexicalCommand

, + payload: P, + ) -> Result { + trigger_command_listeners(self, command, &payload) + } + + // The dirty records. + + pub(crate) fn dirty_type(&self) -> DirtyType { + self.0.dirty.borrow().dirty_type + } + + /// Marks a node dirty: an element as intentionally dirty, any other node + /// as a dirty leaf. + pub(crate) fn mark_dirty(&self, key: &str, is_element: bool) { + let mut dirty = self.0.dirty.borrow_mut(); + // Don't downgrade a full reconcile. + if dirty.dirty_type == DirtyType::None { + dirty.dirty_type = DirtyType::HasDirtyNodes; + } + if is_element { + dirty.elements.set(key, true); + } else { + dirty.leaves.add(key); + } + } + + pub(crate) fn add_tags(&self, tags: &[String]) { + let mut set = self.0.tags.borrow_mut(); + for tag in tags { + set.add(tag); + } + } +} + +/// `markNodesWithTypesAsDirty(editor, types)`: an update that marks every +/// node of those types in the committed state dirty, in the order JS's +/// node map holds them. +fn mark_nodes_with_types_as_dirty(editor: &Editor, types: &[&str]) { + let editor_state = editor.get_editor_state(); + let keys: Vec = { + let node_map = editor_state.0.node_map.borrow(); + editor_state + .keys_in_creation_order() + .into_iter() + .filter(|key| types.contains(&node_map[key].get_type())) + .collect() + }; + if keys.is_empty() { + return; } + let options = UpdateOptions { + tags: if editor.pending().is_none() { + vec![HISTORY_MERGE_TAG.to_owned()] + } else { + Vec::new() + }, + ..UpdateOptions::default() + }; + // Marking existing nodes dirty can't fail. + let _ = editor.update( + move || { + for key in &keys { + if let Some(node) = crate::get_node_by_key(key) { + node.mark_dirty(); + } + } + Ok(()) + }, + options, + ); } diff --git a/rust/lexical-editor/src/editor_state.rs b/rust/lexical-editor/src/editor_state.rs index 8eb8b9c11aa..59907931248 100644 --- a/rust/lexical-editor/src/editor_state.rs +++ b/rust/lexical-editor/src/editor_state.rs @@ -3,6 +3,7 @@ use crate::json::{Object, Value}; use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::nodes::root::RootNode; +use crate::selection::SelectionRef; use crate::serialized_export::{export_node_json, with_compact_export}; use crate::updates::read_editor_state; use crate::utils::get_root; @@ -12,28 +13,46 @@ use std::rc::Rc; pub(crate) type NodeMap = HashMap>; -/// An editor state: the node map (and, once ported, the selection). A -/// handle; cloning it shares the same state. Once read-only, nothing changes -/// it. +/// An editor state: the node map and the selection. A handle; cloning it +/// shares the same state. Once read-only (committed or parsed), nothing +/// changes it: an update works on a clone whose node map shares the nodes, +/// and a node is copied before its first change (`getWritable`). #[derive(Clone, Debug)] pub struct EditorState(pub(crate) Rc); #[derive(Debug)] pub(crate) struct EditorStateInner { pub(crate) node_map: RefCell, + pub(crate) selection: RefCell>, pub(crate) read_only: Cell, + /// `_flushSync`: commit at the end of the update (`discrete`). + pub(crate) flush_sync: Cell, + /// `_parsed`: made by `parseEditorState`, so no transform has run on it. + pub(crate) parsed: Cell, } -/// `createEmptyEditorState()`. -pub(crate) fn create_empty_editor_state() -> EditorState { - let mut node_map = NodeMap::new(); - node_map.insert("root".to_owned(), Rc::new(RootNode::new())); +fn new_editor_state(node_map: NodeMap) -> EditorState { EditorState(Rc::new(EditorStateInner { node_map: RefCell::new(node_map), + selection: RefCell::new(None), read_only: Cell::new(false), + flush_sync: Cell::new(false), + parsed: Cell::new(false), })) } +/// `createEmptyEditorState()`. +pub(crate) fn create_empty_editor_state() -> EditorState { + let mut node_map = NodeMap::new(); + node_map.insert("root".to_owned(), Rc::new(RootNode::new())); + new_editor_state(node_map) +} + +/// `cloneEditorState(current)`: the same nodes, no selection, writable. +pub(crate) fn clone_editor_state(current: &EditorState) -> EditorState { + new_editor_state(current.0.node_map.borrow().clone()) +} + /// `$exportNodeToJSON(node)`: the node and its children. Slots come with /// their port. fn export_node_to_json(node: &NodeRef) -> Value { @@ -50,15 +69,32 @@ fn export_node_to_json(node: &NodeRef) -> Value { } impl EditorState { - /// `isEmpty()`: whether the state has nothing but its root (and no - /// selection). + /// `isEmpty()`: nothing but the root, and no selection. pub fn is_empty(&self) -> bool { - self.0.node_map.borrow().len() <= 1 + self.0.node_map.borrow().len() <= 1 && self.0.selection.borrow().is_none() + } + + /// The state's selection (`editorState._selection`). + pub fn selection(&self) -> Option { + self.0.selection.borrow().clone() + } + + /// The node keys in the order JS's node map holds them, the order nodes + /// were created in: the root, then by key, as keys come from a counter. + pub(crate) fn keys_in_creation_order(&self) -> Vec { + let mut keys: Vec = self.0.node_map.borrow().keys().cloned().collect(); + keys.sort_by_key(|k| (k != "root", k.parse::().unwrap_or(u64::MAX), k.clone())); + keys + } + + /// Whether `self` and `other` are the same state. + pub fn ptr_eq(&self, other: &EditorState) -> bool { + Rc::ptr_eq(&self.0, &other.0) } /// `read(callbackFn)`: runs `f` with this state active, read-only. pub fn read(&self, f: impl FnOnce() -> R) -> R { - read_editor_state(self, f) + read_editor_state(None, self, f) } /// `toJSON(compact)`: the document's JSON, in the compact form or the @@ -66,7 +102,7 @@ impl EditorState { /// is `JSON.stringify(editorState.toJSON())`. pub fn to_json(&self, compact: bool) -> Value { with_compact_export(compact, || { - read_editor_state(self, || { + read_editor_state(None, self, || { let mut json = Object::new(); json.insert("root", export_node_to_json(&get_root())); Value::Object(json) diff --git a/rust/lexical-editor/src/gc.rs b/rust/lexical-editor/src/gc.rs new file mode 100644 index 00000000000..35ee06bddc7 --- /dev/null +++ b/rust/lexical-editor/src/gc.rs @@ -0,0 +1,118 @@ +//! `LexicalGC.ts`: dropping the nodes an update detached. Decorators and +//! slots come with their ports. + +use crate::editor_state::NodeMap; +use crate::ordered::OrderedMap; +use crate::{Editor, EditorState}; + +/// `node.isAttached()` on a node map: whether its parents lead to the root. +fn is_attached(node_map: &NodeMap, key: &str) -> bool { + let mut key = key; + loop { + if key == "root" { + return true; + } + match node_map.get(key).and_then(|n| n.base().parent.as_deref()) { + Some(parent) => key = parent, + None => return false, + } + } +} + +/// `$garbageCollectDetachedDeepChildNodes`. +fn garbage_collect_detached_deep_child_nodes( + parent_key: &str, + prev_node_map: &NodeMap, + node_map: &NodeMap, + node_map_delete: &mut Vec, + dirty_elements: &mut OrderedMap, +) { + let Some(node) = node_map.get(parent_key) else { + return; + }; + let Some(element) = node.element() else { + return; + }; + let mut child_key = element.first.clone(); + while let Some(key) = child_key { + let Some(child) = node_map.get(&key) else { + break; + }; + let is_element = child.element().is_some(); + // A dirty child element is left to the outer loop over the dirty + // elements, which visits it (unless deleted before its turn). + if child.base().parent.as_deref() == Some(parent_key) + && !(is_element && dirty_elements.contains(&key)) + { + if is_element { + garbage_collect_detached_deep_child_nodes( + &key, + prev_node_map, + node_map, + node_map_delete, + dirty_elements, + ); + } + // A node created and dropped in this update leaves the dirty + // record too. + if !prev_node_map.contains_key(&key) { + dirty_elements.delete(&key); + } + node_map_delete.push(key.clone()); + } + child_key = child.base().next.clone(); + } +} + +/// `$garbageCollectDetachedNodes(prevEditorState, editorState, dirtyLeaves, +/// dirtyElements)`: removes the detached dirty nodes, and their subtrees, +/// from `editor_state`. +pub(crate) fn garbage_collect_detached_nodes( + prev_editor_state: &EditorState, + editor_state: &EditorState, + editor: &Editor, +) { + let prev_node_map = prev_editor_state.0.node_map.borrow(); + let mut node_map_delete = Vec::new(); + { + let node_map = editor_state.0.node_map.borrow(); + let mut dirty = editor.0.dirty.borrow_mut(); + let mut i = 0; + while let Some(key) = dirty.elements.entry_at(&mut i).map(|(k, _)| k.to_owned()) { + i += 1; + let Some(node) = node_map.get(&key) else { + continue; + }; + if is_attached(&node_map, &key) { + continue; + } + if node.element().is_some() { + garbage_collect_detached_deep_child_nodes( + &key, + &prev_node_map, + &node_map, + &mut node_map_delete, + &mut dirty.elements, + ); + } + if !prev_node_map.contains_key(&key) { + dirty.elements.delete(&key); + } + node_map_delete.push(key); + } + let mut i = 0; + while let Some(key) = dirty.leaves.key_at(&mut i).map(str::to_owned) { + i += 1; + if node_map.contains_key(&key) && !is_attached(&node_map, &key) { + if !prev_node_map.contains_key(&key) { + dirty.leaves.delete(&key); + } + node_map_delete.push(key); + } + } + } + let mut node_map = editor_state.0.node_map.borrow_mut(); + for key in node_map_delete { + node_map.remove(&key); + } +} diff --git a/rust/lexical-editor/src/lib.rs b/rust/lexical-editor/src/lib.rs index c36a7b2e2ea..4c8360c2af9 100644 --- a/rust/lexical-editor/src/lib.rs +++ b/rust/lexical-editor/src/lib.rs @@ -7,12 +7,13 @@ //! //! The internals mirror the upstream sources file by file. JS's `$` //! functions drop the `$` and take snake case: `$getRoot()` is -//! [`get_root()`], and like JS they only work inside [`Editor::read`] or -//! [`EditorState::read`]. A node is read through a [`NodeRef`], which -//! resolves the latest version on every call, as JS node methods do. +//! [`get_root()`], and like JS they only work inside [`Editor::update`], +//! [`Editor::read`] or [`EditorState::read`]. A node is read and changed +//! through a [`NodeRef`], which resolves the latest version on every call, +//! as JS node methods do. Text offsets are UTF-16 code units, as in JS. //! //! ``` -//! use lexical_editor::{create_editor, get_root, CreateEditorArgs}; +//! use lexical_editor::{create_editor, get_root, CreateEditorArgs, UpdateOptions}; //! //! let editor = create_editor(CreateEditorArgs::default()); //! let state = editor @@ -23,6 +24,24 @@ //! state.to_json(true).to_string(), //! r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"Hi"}]}]}}"#, //! ); +//! +//! // An update: split the text and bold the second half. +//! editor.set_editor_state(&state, None).unwrap(); +//! editor +//! .update( +//! || { +//! let text = get_root().get_first_descendant().unwrap(); +//! let parts = text.split_text(&[1])?; +//! parts[1].toggle_format("bold"); +//! Ok(()) +//! }, +//! UpdateOptions { discrete: true, ..UpdateOptions::default() }, +//! ) +//! .unwrap(); +//! assert_eq!( +//! editor.get_editor_state().to_json(true).to_string(), +//! r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"H"},{"type":"text","format":1,"text":"i"}]}]}}"#, +//! ); //! ``` mod caret_tree; @@ -30,21 +49,37 @@ pub mod constants; mod editor; mod editor_state; mod error; +mod gc; mod generated_json; pub mod json; mod node; pub mod nodes; +mod normalization; +mod ordered; +mod selection; mod serialized_export; mod updates; +pub mod utf16; mod utils; -pub use editor::{CreateEditorArgs, Editor, NodeClass, create_editor}; +pub use editor::{ + COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, CreateEditorArgs, Editor, HISTORY_MERGE_TAG, + LexicalCommand, NodeClass, Transform, Unregister, UpdateListenerPayload, UpdateOptions, + create_command, create_editor, +}; pub use editor_state::EditorState; pub use error::LexicalError; pub use node::{LexicalNode, NodeBase, NodeClone, NodeKey, NodeRef}; pub use nodes::element::{Direction, ElementData}; -pub use nodes::text::TextData; +pub use nodes::paragraph::create_paragraph_node; +pub use nodes::text::{TextData, create_text_node, toggle_text_format_type}; +pub use selection::{ + NodeSelection, Point, PointType, RangeSelection, Selection, SelectionRef, Which, + create_node_selection, create_range_selection, get_selection, set_selection, +}; pub use serialized_export::{export_node_json, is_compact_export, with_compact_export}; +pub use updates::parse_serialized_node; pub use utils::{get_node_by_key, get_root, reset_random_key}; /// The upstream Lexical release this port conforms to. The conformance diff --git a/rust/lexical-editor/src/node.rs b/rust/lexical-editor/src/node.rs index 4401928bfff..872018ce440 100644 --- a/rust/lexical-editor/src/node.rs +++ b/rust/lexical-editor/src/node.rs @@ -1,13 +1,21 @@ //! `LexicalNode.ts`: the node trait every node implements, and [`NodeRef`], //! the handle through which code outside a node reads and changes one. -use crate::caret_tree; -use crate::nodes::element::{self, ElementData}; +use crate::caret_tree::{ + detach_node_with_selection, error_on_insert_text_node_on_root, insert_node_between, + insert_sibling, select_adjacent_node, +}; +use crate::error::invariant; +use crate::nodes::element::{self, ElementData, is_root_or_shadow_root}; use crate::nodes::root::RootNode; use crate::nodes::text::TextData; -use crate::updates::get_active_editor_state; -use crate::utils::{get_node_by_key_impl, with_writable}; -use crate::{LexicalError, json::Object}; +use crate::selection::{ + PointType, SelectionRef, Which, get_selection, maybe_move_children_selection_to_parent, + move_selection_point_to_end, move_selection_point_to_sibling, set_selection, +}; +use crate::updates::{error_on_read_only, get_active_editor_state}; +use crate::utils::{get_node_by_key_impl, get_writable, mutate, with_writable}; +use crate::{LexicalError, json::Object, utf16}; use std::any::Any; use std::fmt; use std::rc::Rc; @@ -71,6 +79,12 @@ pub trait LexicalNode: NodeClone + fmt::Debug + 'static { false } + /// `ElementNode.canBeEmpty()`: whether the element stays when its last + /// child is removed. + fn can_be_empty(&self) -> bool { + true + } + /// `getTextContent()`. fn get_text_content(&self) -> String { if let Some(text) = self.text() { @@ -236,14 +250,301 @@ impl NodeRef { self.get_latest().export_json(compact) } - /// `append(...nodes)`, the insertion half of `ElementNode.splice` (with - /// `RootNode.splice`'s check), until the update API is ported. - pub(crate) fn append(&self, nodes: &[NodeRef]) -> Result<(), LexicalError> { - caret_tree::append(self, nodes) - } - /// `updateFromJSON(json)`. pub(crate) fn update_from_json(&self, json: &Object) { with_writable(&self.key, |node| node.update_from_json(json)); } + + /// `getParentOrThrow()`. + pub fn get_parent_or_throw(&self) -> Result { + self.get_parent().ok_or_else(|| { + LexicalError::new(format!("Expected node {} to have a parent.", self.key)) + }) + } + + /// `isAttached()`: whether the node's parents lead to the root. + pub fn is_attached(&self) -> bool { + let editor_state = get_active_editor_state(); + let node_map = editor_state.0.node_map.borrow(); + let mut key = self.key.as_str(); + loop { + if key == "root" { + return true; + } + match node_map.get(key).and_then(|n| n.base().parent.as_deref()) { + Some(parent) => key = parent, + None => return false, + } + } + } + + /// `getIndexWithinParent()`; `None` where JS returns -1. + pub fn get_index_within_parent(&self) -> Option { + let parent = self.get_parent()?; + let mut node = parent.get_first_child(); + let mut index = 0; + while let Some(n) = node { + if &n == self { + return Some(index); + } + index += 1; + node = n.get_next_sibling(); + } + None + } + + /// `getTextContentSize()`: the text content's length in UTF-16 code + /// units. + pub fn get_text_content_size(&self) -> usize { + if self.is_element() { + return self.get_element_text_content_size(); + } + utf16::len(&self.get_text_content()) + } + + /// `ElementNode.canBeEmpty()`. + pub fn can_be_empty(&self) -> bool { + self.get_latest().can_be_empty() + } + + /// `isSelected()` for a node selection. Range selections come with + /// `getNodes`. + fn is_selected_by_node_selection(&self, selection: &SelectionRef) -> bool { + selection.with_node(|n| n.has(&self.key)).unwrap_or(false) + } + + /// `markDirty()`: marks the node dirty, so transforms run on it. + pub fn mark_dirty(&self) { + get_writable(&self.key); + } + + /// `remove(preserveEmptyParent)`. + pub fn remove(&self, preserve_empty_parent: bool) -> Result<(), LexicalError> { + invariant!(!self.is_root(), "remove: cannot be called on root nodes"); + remove_node(self, true, preserve_empty_parent) + } + + /// `replace(replaceWith, includeChildren)`: puts `replace_with` where + /// this node is, with this node's children when `include_children`. + pub fn replace( + &self, + replace_with: &NodeRef, + include_children: bool, + ) -> Result { + invariant!(!self.is_root(), "replace: cannot be called on root nodes"); + error_on_read_only(); + let selection = get_selection().map(|s| s.clone_selection()); + error_on_insert_text_node_on_root(self, replace_with)?; + let to_replace_key = self.key.clone(); + get_writable(replace_with.get_key()); + let parent = self.get_parent_or_throw()?; + get_writable(parent.get_key()); + let size = parent.get_children_size(); + let replace_with_old_parent = replace_with.get_parent(); + let range = selection.as_ref().filter(|s| s.is_range()); + detach_node_with_selection(replace_with, range)?; + let prev_sibling = self.get_previous_sibling(); + let next_sibling = self.get_next_sibling(); + remove_node(self, false, true)?; + if let Some(p) = &prev_sibling { + get_writable(p.get_key()); + } + if let Some(n) = &next_sibling { + get_writable(n.get_key()); + } + insert_node_between( + parent.get_key(), + replace_with.get_key(), + prev_sibling.as_ref().map(NodeRef::get_key), + next_sibling.as_ref().map(NodeRef::get_key), + ); + // When replace_with was already a child of the same parent, two + // children became one. + let size = if replace_with_old_parent.as_ref() == Some(&parent) { + size - 1 + } else { + size + }; + mutate(parent.get_key(), |p| { + p.element_mut().expect("a parent is an element").size = size + }); + let mut prev_size_before_children_transfer = 0; + if include_children { + invariant!( + self.is_element() && replace_with.is_element(), + "includeChildren should only be true for ElementNodes" + ); + prev_size_before_children_transfer = replace_with.get_children_size(); + replace_with.splice(prev_size_before_children_transfer, 0, &self.get_children())?; + } + if let Some(selection) = range { + set_selection(Some(selection.clone())); + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + if point.key == to_replace_key { + if include_children && point.point_type == PointType::Element { + selection.point_set( + which, + replace_with.get_key(), + prev_size_before_children_transfer + point.offset, + PointType::Element, + )?; + } else { + move_selection_point_to_end(selection, which, replace_with)?; + } + } + } + } + Ok(replace_with.clone()) + } + + /// `insertAfter(nodeToInsert, restoreSelection)`. + pub fn insert_after( + &self, + node_to_insert: &NodeRef, + restore_selection: bool, + ) -> Result { + invariant!( + !self.is_root(), + "insertAfter: cannot be called on root nodes" + ); + insert_sibling(self, true, node_to_insert, restore_selection) + } + + /// `insertBefore(nodeToInsert, restoreSelection)`. + pub fn insert_before( + &self, + node_to_insert: &NodeRef, + restore_selection: bool, + ) -> Result { + invariant!( + !self.is_root(), + "insertBefore: cannot be called on root nodes" + ); + insert_sibling(self, false, node_to_insert, restore_selection) + } + + /// `select(anchorOffset, focusOffset)` of a text node or an element; + /// `None` is JS's `undefined` (the end). + pub fn select( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + if self.is_text() { + self.select_text(anchor_offset, focus_offset) + } else if self.is_element() { + self.select_element(anchor_offset, focus_offset) + } else { + Err(LexicalError::new(format!( + "select: node {} is not a text node or an element", + self.key + ))) + } + } + + /// `selectStart()`. + pub fn select_start(&self) -> Result { + if self.is_text() { + self.select(Some(0), Some(0)) + } else if self.is_element() { + match self.get_first_descendant() { + Some(first) => first.select_start(), + None => self.select(None, None), + } + } else { + self.select_previous(None, None) + } + } + + /// `selectEnd()`. + pub fn select_end(&self) -> Result { + if self.is_text() { + let size = self.get_text_content_size(); + self.select(Some(size), Some(size)) + } else if self.is_element() { + match self.get_last_descendant() { + Some(last) => last.select_end(), + None => self.select(None, None), + } + } else { + self.select_next(Some(0), Some(0)) + } + } + + /// `selectPrevious(anchorOffset, focusOffset)`. + pub fn select_previous( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + select_adjacent_node(self, false, anchor_offset, focus_offset) + } + + /// `selectNext(anchorOffset, focusOffset)`. + pub fn select_next( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + select_adjacent_node(self, true, anchor_offset, focus_offset) + } +} + +/// `$removeNode(nodeToRemove, restoreSelection, preserveEmptyParent)`. +pub(crate) fn remove_node( + node_to_remove: &NodeRef, + restore_selection: bool, + preserve_empty_parent: bool, +) -> Result<(), LexicalError> { + error_on_read_only(); + let key = node_to_remove.get_key().to_owned(); + let Some(parent) = node_to_remove.get_parent() else { + return Ok(()); + }; + let selection = maybe_move_children_selection_to_parent(node_to_remove)?; + let mut selection_moved = false; + match &selection { + Some(range) if range.is_range() && restore_selection => { + for which in [Which::Anchor, Which::Focus] { + if range.point(which).expect("a range selection").key == key { + move_selection_point_to_sibling( + range, + which, + node_to_remove, + &parent, + node_to_remove.get_previous_sibling().as_ref(), + node_to_remove.get_next_sibling().as_ref(), + )?; + selection_moved = true; + } + } + } + Some(nodes) + if nodes.is_node() + && restore_selection + && node_to_remove.is_selected_by_node_selection(nodes) => + { + node_to_remove.select_previous(None, None)?; + } + _ => {} + } + + get_writable(&key); + let range = selection + .as_ref() + .filter(|s| restore_selection && !selection_moved && s.is_range()); + detach_node_with_selection(node_to_remove, range)?; + + if !preserve_empty_parent + && !is_root_or_shadow_root(&*parent.get_latest()) + && !parent.can_be_empty() + && parent.is_empty() + { + remove_node(&parent, restore_selection, false)?; + } + if restore_selection && selection.is_some() && parent.is_root() && parent.is_empty() { + parent.select_end()?; + } + Ok(()) } diff --git a/rust/lexical-editor/src/nodes/element.rs b/rust/lexical-editor/src/nodes/element.rs index 14b7251385b..129693d4d3d 100644 --- a/rust/lexical-editor/src/nodes/element.rs +++ b/rust/lexical-editor/src/nodes/element.rs @@ -1,9 +1,19 @@ //! `nodes/LexicalElementNode.ts`: the fields and base behavior of nodes that //! have children. -use crate::constants::{DOUBLE_LINE_BREAK, ELEMENT_TYPE_TO_FORMAT, name_of}; +use crate::caret_tree::{detach_node, detach_sibling_range, insert_node_between}; +use crate::constants::{DOUBLE_LINE_BREAK, ELEMENT_TYPE_TO_FORMAT, lookup, name_of}; +use crate::error::invariant; use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::nodes::root::RootNode; +use crate::selection::{ + Point, PointType, SelectionRef, Which, get_selection, internal_make_range_selection, + move_selection_point_to_sibling, +}; +use crate::updates::error_on_read_only; +use crate::utils::{get_writable, mutate, with_writable}; +use crate::{LexicalError, utf16}; +use std::collections::HashSet; /// `ElementNode`'s text direction (`'ltr' | 'rtl'`; `null` is `None`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -120,3 +130,302 @@ fn has_text_inside(element: &NodeRef) -> bool { .iter() .any(|child| child.is_text() || (child.is_element() && has_text_inside(child))) } + +/// `isPointRemoved(point, nodesToRemoveKeySet, nodesToInsertKeySet)`. +fn is_point_removed( + point: &Point, + removed: &HashSet<&str>, + inserted: &HashSet<&str>, +) -> Result { + let mut node = Some(point.get_node()?); + while let Some(n) = node { + if removed.contains(n.get_key()) && !inserted.contains(n.get_key()) { + return Ok(true); + } + node = n.get_parent(); + } + Ok(false) +} + +/// `ElementNode`'s methods, for a node that is an element. They panic on +/// any other node, as calling them on one is a bug. +impl NodeRef { + fn with_element(&self, f: impl FnOnce(&ElementData) -> R) -> R { + let node = self.get_latest(); + f(node.element().expect("an element")) + } + + fn set_element(&self, f: impl FnOnce(&mut ElementData)) { + with_writable(self.get_key(), |n| f(n.element_mut().expect("an element"))); + } + + /// The element's fields (`getFormat()`, `getIndent()`, ...). + pub fn element_data(&self) -> ElementData { + self.with_element(Clone::clone) + } + + /// `isEmpty()`. + pub fn is_empty(&self) -> bool { + self.get_children_size() == 0 + } + + /// `getChildAtIndex(index)`. + pub fn get_child_at_index(&self, index: usize) -> Option { + let mut node = self.get_first_child(); + for _ in 0..index { + node = node?.get_next_sibling(); + } + node + } + + /// `getFirstDescendant()`. + pub fn get_first_descendant(&self) -> Option { + let mut node = self.get_first_child()?; + while node.is_element() { + match node.get_first_child() { + Some(child) => node = child, + None => break, + } + } + Some(node) + } + + /// `getLastDescendant()`. + pub fn get_last_descendant(&self) -> Option { + let mut node = self.get_last_child()?; + while node.is_element() { + match node.get_last_child() { + Some(child) => node = child, + None => break, + } + } + Some(node) + } + + /// `ElementNode.getTextContentSize()`. + pub(crate) fn get_element_text_content_size(&self) -> usize { + let children = self.get_children(); + let length = children.len(); + let mut size = 0; + for (i, child) in children.iter().enumerate() { + size += child.get_text_content_size(); + if child.is_element() && i != length - 1 && !child.is_inline() { + size += utf16::len(DOUBLE_LINE_BREAK); + } + } + size + } + + /// `ElementNode.select(anchorOffset, focusOffset)`: an element range at + /// those child offsets, the end by default. + pub fn select_element( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + error_on_read_only(); + let selection = get_selection(); + let children_count = self.get_children_size(); + if !self.can_be_empty() { + if anchor_offset == Some(0) && focus_offset == Some(0) { + if let Some(first) = self + .get_first_child() + .filter(|c| c.is_text() || c.is_element()) + { + return first.select(Some(0), Some(0)); + } + } else if anchor_offset.is_none_or(|o| o == children_count) + && focus_offset.is_none_or(|o| o == children_count) + && let Some(last) = self + .get_last_child() + .filter(|c| c.is_text() || c.is_element()) + { + return last.select(None, None); + } + } + let anchor_offset = anchor_offset.unwrap_or(children_count); + let focus_offset = focus_offset.unwrap_or(children_count); + let key = self.get_key(); + match selection.filter(SelectionRef::is_range) { + None => Ok(internal_make_range_selection( + Point::new(key, anchor_offset, PointType::Element), + Point::new(key, focus_offset, PointType::Element), + )), + Some(selection) => { + selection.point_set(Which::Anchor, key, anchor_offset, PointType::Element)?; + selection.point_set(Which::Focus, key, focus_offset, PointType::Element)?; + selection.set_dirty(true); + Ok(selection) + } + } + } + + /// `clear()`: removes every child. + pub fn clear(&self) -> Result<(), LexicalError> { + get_writable(self.get_key()); + for child in self.get_children() { + child.remove(false)?; + } + Ok(()) + } + + /// `append(...nodesToAppend)`. + pub fn append(&self, nodes: &[NodeRef]) -> Result<(), LexicalError> { + self.splice(self.get_children_size(), 0, nodes) + } + + /// `setDirection(direction)`. + pub fn set_direction(&self, direction: Option) { + self.set_element(|e| e.dir = direction); + } + + /// `setFormat(type)`: an alignment by name (`'center'`, ...); `''` and + /// unknown names clear it. + pub fn set_element_format(&self, format_type: &str) { + let format = lookup(ELEMENT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + self.set_element(|e| e.format = format); + } + + /// `setStyle(style)`. + pub fn set_element_style(&self, style: &str) { + self.set_element(|e| e.style = style.to_owned()); + } + + /// `setTextFormat(type)`. + pub fn set_text_format(&self, text_format: f64) { + self.set_element(|e| e.text_format = text_format); + } + + /// `setTextStyle(style)`. + pub fn set_text_style(&self, style: &str) { + self.set_element(|e| e.text_style = style.to_owned()); + } + + /// `setIndent(indentLevel)`. + pub fn set_indent(&self, indent: f64) { + self.set_element(|e| e.indent = indent); + } + + /// `splice(start, deleteCount, nodesToInsert)`: removes `delete_count` + /// children from `start` and inserts `nodes` there (moving any that are + /// in the tree already), repairing the selection when children are + /// removed. The root takes only elements (`RootNode.splice`). + pub fn splice( + &self, + start: usize, + delete_count: usize, + nodes_to_insert: &[NodeRef], + ) -> Result<(), LexicalError> { + if self.is_root() { + for node in nodes_to_insert { + // DecoratorNode is allowed too, once it is ported. + invariant!( + node.is_element(), + "rootNode.splice: Only element or decorator nodes can be inserted to the root node" + ); + } + } + let old_size = self.get_children_size(); + get_writable(self.get_key()); + invariant!( + start + delete_count <= old_size, + "ElementNode.splice: start + deleteCount > oldSize ({start} + {delete_count} > {old_size})" + ); + let self_key = self.get_key(); + let mut nodes_to_insert_keys: Vec = Vec::new(); + let mut nodes_to_remove_keys = Vec::new(); + let mut node_after_range = self.get_child_at_index(start + delete_count); + let mut node_before_range = None; + let mut new_size = (old_size - delete_count + nodes_to_insert.len()) as isize; + + if start != 0 { + if start == old_size { + node_before_range = self.get_last_child(); + } else if let Some(node) = self.get_child_at_index(start) { + node_before_range = node.get_previous_sibling(); + } + } + + if delete_count > 0 { + let first = match &node_before_range { + None => self.get_first_child(), + Some(before) => before.get_next_sibling(), + }; + nodes_to_remove_keys = + detach_sibling_range(self, first, delete_count, node_before_range.as_ref())?; + } + + // The writable insertion tail. + let mut writable_prev_node = match &node_before_range { + Some(before) if !nodes_to_insert.is_empty() => { + get_writable(before.get_key()); + Some(before.clone()) + } + _ => None, + }; + for node_to_insert in nodes_to_insert { + if writable_prev_node.as_ref() == Some(node_to_insert) { + node_before_range = node_to_insert.get_previous_sibling(); + writable_prev_node = node_before_range.clone(); + if let Some(prev) = &writable_prev_node { + get_writable(prev.get_key()); + } + } + if node_after_range.as_ref() == Some(node_to_insert) { + node_after_range = node_to_insert.get_next_sibling(); + } + get_writable(node_to_insert.get_key()); + if node_to_insert.get_parent().as_ref() == Some(self) { + new_size -= 1; + } + detach_node(node_to_insert); + if let Some(after) = &node_after_range { + get_writable(after.get_key()); + } + insert_node_between( + self_key, + node_to_insert.get_key(), + writable_prev_node.as_ref().map(NodeRef::get_key), + node_after_range.as_ref().map(NodeRef::get_key), + ); + invariant!( + node_to_insert.get_key() != self_key, + "append: attempting to append self" + ); + nodes_to_insert_keys.push(node_to_insert.get_key().to_owned()); + writable_prev_node = Some(node_to_insert.clone()); + } + + let new_size = usize::try_from(new_size).expect("size >= 0"); + mutate(self_key, |n| { + n.element_mut().expect("an element").size = new_size + }); + + // Removing children moves the selection out of them, and removes an + // element that can't be empty. + if !nodes_to_remove_keys.is_empty() + && let Some(selection) = get_selection().filter(SelectionRef::is_range) + { + let removed: HashSet<&str> = nodes_to_remove_keys.iter().map(String::as_str).collect(); + let inserted: HashSet<&str> = nodes_to_insert_keys.iter().map(String::as_str).collect(); + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + if is_point_removed(&point, &removed, &inserted)? { + move_selection_point_to_sibling( + &selection, + which, + &point.get_node()?, + self, + node_before_range.as_ref(), + node_after_range.as_ref(), + )?; + } + } + if new_size == 0 && !self.can_be_empty() && !is_root_or_shadow_root(&*self.get_latest()) + { + self.remove(false)?; + } + } + Ok(()) + } +} diff --git a/rust/lexical-editor/src/nodes/paragraph.rs b/rust/lexical-editor/src/nodes/paragraph.rs index 65e348f816d..a3d37dcca4d 100644 --- a/rust/lexical-editor/src/nodes/paragraph.rs +++ b/rust/lexical-editor/src/nodes/paragraph.rs @@ -7,6 +7,11 @@ use crate::json::{Object, Value}; use crate::node::{LexicalNode, NodeBase, NodeRef}; use crate::nodes::element::ElementData; +/// `$createParagraphNode()`. +pub fn create_paragraph_node() -> NodeRef { + crate::utils::set_node_key(Box::new(ParagraphNode::default())) +} + #[derive(Clone, Debug, Default)] pub struct ParagraphNode { base: NodeBase, diff --git a/rust/lexical-editor/src/nodes/text.rs b/rust/lexical-editor/src/nodes/text.rs index 492e4df1d3e..25fe728b7c5 100644 --- a/rust/lexical-editor/src/nodes/text.rs +++ b/rust/lexical-editor/src/nodes/text.rs @@ -1,9 +1,23 @@ //! `nodes/LexicalTextNode.ts`. -use crate::constants::{TEXT_MODE_TO_TYPE, name_of}; +use crate::constants::{ + DETAIL_TYPE_TO_DETAIL, IS_DIRECTIONLESS, IS_NORMAL, IS_SEGMENTED, IS_TOKEN, IS_UNMERGEABLE, + TEXT_MODE_TO_TYPE, TEXT_TYPE_TO_FORMAT, lookup, name_of, +}; +use crate::error::invariant; use crate::generated_json::{export_compact_text_node, export_text_node, update_text_node}; use crate::json::Object; -use crate::node::{LexicalNode, NodeBase}; +use crate::node::{LexicalNode, NodeBase, NodeRef}; +use crate::nodes::tab::TabNode; +use crate::selection::{ + Point, PointType, SelectionRef, Which, adjust_point_offset_for_merged_sibling, get_selection, + internal_make_range_selection, update_element_selection_on_create_delete_node, +}; +use crate::updates::error_on_read_only; +use crate::utils::{ + get_writable, internal_mark_siblings_as_dirty, mutate, set_node_key, with_writable, +}; +use crate::{LexicalError, utf16}; /// The fields `TextNode` adds. `format` and `detail` are kept as the JS /// number they parsed to, so a document writes back exactly what JS writes; @@ -88,3 +102,453 @@ impl LexicalNode for TextNode { update_text_node(&mut self.text, json); } } + +impl TextNode { + /// A text node holding `text` (`new TextNode(text)`), not yet in an + /// editor state. + pub fn new(text: impl Into) -> Self { + Self { + base: NodeBase::default(), + text: TextData { + text: text.into(), + ..TextData::default() + }, + } + } +} + +/// `$createTextNode(text)`. +pub fn create_text_node(text: &str) -> NodeRef { + set_node_key(Box::new(TextNode::new(text))) +} + +/// `ToInt32(x)`, how JS's bitwise operators read a number. +pub(crate) fn to_int32(x: f64) -> i32 { + if !x.is_finite() { + return 0; + } + x.trunc().rem_euclid(4_294_967_296.0) as u32 as i32 +} + +/// `toggleTextFormatType(format, type, alignWithFormat)`. +pub fn toggle_text_format_type( + format: i32, + format_type: &str, + align_with_format: Option, +) -> i32 { + let flag = |name| lookup(TEXT_TYPE_TO_FORMAT, name).unwrap_or(0); + let active_format = flag(format_type); + if let Some(align) = align_with_format + && (format & active_format) == (align & active_format) + { + return format; + } + let mut new_format = format ^ active_format; + match format_type { + "subscript" => new_format &= !flag("superscript"), + "superscript" => new_format &= !flag("subscript"), + "lowercase" => new_format &= !(flag("uppercase") | flag("capitalize")), + "uppercase" => new_format &= !(flag("lowercase") | flag("capitalize")), + "capitalize" => new_format &= !(flag("lowercase") | flag("uppercase")), + _ => {} + } + new_format +} + +/// `text.slice(start)` with JS's negative `start` (from the end). +fn slice_from_signed(text: &str, start: i64) -> String { + let start = if start < 0 { + (utf16::len(text) as i64 + start).max(0) + } else { + start + }; + utf16::slice_from(text, start as usize) +} + +/// `TextNode`'s methods, for a node that is a text node (a `TextNode` or a +/// subclass such as `TabNode`). They panic on any other node, as calling +/// them on one is a bug. +impl NodeRef { + fn with_text(&self, f: impl FnOnce(&TextData) -> R) -> R { + let node = self.get_latest(); + f(node.text().expect("a text node")) + } + + fn set_text_data(&self, f: impl FnOnce(&mut TextData)) { + with_writable(self.get_key(), |n| f(n.text_mut().expect("a text node"))); + } + + fn is_tab(&self) -> bool { + self.get_latest().is::() + } + + /// The text node's fields (`getFormat()`, `getStyle()`, ...). + pub fn text_data(&self) -> TextData { + self.with_text(Clone::clone) + } + + /// `isSimpleText()`: a plain `text` node in normal mode. + pub fn is_simple_text(&self) -> bool { + self.get_type() == "text" && self.with_text(|t| t.mode == IS_NORMAL) + } + + /// `isUnmergeable()`. + pub fn is_unmergeable(&self) -> bool { + self.with_text(|t| to_int32(t.detail) & IS_UNMERGEABLE != 0) + } + + /// `isToken()`. + pub fn is_token(&self) -> bool { + self.with_text(|t| t.mode == IS_TOKEN) + } + + /// `isSegmented()`. + pub fn is_segmented(&self) -> bool { + self.with_text(|t| t.mode == IS_SEGMENTED) + } + + /// `hasFormat(type)`. + pub fn has_format(&self, format_type: &str) -> bool { + let flag = lookup(TEXT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + self.with_text(|t| to_int32(t.format) & flag != 0) + } + + /// `setFormat(format)` with a number. + pub fn set_format(&self, format: f64) { + self.set_text_data(|t| t.format = format); + } + + /// `setFormat(type)` with a name, such as `'bold'`. + pub fn set_format_type(&self, format_type: &str) { + let format = lookup(TEXT_TYPE_TO_FORMAT, format_type).unwrap_or(0); + self.set_format(f64::from(format)); + } + + /// `setDetail(detail)` with a number. A tab only takes + /// `IS_UNMERGEABLE`, which it has. + pub fn set_detail(&self, detail: f64) -> Result<(), LexicalError> { + if self.is_tab() { + invariant!( + detail == f64::from(IS_UNMERGEABLE), + "TabNode does not support setDetail" + ); + return Ok(()); + } + self.set_text_data(|t| t.detail = detail); + Ok(()) + } + + /// `setDetail(type)` with a name: `'directionless'` or `'unmergeable'`. + pub fn set_detail_type(&self, detail_type: &str) -> Result<(), LexicalError> { + invariant!(!self.is_tab(), "TabNode does not support setDetail"); + let detail = lookup(DETAIL_TYPE_TO_DETAIL, detail_type).unwrap_or(0); + self.set_detail(f64::from(detail)) + } + + /// `setStyle(style)`. + pub fn set_style(&self, style: &str) { + self.set_text_data(|t| t.style = style.to_owned()); + } + + /// `toggleFormat(type)`. + pub fn toggle_format(&self, format_type: &str) { + let format = to_int32(self.with_text(|t| t.format)); + let new_format = toggle_text_format_type(format, format_type, None); + self.set_format(f64::from(new_format)); + } + + /// `toggleDirectionless()`. + pub fn toggle_directionless(&self) { + self.set_text_data(|t| t.detail = f64::from(to_int32(t.detail) ^ IS_DIRECTIONLESS)); + } + + /// `toggleUnmergeable()`. + pub fn toggle_unmergeable(&self) { + self.set_text_data(|t| t.detail = f64::from(to_int32(t.detail) ^ IS_UNMERGEABLE)); + } + + /// `setMode(type)`: `'normal'`, `'token'` or `'segmented'`. A tab is + /// always normal. + pub fn set_mode(&self, mode_type: &str) -> Result<(), LexicalError> { + if self.is_tab() { + invariant!(mode_type == "normal", "TabNode does not support setMode"); + return Ok(()); + } + let Some(mode) = lookup(TEXT_MODE_TO_TYPE, mode_type) else { + return Err(LexicalError::new(format!( + "setMode: unknown mode {mode_type}" + ))); + }; + if self.with_text(|t| t.mode) != mode { + self.set_text_data(|t| t.mode = mode); + } + Ok(()) + } + + /// `setTextContent(text)`. A tab keeps its `\t`. + pub fn set_text_content(&self, text: &str) { + let text = if self.is_tab() { "\t" } else { text }; + if self.with_text(|t| t.text != text) { + self.set_text_data(|t| t.text = text.to_owned()); + } + } + + /// `TextNode.select(anchorOffset, focusOffset)`: a text range at those + /// UTF-16 offsets, the end by default. + pub fn select_text( + &self, + anchor_offset: Option, + focus_offset: Option, + ) -> Result { + error_on_read_only(); + let selection = get_selection(); + let last_offset = utf16::len(&self.get_text_content()); + let anchor_offset = anchor_offset.unwrap_or(last_offset); + let focus_offset = focus_offset.unwrap_or(last_offset); + let key = self.get_key(); + match selection.filter(SelectionRef::is_range) { + None => Ok(internal_make_range_selection( + Point::new(key, anchor_offset, PointType::Text), + Point::new(key, focus_offset, PointType::Text), + )), + Some(selection) => { + selection.set_text_node_range(self, anchor_offset, self, focus_offset)?; + Ok(selection) + } + } + } + + /// `spliceText(offset, delCount, newText, moveSelection)`: replaces + /// `del_count` UTF-16 units at `offset` with `new_text`, and moves a + /// range selection after it when `move_selection`. + pub fn splice_text( + &self, + offset: i64, + del_count: i64, + new_text: &str, + move_selection: bool, + ) -> Result { + if self.is_tab() { + invariant!( + (new_text.is_empty() && del_count == 0) || (new_text == "\t" && del_count == 1), + "TabNode does not support spliceText" + ); + return Ok(self.clone()); + } + get_writable(self.get_key()); + let text = self.get_text_content(); + let handled_text_length = utf16::len(new_text) as i64; + let mut index = offset; + if index < 0 { + index = (handled_text_length + index).max(0); + } + if move_selection && let Some(selection) = get_selection().filter(SelectionRef::is_range) { + let new_offset = (offset + handled_text_length).max(0) as usize; + selection.set_text_node_range(self, new_offset, self, new_offset)?; + } + let updated_text = utf16::slice(&text, 0, index as usize) + + new_text + + &slice_from_signed(&text, index + del_count); + mutate(self.get_key(), |n| { + n.text_mut().expect("a text node").text = updated_text + }); + Ok(self.clone()) + } + + /// `splitText(...splitOffsets)`: splits the text at those UTF-16 + /// offsets into this node and new siblings with the same format, style + /// and detail, and returns the parts. A segmented node is replaced. + pub fn split_text(&self, split_offsets: &[i64]) -> Result, LexicalError> { + error_on_read_only(); + let text_content = self.get_text_content(); + if text_content.is_empty() { + return Ok(Vec::new()); + } + let key = self.get_key().to_owned(); + let text_length = utf16::len(&text_content) as i64; + let mut offsets = split_offsets.to_vec(); + offsets.sort_unstable(); + offsets.push(text_length); + let mut parts = Vec::new(); + let mut start = 0; + for &end in &offsets { + if start >= text_length { + break; + } + if end > start { + parts.push(utf16::slice(&text_content, start as usize, end as usize)); + start = end; + } + } + if parts.len() == 1 { + return Ok(vec![self.clone()]); + } + let first_part = &parts[0]; + let parent = self.get_parent(); + let TextData { + format, + style, + detail, + .. + } = self.text_data(); + + // Prepare to handle selection + let selection = get_selection().filter(SelectionRef::is_range); + let mut start_text_point = None; + let mut end_text_point = None; + if let Some(selection) = &selection { + let (start_point, end_point) = if selection.is_backward()? { + (Which::Focus, Which::Anchor) + } else { + (Which::Anchor, Which::Focus) + }; + let is_here = |which| { + let point = selection.point(which).expect("a range selection"); + point.point_type == PointType::Text && point.key == key + }; + if is_here(start_point) { + start_text_point = Some(start_point); + } + if is_here(end_point) { + end_text_point = Some(end_point); + } + } + + let copy_fields = |node: &NodeRef| { + mutate(node.get_key(), |n| { + let t = n.text_mut().expect("a text node"); + t.format = format; + t.style = style.clone(); + t.detail = detail; + }); + }; + let has_replaced_self = self.is_segmented(); + let writable_node = if has_replaced_self { + let node = create_text_node(first_part); + copy_fields(&node); + node + } else { + self.set_text_content(first_part); + self.clone() + }; + + let mut split_nodes = vec![writable_node]; + for part in &parts[1..] { + let sibling = create_text_node(part); + copy_fields(&sibling); + split_nodes.push(sibling); + } + + // Move the selection to the best location in the split string. The + // end point is always left-biased, and the start point is generally + // left-biased unless the end point would land on a later node. + let offset_of = |which: Option| { + which.map(|w| { + selection + .as_ref() + .and_then(|s| s.point(w)) + .expect("a range selection") + .offset + }) + }; + let original_start_offset = offset_of(start_text_point); + let original_end_offset = offset_of(end_text_point); + let mut start_offset = 0; + for node in &split_nodes { + if start_text_point.is_none() && end_text_point.is_none() { + break; + } + let selection = selection.as_ref().expect("a point is selected"); + let end_offset = start_offset + node.get_text_content_size(); + if let (Some(which), Some(original)) = (start_text_point, original_start_offset) + && original <= end_offset + && original >= start_offset + { + selection.point_set( + which, + node.get_key(), + original - start_offset, + PointType::Text, + )?; + if original < end_offset { + // The start isn't on a border, so we can stop checking. + start_text_point = None; + } + } + if let (Some(which), Some(original)) = (end_text_point, original_end_offset) + && original <= end_offset + && original >= start_offset + { + selection.point_set( + which, + node.get_key(), + original - start_offset, + PointType::Text, + )?; + break; + } + start_offset = end_offset; + } + + // Insert the nodes into the parent's children. + if let Some(parent) = parent { + internal_mark_siblings_as_dirty(self); + get_writable(parent.get_key()); + let insertion_index = self.get_index_within_parent().expect("a child"); + if has_replaced_self { + parent.splice(insertion_index, 0, &split_nodes)?; + self.remove(false)?; + } else { + parent.splice(insertion_index, 1, &split_nodes)?; + } + if let Some(selection) = &selection { + update_element_selection_on_create_delete_node( + selection, + &parent, + insertion_index, + parts.len() as isize - 1, + )?; + } + } + Ok(split_nodes) + } + + /// `mergeWithSibling(target)`: appends (or prepends) the text of an + /// adjacent text node and removes it, keeping the selection on the same + /// text. + pub fn merge_with_sibling(&self, target: &NodeRef) -> Result { + let is_before = self.get_previous_sibling().as_ref() == Some(target); + invariant!( + is_before || self.get_next_sibling().as_ref() == Some(target), + "mergeWithSibling: sibling must be a previous or next sibling" + ); + let key = self.get_key(); + let target_key = target.get_key(); + let text = self.get_text_content(); + let text_length = utf16::len(&text); + if let Some(selection) = get_selection().filter(SelectionRef::is_range) { + for which in [Which::Anchor, Which::Focus] { + if selection.point(which).expect("a range selection").key == target_key { + adjust_point_offset_for_merged_sibling( + &selection, + which, + is_before, + key, + target, + text_length, + )?; + } + } + } + let target_text = target.get_text_content(); + let new_text = if is_before { + target_text + &text + } else { + text + &target_text + }; + self.set_text_content(&new_text); + get_writable(key); + target.remove(false)?; + Ok(self.clone()) + } +} diff --git a/rust/lexical-editor/src/normalization.rs b/rust/lexical-editor/src/normalization.rs new file mode 100644 index 00000000000..52cb7b38733 --- /dev/null +++ b/rust/lexical-editor/src/normalization.rs @@ -0,0 +1,68 @@ +//! `LexicalNormalization.ts`: merging adjacent simple text nodes. A +//! headless editor never composes, so the composition holds don't apply. + +use crate::LexicalError; +use crate::node::NodeRef; +use crate::updates::get_active_editor; + +/// `$canSimpleTextNodesBeMerged(node1, node2)`. NodeState comes with its +/// port. +fn can_simple_text_nodes_be_merged(node1: &NodeRef, node2: &NodeRef) -> bool { + let (a, b) = (node1.get_latest(), node2.get_latest()); + let (a, b) = ( + a.text().expect("a text node"), + b.text().expect("a text node"), + ); + a.mode == b.mode && a.format == b.format && a.style == b.style +} + +/// `$mergeTextNodes(node1, node2)`. +fn merge_text_nodes(node1: &NodeRef, node2: &NodeRef) -> Result { + let writable_node1 = node1.merge_with_sibling(node2)?; + if let Some(editor) = get_active_editor() { + let mut dirty = editor.0.dirty.borrow_mut(); + dirty.normalized.add(node1.get_key()); + dirty.normalized.add(node2.get_key()); + } + Ok(writable_node1) +} + +fn is_mergeable_simple_text(node: &NodeRef) -> bool { + node.is_text() && node.is_simple_text() && !node.is_unmergeable() +} + +/// `$normalizeTextNode(textNode)`: removes the node if it is empty, else +/// merges it with an adjacent compatible text node on each side, removing +/// empty ones on the way. +pub(crate) fn normalize_text_node(text_node: &NodeRef) -> Result<(), LexicalError> { + let mut node = text_node.clone(); + if node.get_text_content().is_empty() && node.is_simple_text() && !node.is_unmergeable() { + node.remove(false)?; + return Ok(()); + } + + // Backward + while let Some(previous) = node.get_previous_sibling().filter(is_mergeable_simple_text) { + if previous.get_text_content().is_empty() { + previous.remove(false)?; + } else { + if can_simple_text_nodes_be_merged(&previous, &node) { + node = merge_text_nodes(&previous, &node)?; + } + break; + } + } + + // Forward + while let Some(next) = node.get_next_sibling().filter(is_mergeable_simple_text) { + if next.get_text_content().is_empty() { + next.remove(false)?; + } else { + if can_simple_text_nodes_be_merged(&node, &next) { + merge_text_nodes(&node, &next)?; + } + break; + } + } + Ok(()) +} diff --git a/rust/lexical-editor/src/ordered.rs b/rust/lexical-editor/src/ordered.rs new file mode 100644 index 00000000000..e7130911a8b --- /dev/null +++ b/rust/lexical-editor/src/ordered.rs @@ -0,0 +1,150 @@ +//! Insertion-ordered collections with JS `Map` and `Set` semantics: setting +//! an existing key keeps its position, a deleted key that is added again goes +//! last, and iterating by index (`get(i)` for `i = 0, 1, ...`) visits entries +//! added during the iteration, as `for...of` over a JS `Set` does. The update +//! cycle depends on that: normalizing a dirty text node can dirty others, +//! which the same pass then visits. + +use std::collections::HashMap; + +#[derive(Clone, Debug)] +pub(crate) struct OrderedMap { + entries: Vec>, + index: HashMap, +} + +impl Default for OrderedMap { + fn default() -> Self { + Self { + entries: Vec::new(), + index: HashMap::new(), + } + } +} + +impl OrderedMap { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn len(&self) -> usize { + self.index.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.index.is_empty() + } + + pub(crate) fn contains(&self, key: &str) -> bool { + self.index.contains_key(key) + } + + /// `map.set(key, value)`. + pub(crate) fn set(&mut self, key: &str, value: V) { + if let Some(&i) = self.index.get(key) { + self.entries[i].as_mut().expect("indexed").1 = value; + return; + } + self.index.insert(key.to_owned(), self.entries.len()); + self.entries.push(Some((key.to_owned(), value))); + } + + /// `map.delete(key)`. + pub(crate) fn delete(&mut self, key: &str) -> Option { + let i = self.index.remove(key)?; + self.entries[i].take().map(|(_, v)| v) + } + + /// The entry at position `i` of the iteration order, skipping deleted + /// entries; `None` past the end. Positions stay valid while entries are + /// added. + pub(crate) fn entry_at(&self, i: &mut usize) -> Option<(&str, &V)> { + while *i < self.entries.len() { + if let Some((k, v)) = &self.entries[*i] { + return Some((k, v)); + } + *i += 1; + } + None + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|e| e.as_ref().map(|(k, v)| (k.as_str(), v))) + } + + pub(crate) fn keys(&self) -> impl Iterator { + self.iter().map(|(k, _)| k) + } +} + +/// A JS `Set` of strings. +#[derive(Clone, Debug, Default)] +pub(crate) struct OrderedSet(OrderedMap<()>); + +impl OrderedSet { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn contains(&self, key: &str) -> bool { + self.0.contains(key) + } + + pub(crate) fn add(&mut self, key: &str) { + self.0.set(key, ()); + } + + pub(crate) fn delete(&mut self, key: &str) -> bool { + self.0.delete(key).is_some() + } + + /// See [`OrderedMap::entry_at`]. + pub(crate) fn key_at(&self, i: &mut usize) -> Option<&str> { + self.0.entry_at(i).map(|(k, _)| k) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.0.keys() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iterates_like_a_js_set() { + // const s = new Set(['a', 'b', 'c']); s.delete('b'); s.add('a'); + // const seen = []; for (const k of s) { seen.push(k); if (k === 'a') s.add('d'); } + let mut s = OrderedSet::new(); + for k in ["a", "b", "c"] { + s.add(k); + } + s.delete("b"); + s.add("a"); + let mut seen = Vec::new(); + let mut i = 0; + while let Some(k) = s.key_at(&mut i).map(str::to_owned) { + if k == "a" { + s.add("d"); + } + seen.push(k); + i += 1; + } + assert_eq!(seen, ["a", "c", "d"]); + // A deleted key added again goes last. + s.delete("a"); + s.add("a"); + assert_eq!(s.iter().collect::>(), ["c", "d", "a"]); + } +} diff --git a/rust/lexical-editor/src/selection.rs b/rust/lexical-editor/src/selection.rs new file mode 100644 index 00000000000..341689df306 --- /dev/null +++ b/rust/lexical-editor/src/selection.rs @@ -0,0 +1,571 @@ +//! `LexicalSelection.ts`: points, `RangeSelection` and `NodeSelection`, and +//! the helpers node methods use to keep a selection valid while they change +//! the tree. Selection editing (`insertText`, `deleteCharacter`, ...) comes +//! next (rust/DESIGN.md, M1). + +use crate::LexicalError; +use crate::error::invariant; +use crate::node::{NodeKey, NodeRef}; +use crate::ordered::OrderedSet; +use crate::updates::{get_active_editor_state, is_currently_read_only_mode}; +use crate::utils::get_node_by_key; +use std::cell::RefCell; +use std::rc::Rc; + +/// A point's type: inside a text node (an offset in UTF-16 code units) or +/// between an element's children (a child index). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointType { + Text, + Element, +} + +impl PointType { + pub fn as_str(self) -> &'static str { + match self { + PointType::Text => "text", + PointType::Element => "element", + } + } +} + +/// `PointType`: a node key, an offset and a type. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Point { + pub key: NodeKey, + pub offset: usize, + pub point_type: PointType, +} + +impl Point { + pub fn new(key: impl Into, offset: usize, point_type: PointType) -> Self { + Self { + key: key.into(), + offset, + point_type, + } + } + + /// `point.getNode()`. + pub fn get_node(&self) -> Result { + get_node_by_key(&self.key).ok_or_else(|| LexicalError::new("Point.getNode: node not found")) + } +} + +/// Which point of a range selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Which { + Anchor, + Focus, +} + +/// `RangeSelection`'s fields. +#[derive(Clone, Debug)] +pub struct RangeSelection { + pub anchor: Point, + pub focus: Point, + pub format: f64, + pub style: String, + pub(crate) dirty: bool, + pub(crate) cached_is_backward: Option, +} + +impl RangeSelection { + /// `new RangeSelection(anchor, focus, format, style)`. + pub fn new(anchor: Point, focus: Point, format: f64, style: impl Into) -> Self { + Self { + anchor, + focus, + format, + style: style.into(), + dirty: false, + cached_is_backward: None, + } + } + + pub fn point(&self, which: Which) -> &Point { + match which { + Which::Anchor => &self.anchor, + Which::Focus => &self.focus, + } + } + + fn point_mut(&mut self, which: Which) -> &mut Point { + match which { + Which::Anchor => &mut self.anchor, + Which::Focus => &mut self.focus, + } + } + + /// `is(selection)`: equal points, format and style. + pub fn is(&self, other: &RangeSelection) -> bool { + self.anchor == other.anchor + && self.focus == other.focus + && self.format == other.format + && self.style == other.style + } + + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } +} + +/// `NodeSelection`'s fields: the selected keys, in the order added. +#[derive(Clone, Debug, Default)] +pub struct NodeSelection { + pub(crate) nodes: OrderedSet, + pub(crate) dirty: bool, +} + +impl NodeSelection { + pub fn keys(&self) -> impl Iterator { + self.nodes.iter() + } + + pub fn add(&mut self, key: &str) { + self.dirty = true; + self.nodes.add(key); + } + + pub fn delete(&mut self, key: &str) { + self.dirty = true; + self.nodes.delete(key); + } + + pub fn has(&self, key: &str) -> bool { + self.nodes.contains(key) + } +} + +/// A selection's value. +#[derive(Clone, Debug)] +pub enum Selection { + Range(RangeSelection), + Node(NodeSelection), +} + +impl Selection { + fn dirty_mut(&mut self) -> &mut bool { + match self { + Selection::Range(s) => &mut s.dirty, + Selection::Node(s) => &mut s.dirty, + } + } + + /// `selection.is(other)`. + pub fn is(&self, other: &Selection) -> bool { + match (self, other) { + (Selection::Range(a), Selection::Range(b)) => a.is(b), + (Selection::Node(a), Selection::Node(b)) => { + a.nodes.len() == b.nodes.len() && a.nodes.iter().all(|k| b.nodes.contains(k)) + } + _ => false, + } + } +} + +/// A selection object, shared as JS shares it: `$getSelection()` returns the +/// one the editor state holds, so a change through it is a change of the +/// state's selection; [`clone_selection`](Self::clone_selection) makes a +/// separate one. +#[derive(Clone, Debug)] +pub struct SelectionRef(Rc>); + +impl SelectionRef { + pub fn new(selection: Selection) -> Self { + Self(Rc::new(RefCell::new(selection))) + } + + /// The selection's current value. + pub fn get(&self) -> Selection { + self.0.borrow().clone() + } + + pub fn is_range(&self) -> bool { + matches!(*self.0.borrow(), Selection::Range(_)) + } + + pub fn is_node(&self) -> bool { + matches!(*self.0.borrow(), Selection::Node(_)) + } + + /// The range selection's value, `None` for a node selection. + pub fn range(&self) -> Option { + match &*self.0.borrow() { + Selection::Range(r) => Some(r.clone()), + Selection::Node(_) => None, + } + } + + pub(crate) fn with_range(&self, f: impl FnOnce(&mut RangeSelection) -> R) -> Option { + match &mut *self.0.borrow_mut() { + Selection::Range(r) => Some(f(r)), + Selection::Node(_) => None, + } + } + + pub(crate) fn with_node(&self, f: impl FnOnce(&mut NodeSelection) -> R) -> Option { + match &mut *self.0.borrow_mut() { + Selection::Node(n) => Some(f(n)), + Selection::Range(_) => None, + } + } + + /// A node selection's keys, in the order added; empty for a range + /// selection. + pub fn node_keys(&self) -> Vec { + self.with_node(|n| n.keys().map(str::to_owned).collect()) + .unwrap_or_default() + } + + /// The point `which` of a range selection. + pub fn point(&self, which: Which) -> Option { + self.with_range(|r| r.point(which).clone()) + } + + pub fn is_dirty(&self) -> bool { + *self.0.borrow_mut().dirty_mut() + } + + pub(crate) fn set_dirty(&self, dirty: bool) { + *self.0.borrow_mut().dirty_mut() = dirty; + } + + /// `selection.clone()`: a separate selection with the same value, not + /// dirty. + pub fn clone_selection(&self) -> SelectionRef { + let mut value = self.get(); + match &mut value { + Selection::Range(r) => { + r.dirty = false; + r.cached_is_backward = None; + } + Selection::Node(n) => n.dirty = false, + } + SelectionRef::new(value) + } + + pub fn ptr_eq(&self, other: &SelectionRef) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + /// `point.set(key, offset, type)` on the point `which` of a range + /// selection; nothing on a node selection. + pub fn point_set( + &self, + which: Which, + key: &str, + offset: usize, + point_type: PointType, + ) -> Result<(), LexicalError> { + // JS checks this in DEV builds, which the vectors come from. + let node = get_node_by_key(key); + let matches = match (&node, point_type) { + (Some(n), PointType::Text) => n.is_text(), + (Some(n), PointType::Element) => n.is_element(), + (None, _) => false, + }; + invariant!( + matches, + "PointType.set: node with key {key} is {} and can not be used for a {} point", + node.map_or("[not found]", |n| n.get_type()), + point_type.as_str() + ); + let read_only = is_currently_read_only_mode(); + self.with_range(|r| { + *r.point_mut(which) = Point::new(key, offset, point_type); + // The composition key follows the point once composition is + // ported; a headless editor never composes. + if !read_only { + r.cached_is_backward = None; + r.dirty = true; + } + }); + Ok(()) + } + + /// `setTextNodeRange(anchorNode, anchorOffset, focusNode, focusOffset)`. + pub(crate) fn set_text_node_range( + &self, + anchor: &NodeRef, + anchor_offset: usize, + focus: &NodeRef, + focus_offset: usize, + ) -> Result<(), LexicalError> { + self.point_set( + Which::Anchor, + anchor.get_key(), + anchor_offset, + PointType::Text, + )?; + self.point_set(Which::Focus, focus.get_key(), focus_offset, PointType::Text) + } + + /// `isBackward()`: whether the focus comes before the anchor. + pub fn is_backward(&self) -> Result { + let range = self.range().expect("a range selection"); + if let Some(cached) = range.cached_is_backward { + return Ok(cached); + } + let is_backward = is_before(&range.focus, &range.anchor)?; + if !is_currently_read_only_mode() { + self.with_range(|r| r.cached_is_backward = Some(is_backward)); + } + Ok(is_backward) + } +} + +/// `a.isBefore(b)`. Points on different nodes are ordered by carets +/// (`$comparePointCaretNext`), which come with the caret port. +fn is_before(a: &Point, b: &Point) -> Result { + if a.key == b.key { + return Ok(a.offset < b.offset); + } + Err(LexicalError::new( + "Point.isBefore: points on different nodes need the caret port (rust/DESIGN.md, M1)", + )) +} + +/// `$getSelection()`. +pub fn get_selection() -> Option { + get_active_editor_state().0.selection.borrow().clone() +} + +/// `$setSelection(selection)`. +pub fn set_selection(selection: Option) { + crate::updates::error_on_read_only(); + if let Some(selection) = &selection { + selection.set_dirty(true); + } + *get_active_editor_state().0.selection.borrow_mut() = selection; +} + +/// `$createRangeSelection()`: a selection at the start of the root, not +/// set on the editor state. +pub fn create_range_selection() -> SelectionRef { + SelectionRef::new(Selection::Range(RangeSelection::new( + Point::new("root", 0, PointType::Element), + Point::new("root", 0, PointType::Element), + 0.0, + "", + ))) +} + +/// `$createNodeSelection()`. +pub fn create_node_selection() -> SelectionRef { + SelectionRef::new(Selection::Node(NodeSelection::default())) +} + +/// `$internalMakeRangeSelection`: a new range selection, set on the active +/// editor state. +pub(crate) fn internal_make_range_selection(anchor: Point, focus: Point) -> SelectionRef { + let mut range = RangeSelection::new(anchor, focus, 0.0, ""); + range.dirty = true; + let selection = SelectionRef::new(Selection::Range(range)); + *get_active_editor_state().0.selection.borrow_mut() = Some(selection.clone()); + selection +} + +/// `selectPointOnNode(point, node)`. +fn select_point_on_node( + selection: &SelectionRef, + which: Which, + node: &NodeRef, +) -> Result<(), LexicalError> { + let mut key = node.get_key().to_owned(); + let mut offset = selection.point(which).expect("a range selection").offset; + let mut point_type = PointType::Element; + if node.is_text() { + point_type = PointType::Text; + offset = offset.min(node.get_text_content_size()); + } else if !node.is_element() { + match node.get_next_sibling().filter(NodeRef::is_text) { + Some(next) => { + key = next.get_key().to_owned(); + offset = 0; + point_type = PointType::Text; + } + None => { + if let Some(parent) = node.get_parent() { + key = parent.get_key().to_owned(); + offset = node.get_index_within_parent().map_or(0, |i| i + 1); + } + } + } + } + selection.point_set(which, &key, offset, point_type) +} + +/// `$moveSelectionPointToEnd(point, node)`. +pub(crate) fn move_selection_point_to_end( + selection: &SelectionRef, + which: Which, + node: &NodeRef, +) -> Result<(), LexicalError> { + if node.is_element() { + match node.get_last_descendant() { + Some(last) if last.is_element() || last.is_text() => { + select_point_on_node(selection, which, &last) + } + _ => select_point_on_node(selection, which, node), + } + } else { + select_point_on_node(selection, which, node) + } +} + +/// `$selectionTouchesElement(selection, parentNode)`. +pub(crate) fn selection_touches_element(selection: &SelectionRef, parent: &NodeRef) -> bool { + selection + .with_range(|r| r.anchor.key == parent.get_key() || r.focus.key == parent.get_key()) + .unwrap_or(false) +} + +/// `$updateElementSelectionOnCreateDeleteNode(selection, parentNode, +/// nodeOffset, times)`. +pub(crate) fn update_element_selection_on_create_delete_node( + selection: &SelectionRef, + parent: &NodeRef, + node_offset: usize, + times: isize, +) -> Result<(), LexicalError> { + if !selection_touches_element(selection, parent) { + return Ok(()); + } + for which in [Which::Anchor, Which::Focus] { + let point = selection.point(which).expect("a range selection"); + if point.key == parent.get_key() + && ((node_offset <= point.offset && times > 0) + || (node_offset < point.offset && times < 0)) + { + let offset = (point.offset as isize + times).max(0) as usize; + selection.point_set(which, parent.get_key(), offset, PointType::Element)?; + } + let point = selection.point(which).expect("a range selection"); + let node = point.get_node()?; + if node.is_element() { + let size = node.get_children_size(); + let at_end = point.offset >= size; + let child = if at_end { + size.checked_sub(1).and_then(|i| node.get_child_at_index(i)) + } else { + node.get_child_at_index(point.offset) + }; + if let Some(child) = child.filter(NodeRef::is_text) { + let offset = if at_end { + child.get_text_content_size() + } else { + 0 + }; + selection.point_set(which, child.get_key(), offset, PointType::Text)?; + } + } + } + Ok(()) +} + +/// `moveSelectionPointToSibling(point, node, parent, prevSibling, +/// nextSibling)`. +pub(crate) fn move_selection_point_to_sibling( + selection: &SelectionRef, + which: Which, + node: &NodeRef, + parent: &NodeRef, + prev_sibling: Option<&NodeRef>, + next_sibling: Option<&NodeRef>, +) -> Result<(), LexicalError> { + let sibling = prev_sibling.or(next_sibling); + match sibling.filter(|s| s.is_text() || s.is_element()) { + Some(sibling) => { + let is_text = sibling.is_text(); + let offset = if prev_sibling.is_none() { + 0 + } else if is_text { + sibling.get_text_content_size() + } else { + sibling.get_children_size() + }; + let point_type = if is_text { + PointType::Text + } else { + PointType::Element + }; + selection.point_set(which, sibling.get_key(), offset, point_type) + } + None => { + let offset = node + .get_index_within_parent() + .unwrap_or_else(|| parent.get_children_size()); + selection.point_set(which, parent.get_key(), offset, PointType::Element) + } + } +} + +/// `adjustPointOffsetForMergedSibling(point, isBefore, key, target, +/// textLength)`. +pub(crate) fn adjust_point_offset_for_merged_sibling( + selection: &SelectionRef, + which: Which, + is_before: bool, + key: &str, + target: &NodeRef, + text_length: usize, +) -> Result<(), LexicalError> { + let point = selection.point(which).expect("a range selection"); + if point.point_type == PointType::Text { + let offset = point.offset + if is_before { 0 } else { text_length }; + selection.point_set(which, key, offset, PointType::Text) + } else if target + .get_index_within_parent() + .is_some_and(|i| point.offset > i) + { + selection.point_set(which, &point.key, point.offset - 1, PointType::Element) + } else { + Ok(()) + } +} + +/// `$hasAncestor(child, targetNode)`. +fn has_ancestor(child: &NodeRef, target: &NodeRef) -> bool { + let mut parent = child.get_parent(); + while let Some(p) = parent { + if &p == target { + return true; + } + parent = p.get_parent(); + } + false +} + +/// `$maybeMoveChildrenSelectionToParent(parentNode)`. +pub(crate) fn maybe_move_children_selection_to_parent( + parent: &NodeRef, +) -> Result, LexicalError> { + let selection = get_selection(); + let Some(range) = selection.as_ref().filter(|s| s.is_range()) else { + return Ok(selection); + }; + if !parent.is_element() { + return Ok(selection); + } + for which in [Which::Anchor, Which::Focus] { + let node = range.point(which).expect("a range selection").get_node()?; + if has_ancestor(&node, parent) { + range.point_set(which, parent.get_key(), 0, PointType::Element)?; + } + } + Ok(selection) +} + +/// `editorStateHasDirtySelection(editorState, editor)`. +pub(crate) fn has_dirty_selection( + pending: Option<&SelectionRef>, + current: Option<&SelectionRef>, +) -> bool { + match (pending, current) { + (Some(pending), current) => { + pending.is_dirty() || current.is_none_or(|c| !pending.get().is(&c.get())) + } + (None, current) => current.is_some(), + } +} diff --git a/rust/lexical-editor/src/updates.rs b/rust/lexical-editor/src/updates.rs index 842a701c6b7..b5d1b6ff9ec 100644 --- a/rust/lexical-editor/src/updates.rs +++ b/rust/lexical-editor/src/updates.rs @@ -1,27 +1,41 @@ //! `LexicalUpdates.ts`: the active editor and editor state that `$` -//! functions read, and `parseEditorState`. The update lifecycle -//! (`editor.update`, transforms, commit, listeners) comes next -//! (rust/DESIGN.md, milestones). +//! functions read, the update cycle (`editor.update`, transforms, the +//! commit and its listeners), command dispatch, and `parseEditorState`. +//! +//! A headless editor has no DOM, so the reconciler, mutation listeners, +//! selection-change notifications and the DOM selection never run here, as +//! in JS's headless editor. Where JS commits a non-discrete update in a +//! microtask, Rust commits it at the next [`Editor::read`] or discrete +//! update, which JS also does when one comes first. -use crate::editor::NodeClass; -use crate::editor_state::create_empty_editor_state; +use crate::editor::{ + DirtyType, LexicalCommand, NodeClass, Transform, UpdateListenerPayload, UpdateOptions, +}; +use crate::editor_state::{clone_editor_state, create_empty_editor_state}; use crate::error::invariant; +use crate::gc::garbage_collect_detached_nodes; use crate::json::Value; use crate::node::NodeRef; +use crate::normalization::normalize_text_node; +use crate::ordered::{OrderedMap, OrderedSet}; +use crate::selection::has_dirty_selection; +use crate::utils::{get_node_by_key, get_root}; use crate::{Editor, EditorState, LexicalError, constants::NODE_STATE_KEY}; -use std::cell::RefCell; -use std::collections::HashMap; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; -/// JS's module-scope `activeEditorState` and `isReadOnlyMode`, per thread. -/// `activeEditor` comes with the update API, its first reader. +/// JS's module-scope `activeEditor`, `activeEditorState` and +/// `isReadOnlyMode`, per thread. #[derive(Clone, Default)] struct Active { + editor: Option, editor_state: Option, read_only: bool, } thread_local! { static ACTIVE: RefCell = RefCell::new(Active::default()); + static INFINITE_TRANSFORM_COUNT: Cell = const { Cell::new(0) }; } /// Runs `f` with `active` in place, restoring the previous one afterwards, @@ -50,6 +64,13 @@ pub(crate) fn get_active_editor_state() -> EditorState { }) } +/// `internalGetActiveEditor()`: the editor whose update or read is running, +/// if any. Reading an editor state on its own ([`EditorState::read`], +/// parsing) has none. +pub(crate) fn get_active_editor() -> Option { + ACTIVE.with(|a| a.borrow().editor.clone()) +} + /// `isCurrentlyReadOnlyMode()`. pub(crate) fn is_currently_read_only_mode() -> bool { ACTIVE.with(|a| a.borrow().read_only) @@ -62,10 +83,28 @@ pub(crate) fn error_on_read_only() { } } +/// `errorOnInfiniteTransforms()`. +pub(crate) fn error_on_infinite_transforms() { + if INFINITE_TRANSFORM_COUNT.with(Cell::get) > 99 { + panic!( + "One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other." + ); + } +} + +fn bump_infinite_transform_count() { + INFINITE_TRANSFORM_COUNT.with(|c| c.set(c.get() + 1)); +} + /// `readEditorState(editor, editorState, callbackFn)`. -pub(crate) fn read_editor_state(editor_state: &EditorState, f: impl FnOnce() -> R) -> R { +pub(crate) fn read_editor_state( + editor: Option<&Editor>, + editor_state: &EditorState, + f: impl FnOnce() -> R, +) -> R { with_active( Active { + editor: editor.cloned(), editor_state: Some(editor_state.clone()), read_only: true, }, @@ -73,6 +112,143 @@ pub(crate) fn read_editor_state(editor_state: &EditorState, f: impl FnOnce() ) } +/// `$applyTransforms(editor, node, transformsCache)`. +fn apply_transforms( + editor: &Editor, + node: &NodeRef, + cache: &mut HashMap<&'static str, Vec>, +) -> Result<(), LexicalError> { + let node_type = node.get_type(); + let transforms = cache + .entry(node_type) + .or_insert_with(|| editor.transforms_of(node_type)) + .clone(); + for transform in &transforms { + transform(node)?; + if !node.is_attached() { + break; + } + } + Ok(()) +} + +/// Whether a dirty leaf is normalized: attached, simple, mergeable text. +fn should_normalize(node: &NodeRef) -> bool { + node.is_text() && node.is_attached() && node.is_simple_text() && !node.is_unmergeable() +} + +/// `$normalizeAllDirtyTextNodes(editorState, editor)`. Iterates the dirty +/// leaves as JS's `for...of` does, seeing those added meanwhile. +fn normalize_all_dirty_text_nodes(editor: &Editor) -> Result<(), LexicalError> { + let mut i = 0; + loop { + let key = editor + .0 + .dirty + .borrow() + .leaves + .key_at(&mut i) + .map(str::to_owned); + let Some(key) = key else { break }; + if let Some(node) = get_node_by_key(&key) + && should_normalize(&node) + { + normalize_text_node(&node)?; + } + i += 1; + } + Ok(()) +} + +/// `$applyAllTransforms(editorState, editor)`: transforms dirty leaves +/// first, repeating while they dirty more leaves, then the elements marked +/// dirty themselves, the root last, and again from the leaves while +/// anything new is dirty. +/// +/// JS swaps the editor's dirty sets for fresh ones to see what each round +/// dirties, and accumulates the full record in the sets it started with; +/// in the first round the full record is the editor's own set, which is +/// why `full_*` is `None` until the editor's set is first swapped out. +fn apply_all_transforms(editor: &Editor) -> Result<(), LexicalError> { + let mut cache = HashMap::new(); + let mut full_leaves: Option = None; + let mut full_elements: Option> = None; + + loop { + let (leaves_len, elements_len) = { + let dirty = editor.0.dirty.borrow(); + (dirty.leaves.len(), dirty.elements.len()) + }; + if leaves_len == 0 && elements_len == 0 { + break; + } + if leaves_len > 0 { + let untransformed = std::mem::take(&mut editor.0.dirty.borrow_mut().leaves); + let keys: Vec = untransformed.iter().map(str::to_owned).collect(); + full_leaves.get_or_insert(untransformed); + for key in &keys { + if let Some(node) = get_node_by_key(key) { + if should_normalize(&node) { + normalize_text_node(&node)?; + } + if node.is_attached() { + apply_transforms(editor, &node, &mut cache)?; + } + } + full_leaves.as_mut().expect("set above").add(key); + } + // Leaf transforms come before element transforms. + if !editor.0.dirty.borrow().leaves.is_empty() { + bump_infinite_transform_count(); + continue; + } + } + + let (previous_leaves, mut untransformed) = { + let mut dirty = editor.0.dirty.borrow_mut(); + ( + std::mem::take(&mut dirty.leaves), + std::mem::take(&mut dirty.elements), + ) + }; + full_leaves.get_or_insert(previous_leaves); + // The root is always considered intentionally dirty if any attached + // node is dirty, and moving it last applies its transforms last. + if untransformed.delete("root").is_some() { + untransformed.set("root", true); + } + full_elements.get_or_insert_with(|| untransformed.clone()); + let entries: Vec<(String, bool)> = untransformed + .iter() + .map(|(k, v)| (k.to_owned(), *v)) + .collect(); + for (key, intentionally_marked_as_dirty) in entries { + full_elements + .as_mut() + .expect("set above") + .set(&key, intentionally_marked_as_dirty); + if !intentionally_marked_as_dirty { + continue; + } + if let Some(node) = get_node_by_key(&key) + && node.is_attached() + { + apply_transforms(editor, &node, &mut cache)?; + } + } + bump_infinite_transform_count(); + } + + let mut dirty = editor.0.dirty.borrow_mut(); + if let Some(leaves) = full_leaves { + dirty.leaves = leaves; + } + if let Some(elements) = full_elements { + dirty.elements = elements; + } + Ok(()) +} + /// `$parseSerializedNodeImpl`. fn parse_serialized_node_impl( serialized_node: &Value, @@ -125,13 +301,25 @@ fn parse_serialized_node_impl( Ok(node) } -/// `parseEditorState(serializedEditorState, editor)`. +/// `$parseSerializedNode(serializedNode)`: a node, and its children, from +/// its JSON, created in the active update with the active editor's node +/// classes. +pub fn parse_serialized_node(serialized_node: &Value) -> Result { + let editor = + get_active_editor().ok_or_else(|| LexicalError::new("Unable to find an active editor."))?; + parse_serialized_node_impl(serialized_node, editor.registered_nodes()) +} + +/// `parseEditorState(serializedEditorState, editor)`. The nodes are created +/// outside any update of the editor, so nothing is marked dirty (JS saves +/// and restores the editor's dirty records instead). pub(crate) fn parse_editor_state( serialized_editor_state: &Value, editor: &Editor, ) -> Result { let editor_state = create_empty_editor_state(); let active = Active { + editor: None, editor_state: Some(editor_state.clone()), read_only: false, }; @@ -146,5 +334,329 @@ pub(crate) fn parse_editor_state( })?; // Make the editor state immutable. editor_state.0.read_only.set(true); + editor_state.0.parsed.set(true); Ok(editor_state) } + +/// `getEditorStateTextContent(editorState)`. +fn editor_state_text_content(editor_state: &EditorState) -> String { + editor_state.read(|| get_root().get_text_content()) +} + +/// `triggerListeners`'s bracket: listeners run with the editor marked as +/// updating, so an update they start is queued. +fn with_updating(editor: &Editor, f: impl FnOnce() -> R) -> R { + struct Restore<'a>(&'a Editor, bool); + impl Drop for Restore<'_> { + fn drop(&mut self) { + self.0.0.updating.set(self.1); + } + } + let _restore = Restore(editor, editor.0.updating.replace(true)); + f() +} + +/// `$commitPendingUpdates(editor)`: makes the pending state the editor's +/// state, notifies the text content and update listeners, and runs the +/// first update queued meanwhile. +pub(crate) fn commit_pending_updates(editor: &Editor) -> Result<(), LexicalError> { + let Some(pending) = editor.0.pending.borrow_mut().take() else { + return Ok(()); + }; + let current = editor.get_editor_state(); + let needs_update = editor.dirty_type() != DirtyType::None; + *editor.0.editor_state.borrow_mut() = pending.clone(); + pending.0.read_only.set(true); + + let (dirty_elements, dirty_leaves, normalized_nodes) = { + let mut dirty = editor.0.dirty.borrow_mut(); + let elements = dirty + .elements + .iter() + .map(|(k, v)| (k.to_owned(), *v)) + .collect(); + let leaves = dirty.leaves.iter().map(str::to_owned).collect(); + let normalized = dirty.normalized.iter().map(str::to_owned).collect(); + if needs_update { + *dirty = Default::default(); + } + (elements, leaves, normalized) + }; + // Reset even when nothing is dirty, so a no-op update's tags don't leak + // into the next one. + let tags: Vec = std::mem::take(&mut *editor.0.tags.borrow_mut()) + .iter() + .map(str::to_owned) + .collect(); + + let previously_dispatching = editor.0.dispatching_commit_listeners.replace(true); + with_updating(editor, || { + let listeners = editor.text_content_listeners(); + if !listeners.is_empty() { + let current_text = editor_state_text_content(¤t); + let latest_text = editor_state_text_content(&pending); + if current_text != latest_text { + for listener in &listeners { + listener(&latest_text); + } + } + } + let payload = UpdateListenerPayload { + dirty_elements, + dirty_leaves, + normalized_nodes, + tags, + editor_state: pending.clone(), + prev_editor_state: current.clone(), + }; + for listener in editor.update_listeners() { + listener(&payload); + } + }); + editor + .0 + .dispatching_commit_listeners + .set(previously_dispatching); + trigger_enqueued_updates(editor) +} + +/// `$triggerEnqueuedUpdates(editor)`: begins the next queued update, if +/// any, unless listeners have been queueing updates endlessly. +fn trigger_enqueued_updates(editor: &Editor) -> Result<(), LexicalError> { + if editor.0.updates.borrow().is_empty() { + editor.0.cascade_count.set(0); + return Ok(()); + } + let count = editor + .0 + .cascade_count + .replace(editor.0.cascade_count.get() + 1); + if count > 99 { + // JS warns through onWarn. + editor.0.updates.borrow_mut().clear(); + editor.0.cascade_count.set(0); + return Ok(()); + } + let queued = editor.0.updates.borrow_mut().pop_front(); + match queued { + Some(queued) => begin_update(editor, queued.update_fn, &queued.options), + None => Ok(()), + } +} + +/// `$processNestedUpdates(editor, initialSkipTransforms)`: runs the updates +/// queued during this one as part of it. +fn process_nested_updates(editor: &Editor, initial_skip: bool) -> Result { + let mut skip_transforms = initial_skip; + loop { + let queued = editor.0.updates.borrow_mut().pop_front(); + let Some(queued) = queued else { break }; + let pending = editor.pending(); + if queued.options.skip_transforms { + skip_transforms = true; + } + if queued.options.discrete { + invariant!( + pending.is_some(), + "Unexpected empty pending editor state on discrete nested update" + ); + pending.as_ref().expect("checked").0.flush_sync.set(true); + } + editor.add_tags(&queued.options.tags); + if pending.is_none() { + begin_update(editor, queued.update_fn, &queued.options)?; + } else { + (queued.update_fn)()?; + } + } + Ok(skip_transforms) +} + +/// `$discardPendingUpdate(editor, queuedBefore)`: the editor goes back to +/// its committed state; the updates the failed one queued are dropped. +fn discard_pending_update(editor: &Editor, queued_before: &HashSet) { + *editor.0.pending.borrow_mut() = Some(editor.get_editor_state()); + { + let mut dirty = editor.0.dirty.borrow_mut(); + dirty.dirty_type = DirtyType::FullReconcile; + dirty.leaves = OrderedSet::new(); + dirty.elements = OrderedMap::new(); + } + editor + .0 + .updates + .borrow_mut() + .retain(|u| queued_before.contains(&u.id)); +} + +/// `$beginUpdate(editor, updateFn, options)`. +pub(crate) fn begin_update( + editor: &Editor, + update_fn: impl FnOnce() -> Result<(), LexicalError>, + options: &UpdateOptions, +) -> Result<(), LexicalError> { + editor.add_tags(&options.tags); + let mut skip_transforms = options.skip_transforms; + let current = editor.get_editor_state(); + let mut was_cloned = false; + let pending = match editor.pending() { + Some(pending) if !pending.0.read_only.get() => pending, + other => { + let pending = clone_editor_state(other.as_ref().unwrap_or(¤t)); + *editor.0.pending.borrow_mut() = Some(pending.clone()); + was_cloned = true; + pending + } + }; + pending.0.flush_sync.set(options.discrete); + // The updates queued before this one (see discard_pending_update). + let queued_before: HashSet = editor.0.updates.borrow().iter().map(|u| u.id).collect(); + let previous_active_state = ACTIVE.with(|a| a.borrow().editor_state.clone()); + + struct Restore<'a>(&'a Editor, bool); + impl Drop for Restore<'_> { + fn drop(&mut self) { + self.0.0.updating.set(self.1); + INFINITE_TRANSFORM_COUNT.with(|c| c.set(0)); + } + } + let restore = Restore(editor, editor.0.updating.replace(true)); + + let active = Active { + editor: Some(editor.clone()), + editor_state: Some(pending.clone()), + read_only: false, + }; + let result = with_active(active, || { + let run = || -> Result<(), LexicalError> { + if was_cloned && let Some(selection) = current.selection() { + // A headless editor carries the selection over. + *pending.0.selection.borrow_mut() = Some(selection.clone_selection()); + } + update_fn()?; + skip_transforms = process_nested_updates(editor, skip_transforms)?; + if editor.dirty_type() != DirtyType::None { + if skip_transforms { + normalize_all_dirty_text_nodes(editor)?; + } else { + apply_all_transforms(editor)?; + } + process_nested_updates(editor, false)?; + garbage_collect_detached_nodes(¤t, &pending, editor); + } + if let Some(selection) = pending.selection() { + if let Some(range) = selection.range() { + let node_map = pending.0.node_map.borrow(); + invariant!( + node_map.contains_key(&range.anchor.key) + && node_map.contains_key(&range.focus.key), + "updateEditor: selection has been lost because the previously selected nodes have been removed and selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node." + ); + } else if selection.with_node(|n| n.nodes.is_empty()) == Some(true) { + *pending.0.selection.borrow_mut() = None; + } + } + Ok(()) + }; + match run() { + Ok(()) => Ok(()), + Err(error) => { + // An update nested in the running update whose pending state + // it shares leaves the error to that update. + if !was_cloned + && previous_active_state + .as_ref() + .is_some_and(|s| s.ptr_eq(&pending)) + { + return Err(error); + } + discard_pending_update(editor, &queued_before); + // JS reports a failure of this commit to onError too; the + // caller gets the update's error. + let _ = commit_pending_updates(editor); + Err(error) + } + } + }); + drop(restore); + result?; + + let should_update = editor.dirty_type() != DirtyType::None + || has_dirty_selection( + pending.selection().as_ref(), + editor.get_editor_state().selection().as_ref(), + ); + if should_update { + if pending.0.flush_sync.get() { + pending.0.flush_sync.set(false); + commit_pending_updates(editor)?; + } + // Otherwise the next read or discrete update commits it. + } else { + pending.0.flush_sync.set(false); + if was_cloned { + *editor.0.tags.borrow_mut() = OrderedSet::new(); + *editor.0.pending.borrow_mut() = None; + } + } + Ok(()) +} + +/// `updateEditorSync(editor, updateFn, options)`: runs `f` in the editor's +/// running update when there are no options, or in a new update. +pub(crate) fn update_editor_sync( + editor: &Editor, + f: impl FnOnce() -> Result<(), LexicalError>, + options: Option<&UpdateOptions>, +) -> Result<(), LexicalError> { + let (in_update, read_only) = ACTIVE.with(|a| { + let a = a.borrow(); + ( + a.editor.as_ref().is_some_and(|e| e.ptr_eq(editor)), + a.read_only, + ) + }); + match options { + None if in_update && !read_only => f(), + None => begin_update(editor, f, &UpdateOptions::default()), + Some(options) => begin_update(editor, f, options), + } +} + +/// `triggerCommandListeners(editor, type, payload)`: the command's +/// listeners from the highest priority down, each priority's in one update, +/// until one returns true. +pub(crate) fn trigger_command_listeners( + editor: &Editor, + command: &LexicalCommand

, + payload: &P, +) -> Result { + // A command dispatched from outside an update starts a new action. + if !editor.0.updating.get() { + editor.0.cascade_count.set(0); + } + for priority in (0..=4).rev() { + let listeners = editor.command_listeners(command, priority); + if listeners.is_empty() { + continue; + } + let mut handled = false; + update_editor_sync( + editor, + || { + for listener in &listeners { + if listener(payload, editor)? { + handled = true; + return Ok(()); + } + } + Ok(()) + }, + None, + )?; + if handled { + return Ok(true); + } + } + Ok(false) +} diff --git a/rust/lexical-editor/src/utf16.rs b/rust/lexical-editor/src/utf16.rs new file mode 100644 index 00000000000..7dd41cebd98 --- /dev/null +++ b/rust/lexical-editor/src/utf16.rs @@ -0,0 +1,50 @@ +//! Text offsets in UTF-16 code units, as JS strings count them, over Rust's +//! UTF-8 strings. A slice that splits a surrogate pair gives U+FFFD for the +//! half it keeps, the Tier 1 exception for unpaired surrogates +//! (rust/PARITY.md); it is still one unit long, so lengths and offsets agree +//! with JS. + +/// `text.length`. +pub fn len(text: &str) -> usize { + if text.is_ascii() { + text.len() + } else { + text.chars().map(char::len_utf16).sum() + } +} + +/// `text.slice(start, end)` for `0 <= start <= end`, clamped to the length. +pub fn slice(text: &str, start: usize, end: usize) -> String { + if text.is_ascii() { + let end = end.min(text.len()); + let start = start.min(end); + return text[start..end].to_owned(); + } + let units: Vec = text.encode_utf16().collect(); + let end = end.min(units.len()); + let start = start.min(end); + String::from_utf16_lossy(&units[start..end]) +} + +/// `text.slice(start)`. +pub fn slice_from(text: &str, start: usize) -> String { + slice(text, start, usize::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_and_slices_like_js() { + // Each `.length` and `.slice(...)` in Node. + let text = "aé😀b"; + assert_eq!(len(text), 5); + assert_eq!(slice(text, 1, 4), "é😀"); + assert_eq!(slice_from(text, 4), "b"); + assert_eq!(slice(text, 9, 12), ""); + // Splitting the pair keeps one unit each side, as U+FFFD. + assert_eq!(slice(text, 0, 3), "aé\u{FFFD}"); + assert_eq!(len(&slice(text, 0, 3)), 3); + } +} diff --git a/rust/lexical-editor/src/utils.rs b/rust/lexical-editor/src/utils.rs index 6df9ac08ae6..22321d8cf0f 100644 --- a/rust/lexical-editor/src/utils.rs +++ b/rust/lexical-editor/src/utils.rs @@ -1,8 +1,12 @@ -//! `LexicalUtils.ts`: keys, node lookup and `getWritable`. +//! `LexicalUtils.ts`: keys, node lookup, `getWritable` and the dirty marks +//! it leaves for the update cycle. use crate::EditorState; +use crate::editor::DirtyType; use crate::node::{LexicalNode, NodeRef}; -use crate::updates::{error_on_read_only, get_active_editor_state}; +use crate::updates::{ + error_on_infinite_transforms, error_on_read_only, get_active_editor, get_active_editor_state, +}; use std::cell::Cell; use std::rc::Rc; @@ -42,34 +46,112 @@ pub fn get_root() -> NodeRef { NodeRef::new("root") } -/// `$setNodeKey(node, null)`: gives a new node a key and puts it in the -/// active editor state. +/// `$setNodeKey(node, null)`: gives a new node a key, puts it in the active +/// editor state and marks it dirty. pub(crate) fn set_node_key(mut node: Box) -> NodeRef { error_on_read_only(); + error_on_infinite_transforms(); let key = generate_random_key(); node.base_mut().key = key.clone(); + let is_element = node.element().is_some(); get_active_editor_state() .0 .node_map .borrow_mut() .insert(key.clone(), Rc::from(node)); + if let Some(editor) = get_active_editor() { + editor.mark_dirty(&key, is_element); + } NodeRef::new(key) } -/// `node.getWritable()`, then `f` on it. The node is cloned first when -/// anything else shares it, such as the previous editor state. -/// -/// `f` runs while the node map is borrowed, so it must only change the -/// node's own fields, not read other nodes. -pub(crate) fn with_writable(key: &str, f: impl FnOnce(&mut dyn LexicalNode) -> R) -> R { +/// `internalMarkNodeAsDirty(node)`: marks the node's ancestors as dirty +/// (unintentionally, `false`) up to the first one already marked, then the +/// node itself. +pub(crate) fn internal_mark_node_as_dirty(key: &str) { + error_on_infinite_transforms(); + let Some(editor) = get_active_editor() else { + return; + }; + let editor_state = get_active_editor_state(); + let (parent, is_element) = { + let node_map = editor_state.0.node_map.borrow(); + let node = node_map + .get(key) + .unwrap_or_else(|| panic!("internalMarkNodeAsDirty: node {key} does not exist")); + (node.base().parent.clone(), node.element().is_some()) + }; + { + let node_map = editor_state.0.node_map.borrow(); + let mut dirty = editor.0.dirty.borrow_mut(); + // `internalMarkParentElementsAsDirty`. + let mut next_parent = parent; + while let Some(parent_key) = next_parent { + if dirty.elements.contains(&parent_key) { + break; + } + let Some(node) = node_map.get(&parent_key) else { + break; + }; + dirty.elements.set(&parent_key, false); + next_parent = node.base().parent.clone(); + } + if dirty.dirty_type == DirtyType::None { + dirty.dirty_type = DirtyType::HasDirtyNodes; + } + } + editor.mark_dirty(key, is_element); +} + +/// `internalMarkSiblingsAsDirty(node)`. +pub(crate) fn internal_mark_siblings_as_dirty(node: &NodeRef) { + if let Some(previous) = node.get_previous_sibling() { + internal_mark_node_as_dirty(previous.get_key()); + } + if let Some(next) = node.get_next_sibling() { + internal_mark_node_as_dirty(next.get_key()); + } +} + +/// `node.getWritable()`: marks the node dirty, and copies it into the +/// active editor state if anything else (the previous editor state) shares +/// it. Changes then go through [`mutate`]. +pub(crate) fn get_writable(key: &str) { error_on_read_only(); + unshare(key); + internal_mark_node_as_dirty(key); +} + +fn unshare(key: &str) { let editor_state = get_active_editor_state(); let mut node_map = editor_state.0.node_map.borrow_mut(); - let node = node_map - .get_mut(key) - .unwrap_or_else(|| panic!("getWritable: node {key} does not exist")); + let node = node_map.get_mut(key).unwrap_or_else(|| { + panic!( + "Lexical node does not exist in active editor state (key {key}). The reference was probably kept from another read or update." + ) + }); if Rc::get_mut(node).is_none() { *node = Rc::from(node.clone_node()); } - f(Rc::get_mut(node).expect("unshared")) +} + +/// Changes the fields of a node that [`get_writable`] already made writable +/// in this update, as JS assigns to the fields of the writable node, without +/// marking it dirty again. +/// +/// `f` runs while the node map is borrowed, so it must only change the +/// node's own fields, not read other nodes. +pub(crate) fn mutate(key: &str, f: impl FnOnce(&mut dyn LexicalNode) -> R) -> R { + error_on_read_only(); + unshare(key); + let editor_state = get_active_editor_state(); + let mut node_map = editor_state.0.node_map.borrow_mut(); + let node = node_map.get_mut(key).expect("unshared above"); + f(Rc::get_mut(node).expect("unshared above")) +} + +/// `const self = node.getWritable(); f(self)`. +pub(crate) fn with_writable(key: &str, f: impl FnOnce(&mut dyn LexicalNode) -> R) -> R { + get_writable(key); + mutate(key, f) }