yrby 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -9
- data/README.md +13 -11
- data/ext/yrby/src/protocol.rs +131 -21
- data/ext/yrby/src/read.rs +134 -19
- data/lib/y/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5928987b16a5d3a3933b95875dff276e4e18364b46ed0e33baa8e48b5b2e847
|
|
4
|
+
data.tar.gz: 2676689ddee9403765353c4612055fb120ce9e1c6dcdbeb956c482eed313374d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e13256e4881804c3330bf69ce86203bd2aea4bda9313db042f7d368221130f6624285cbddea1a9b61b967c3831635ace45b1291496100872ad8a93c97be2e10
|
|
7
|
+
data.tar.gz: fbf1c8c9ec0f0b23190578d562f75fb14a14ec3d562b54b86437c5abda5bd48a97f2da216927f66fde46b5d52fa12d38b4f39b8073b8c6222d656fd09fd2c24f
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,44 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.3] - 2026-07-01
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `Doc#update_advances?` is now exact for **delete-bearing** updates, so an
|
|
14
|
+
already-applied pure-delete retry no longer reports as advancing. Previously any
|
|
15
|
+
update carrying a delete set returned `true` (record it) because deletes don't
|
|
16
|
+
move the state vector, so the cheap state-vector probe couldn't prove a
|
|
17
|
+
duplicate. A lost-ack retry of a deletion the server had already integrated was
|
|
18
|
+
therefore re-recorded and re-broadcast every time. For delete-bearing updates we
|
|
19
|
+
now compare the full encoded document state (which includes the delete set)
|
|
20
|
+
before vs. after a trial apply on an isolated probe: a genuinely new deletion
|
|
21
|
+
changes it (`true`); an already-applied retry re-encodes identically (`false`).
|
|
22
|
+
Insert/format-only updates keep the cheaper state-vector path, so only
|
|
23
|
+
delete-bearing frames — a minority — pay for the exact comparison. The exactly-
|
|
24
|
+
once guarantee is unchanged in the safe direction: a real deletion is never
|
|
25
|
+
dropped.
|
|
26
|
+
|
|
27
|
+
This lets `yrby-actioncable` (and any caller gating `on_change` on
|
|
28
|
+
`update_advances?`) settle a duplicate pure-delete frame as `:applied` — acked,
|
|
29
|
+
but not stored or relayed — so apps no longer need an app-level
|
|
30
|
+
encode-and-compare guard around their durable writes.
|
|
31
|
+
|
|
32
|
+
## [0.2.2] - 2026-06-30
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `Doc#read_xml` now recovers text from **nested** Lexical/Lexxy blocks. Lexical
|
|
37
|
+
embeds child blocks (list items, table cells, nested lists) as `Y.XmlText`
|
|
38
|
+
embeds that `get_string` silently drops, so lists and tables previously came
|
|
39
|
+
back empty. `read_xml` now walks the embeds: text runs build a line, inline
|
|
40
|
+
children (links) join it, and nested block children flush and recurse — so a
|
|
41
|
+
document with headings, formatted text, links, bullet/numbered/check/nested
|
|
42
|
+
lists, blockquotes, code blocks and tables extracts every piece of text.
|
|
43
|
+
Lexical decorator elements (horizontal rule, image) are skipped instead of
|
|
44
|
+
emitting their `<UNDEFINED …>` serialization. ProseMirror handling is
|
|
45
|
+
unchanged.
|
|
46
|
+
|
|
9
47
|
## [0.2.1] - 2026-06-29
|
|
10
48
|
|
|
11
49
|
### Changed
|
|
@@ -15,15 +53,9 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
15
53
|
|
|
16
54
|
## [0.2.0] - 2026-06-28
|
|
17
55
|
|
|
18
|
-
First release
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
### Changed
|
|
23
|
-
- **Renamed `yrb-lite` → `yrby`.** Module `YrbLite` → top-level `Y`
|
|
24
|
-
(`Y::Doc`, `Y::Error`, `Y::VERSION`). Require path `require "yrb_lite"` →
|
|
25
|
-
`require "y"`. (The native extension crate shipped as `y_ruby` in 0.2.0; see
|
|
26
|
-
0.2.1 for its rename to `yrby`.)
|
|
56
|
+
First release. The public Ruby interface is the top-level module **`Y`**
|
|
57
|
+
(`Y::Doc`, `Y::Error`, `Y::VERSION`), loaded with `require "y"` — mirroring the
|
|
58
|
+
`y-rb` gem's `Y::Doc` interface.
|
|
27
59
|
|
|
28
60
|
### Added
|
|
29
61
|
- Native `Doc#read_text` and `Doc#read_map` readers — reconstruct plain text and
|
data/README.md
CHANGED
|
@@ -41,23 +41,25 @@ npm install yrby-client
|
|
|
41
41
|
one-include ActionCable concern.
|
|
42
42
|
- Authoritative record-before-distribute semantics: each document change can be
|
|
43
43
|
recorded durably before it goes out to anyone.
|
|
44
|
-
-
|
|
44
|
+
- Optional server-side reads: `Doc#read_text` and `Doc#read_map` reconstruct a
|
|
45
|
+
document's contents in Ruby - no Node process - for search, exports, validation,
|
|
46
|
+
or server-side rendering.
|
|
45
47
|
|
|
46
|
-
##
|
|
48
|
+
## Scope
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
`yrby` binds just the part of `y-crdt` you need to *sync and persist* collaborative
|
|
51
|
+
documents - a `Doc`, awareness, and the y-websocket protocol primitives. By default
|
|
52
|
+
the Ruby side treats a document as opaque CRDT state: it applies updates, answers
|
|
53
|
+
sync handshakes, and records deltas without reaching into the contents - the browser
|
|
54
|
+
editor owns the document's shape. When you do need to look inside, `Doc#read_text`
|
|
55
|
+
and `Doc#read_map` reconstruct it server-side, in Ruby.
|
|
54
56
|
|
|
55
|
-
##
|
|
57
|
+
## Durability and delivery
|
|
56
58
|
|
|
57
|
-
The surface
|
|
59
|
+
The surface is intentionally small, but the focus is durability, resiliency, delivery
|
|
58
60
|
guarantees, correctness, and thread safety.
|
|
59
61
|
|
|
60
|
-
Towards that goal, `yrby` adds capabilities that
|
|
62
|
+
Towards that goal, `yrby` adds capabilities that stand out even in the Yjs ecosystem:
|
|
61
63
|
|
|
62
64
|
- Built-in update acknowledgement: the `ActionCableProvider` in `yrby-client` will continue to
|
|
63
65
|
send updates until an ack is received from the server. [`yrby-actioncable`](https://rubygems.org/gems/yrby-actioncable)
|
data/ext/yrby/src/protocol.rs
CHANGED
|
@@ -77,26 +77,38 @@ pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, St
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/// True if applying `update_bytes` would actually change `doc`, i.e. it carries
|
|
80
|
-
/// content the doc doesn't already have.
|
|
81
|
-
/// effects exactly-once: a lost-ack retry
|
|
82
|
-
/// applied; that retry is causally ready
|
|
83
|
-
/// not re-run `on_change`.
|
|
80
|
+
/// content (an insert, a format, or a deletion) the doc doesn't already have.
|
|
81
|
+
/// This lets the server make durable side effects exactly-once: a lost-ack retry
|
|
82
|
+
/// re-sends an update the server already applied; that retry is causally ready
|
|
83
|
+
/// (so `update_is_ready` is true) but must not re-run `on_change`.
|
|
84
84
|
///
|
|
85
85
|
/// We can't read the update's own state vector to decide this: yrs reports an
|
|
86
86
|
/// empty state_vector() for a causally-pending diff (e.g. a resync delta whose
|
|
87
87
|
/// structs depend on updates the doc has but the standalone update doesn't),
|
|
88
|
-
/// which would look identical to a no-op. So measure the real effect
|
|
89
|
-
/// independent probe with the doc's current state
|
|
90
|
-
///
|
|
91
|
-
///
|
|
92
|
-
///
|
|
93
|
-
///
|
|
94
|
-
///
|
|
88
|
+
/// which would look identical to a no-op. So measure the real effect on an
|
|
89
|
+
/// independent probe seeded with the doc's current state (never mutating the real
|
|
90
|
+
/// doc), then compare the probe before and after applying the update:
|
|
91
|
+
///
|
|
92
|
+
/// - **Insert/format-only updates** grow the probe's state vector, so comparing
|
|
93
|
+
/// the state vector is enough — and cheaper than a full re-encode.
|
|
94
|
+
/// - **Delete-bearing updates** don't move the state vector (a deletion tombstones
|
|
95
|
+
/// an existing struct rather than adding one), so we compare the full encoded
|
|
96
|
+
/// state, which carries the delete set. An already-applied pure-delete retry
|
|
97
|
+
/// re-encodes byte-identically → false; a genuinely new deletion changes the
|
|
98
|
+
/// delete set → true. This is exact but pays for two full encodes, so only
|
|
99
|
+
/// delete-bearing frames — a minority — take that path.
|
|
100
|
+
///
|
|
101
|
+
/// Earlier this branch was conservative: any delete-bearing update returned true
|
|
102
|
+
/// (record it), which double-recorded and re-broadcast pure-delete retries the
|
|
103
|
+
/// server had already integrated. The exact comparison removes that duplication
|
|
104
|
+
/// while still never dropping a real deletion. Assumes the update is already
|
|
105
|
+
/// causally ready.
|
|
95
106
|
pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
|
|
96
107
|
let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
let has_deletes = !update.delete_set().is_empty();
|
|
109
|
+
|
|
110
|
+
// Seed an independent probe with the doc's current state so we can measure the
|
|
111
|
+
// update's effect without mutating the real doc.
|
|
100
112
|
let probe = Doc::new();
|
|
101
113
|
let current = doc
|
|
102
114
|
.transact()
|
|
@@ -105,13 +117,30 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
|
|
|
105
117
|
.transact_mut()
|
|
106
118
|
.apply_update(yrs::Update::decode_v1(¤t).map_err(|e| e.to_string())?)
|
|
107
119
|
.map_err(|e| e.to_string())?;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
|
|
121
|
+
if has_deletes {
|
|
122
|
+
// Deletes don't move the state vector; compare the full encoded state
|
|
123
|
+
// (which includes the delete set), before vs. after, on the same probe.
|
|
124
|
+
let before = probe
|
|
125
|
+
.transact()
|
|
126
|
+
.encode_state_as_update_v1(&yrs::StateVector::default());
|
|
127
|
+
probe
|
|
128
|
+
.transact_mut()
|
|
129
|
+
.apply_update(update)
|
|
130
|
+
.map_err(|e| e.to_string())?;
|
|
131
|
+
let after = probe
|
|
132
|
+
.transact()
|
|
133
|
+
.encode_state_as_update_v1(&yrs::StateVector::default());
|
|
134
|
+
Ok(before != after)
|
|
135
|
+
} else {
|
|
136
|
+
let before = probe.transact().state_vector();
|
|
137
|
+
probe
|
|
138
|
+
.transact_mut()
|
|
139
|
+
.apply_update(update)
|
|
140
|
+
.map_err(|e| e.to_string())?;
|
|
141
|
+
let after = probe.transact().state_vector();
|
|
142
|
+
Ok(before != after)
|
|
143
|
+
}
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
/// True if the doc holds pending structs or a pending delete set: blocks that
|
|
@@ -246,6 +275,87 @@ mod tests {
|
|
|
246
275
|
);
|
|
247
276
|
}
|
|
248
277
|
|
|
278
|
+
#[test]
|
|
279
|
+
fn update_advances_is_exact_for_pure_delete_retries() {
|
|
280
|
+
// Build "hello", snapshot the pre-delete content, then delete a char and
|
|
281
|
+
// capture just that deletion as a diff (only a delete set, no new structs).
|
|
282
|
+
let doc = Doc::new();
|
|
283
|
+
let text = doc.get_or_insert_text("content");
|
|
284
|
+
text.insert(&mut doc.transact_mut(), 0, "hello");
|
|
285
|
+
let content_state = doc
|
|
286
|
+
.transact()
|
|
287
|
+
.encode_state_as_update_v1(&yrs::StateVector::default());
|
|
288
|
+
let sv_before = doc.transact().state_vector();
|
|
289
|
+
text.remove_range(&mut doc.transact_mut(), 0, 1); // delete "h"
|
|
290
|
+
let delete = doc.transact().encode_state_as_update_v1(&sv_before);
|
|
291
|
+
|
|
292
|
+
assert!(
|
|
293
|
+
!yrs::Update::decode_v1(&delete)
|
|
294
|
+
.unwrap()
|
|
295
|
+
.delete_set()
|
|
296
|
+
.is_empty(),
|
|
297
|
+
"the diff carries a delete set"
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// A server holding the pre-delete content, but not the deletion yet.
|
|
301
|
+
let server = Doc::new();
|
|
302
|
+
server
|
|
303
|
+
.transact_mut()
|
|
304
|
+
.apply_update(yrs::Update::decode_v1(&content_state).unwrap())
|
|
305
|
+
.unwrap();
|
|
306
|
+
|
|
307
|
+
// The deletion is new: it advances (must be recorded).
|
|
308
|
+
assert!(
|
|
309
|
+
update_advances_doc(&server, &delete).unwrap(),
|
|
310
|
+
"a not-yet-applied deletion advances the doc"
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Apply it; now the byte-identical pure-delete retry must NOT advance.
|
|
314
|
+
// (This is the behavior change: it used to conservatively return true.)
|
|
315
|
+
server
|
|
316
|
+
.transact_mut()
|
|
317
|
+
.apply_update(yrs::Update::decode_v1(&delete).unwrap())
|
|
318
|
+
.unwrap();
|
|
319
|
+
assert!(
|
|
320
|
+
!update_advances_doc(&server, &delete).unwrap(),
|
|
321
|
+
"an already-applied pure-delete retry does not advance"
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#[test]
|
|
326
|
+
fn update_advances_for_a_delete_bundled_with_new_content() {
|
|
327
|
+
// A delete-bearing update that ALSO carries a new struct still advances,
|
|
328
|
+
// even after the pure-delete part would be a no-op on its own.
|
|
329
|
+
let doc = Doc::new();
|
|
330
|
+
let text = doc.get_or_insert_text("content");
|
|
331
|
+
text.insert(&mut doc.transact_mut(), 0, "hello");
|
|
332
|
+
let content_state = doc
|
|
333
|
+
.transact()
|
|
334
|
+
.encode_state_as_update_v1(&yrs::StateVector::default());
|
|
335
|
+
let sv_before = doc.transact().state_vector();
|
|
336
|
+
text.remove_range(&mut doc.transact_mut(), 0, 1); // delete "h"
|
|
337
|
+
text.insert(&mut doc.transact_mut(), 4, "!"); // and add "!"
|
|
338
|
+
let mixed = doc.transact().encode_state_as_update_v1(&sv_before);
|
|
339
|
+
|
|
340
|
+
let server = Doc::new();
|
|
341
|
+
server
|
|
342
|
+
.transact_mut()
|
|
343
|
+
.apply_update(yrs::Update::decode_v1(&content_state).unwrap())
|
|
344
|
+
.unwrap();
|
|
345
|
+
assert!(
|
|
346
|
+
update_advances_doc(&server, &mixed).unwrap(),
|
|
347
|
+
"an insert+delete update advances"
|
|
348
|
+
);
|
|
349
|
+
server
|
|
350
|
+
.transact_mut()
|
|
351
|
+
.apply_update(yrs::Update::decode_v1(&mixed).unwrap())
|
|
352
|
+
.unwrap();
|
|
353
|
+
assert!(
|
|
354
|
+
!update_advances_doc(&server, &mixed).unwrap(),
|
|
355
|
+
"its byte-identical retry does not advance"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
249
359
|
#[test]
|
|
250
360
|
fn merged_doc_update_extracts_and_skips_no_ops() {
|
|
251
361
|
// A document update yields a delta that reconstructs the content.
|
data/ext/yrby/src/read.rs
CHANGED
|
@@ -4,29 +4,99 @@
|
|
|
4
4
|
|
|
5
5
|
use std::collections::HashMap;
|
|
6
6
|
use std::sync::Arc;
|
|
7
|
-
use yrs::
|
|
7
|
+
use yrs::types::text::YChange;
|
|
8
|
+
use yrs::{
|
|
9
|
+
Any, Array, GetString, Map, MapRef, Out, ReadTxn, Text, Xml, XmlFragment, XmlFragmentRef,
|
|
10
|
+
XmlOut, XmlTextRef,
|
|
11
|
+
};
|
|
8
12
|
|
|
9
13
|
/// Read an XML-shaped root as text, one top-level block per line.
|
|
10
14
|
///
|
|
11
|
-
///
|
|
12
|
-
///
|
|
13
|
-
///
|
|
14
|
-
///
|
|
15
|
-
///
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
///
|
|
19
|
-
///
|
|
15
|
+
/// Two editors store their documents differently, and both are handled:
|
|
16
|
+
///
|
|
17
|
+
/// - **ProseMirror** (Tiptap) stores blocks as `Y.XmlElement` children
|
|
18
|
+
/// (`<paragraph>…`). `get_string` already recurses these (tags included; the
|
|
19
|
+
/// caller strips them), so we keep that path.
|
|
20
|
+
/// - **Lexical** (Lexxy) stores every node as a `Y.XmlText`, and nests child
|
|
21
|
+
/// blocks (list items, table cells, nested lists) as *embedded* `Y.XmlText`s —
|
|
22
|
+
/// which `get_string` silently omits, dropping all that content. So for a
|
|
23
|
+
/// Lexical block we walk its content (`Text::diff`) instead: text runs build a
|
|
24
|
+
/// line, inline children (links) join it, and nested block children flush the
|
|
25
|
+
/// line and recurse. Each leaf block becomes one line, so words never glue
|
|
26
|
+
/// across blocks and lists/tables come through intact.
|
|
20
27
|
pub fn xml_blocks_text<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> String {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
XmlOut::
|
|
25
|
-
XmlOut::
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
let mut out: Vec<String> = Vec::new();
|
|
29
|
+
for node in fragment.children(txn) {
|
|
30
|
+
match node {
|
|
31
|
+
XmlOut::Text(t) => walk_lexical_block(txn, &t, &mut out),
|
|
32
|
+
XmlOut::Element(e) => {
|
|
33
|
+
// ProseMirror blocks have a tag but no `__type`; get_string recurses
|
|
34
|
+
// them (tags kept, caller strips). A Lexical decorator (horizontal
|
|
35
|
+
// rule, image) is an XmlElement *with* a `__type` and no extractable
|
|
36
|
+
// text -- skip it rather than emit its `<UNDEFINED …>` serialization.
|
|
37
|
+
if e.get_attribute(txn, "__type").is_none() {
|
|
38
|
+
out.push(e.get_string(txn));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
XmlOut::Fragment(f) => out.push(f.get_string(txn)),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
out.join("\n")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Lexical node `__type`s whose text belongs on the surrounding line rather than
|
|
48
|
+
/// a new block (e.g. a link inside a paragraph). Everything else with embedded
|
|
49
|
+
/// child `Y.XmlText`s is treated as a block and recursed.
|
|
50
|
+
fn is_inline_lexical_type(ty: &str) -> bool {
|
|
51
|
+
matches!(
|
|
52
|
+
ty,
|
|
53
|
+
"text" | "link" | "autolink" | "linebreak" | "tab" | "hashtag" | "mark" | "overflow"
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// A Lexical node's `__type` (stored as an XML attribute on its `Y.XmlText`).
|
|
58
|
+
fn lexical_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
|
|
59
|
+
match t.get_attribute(txn, "__type") {
|
|
60
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
61
|
+
_ => String::new(),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Gather the text of an inline Lexical element (its text runs and any nested
|
|
66
|
+
/// inline elements) without introducing block breaks.
|
|
67
|
+
fn inline_lexical_text<T: ReadTxn>(txn: &T, t: &XmlTextRef, buf: &mut String) {
|
|
68
|
+
for d in t.diff(txn, YChange::identity) {
|
|
69
|
+
match d.insert {
|
|
70
|
+
Out::Any(Any::String(s)) => buf.push_str(&s),
|
|
71
|
+
Out::YXmlText(child) => inline_lexical_text(txn, &child, buf),
|
|
72
|
+
_ => {} // per-text-node metadata map, decorator embeds: no text
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Walk a Lexical block (`Y.XmlText`), pushing one line per leaf block. Text runs
|
|
78
|
+
/// accumulate; inline children join the line; block children flush it and recurse.
|
|
79
|
+
fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>) {
|
|
80
|
+
let mut line = String::new();
|
|
81
|
+
for d in t.diff(txn, YChange::identity) {
|
|
82
|
+
match d.insert {
|
|
83
|
+
Out::Any(Any::String(s)) => line.push_str(&s),
|
|
84
|
+
Out::YXmlText(child) => {
|
|
85
|
+
if is_inline_lexical_type(&lexical_type(txn, &child)) {
|
|
86
|
+
inline_lexical_text(txn, &child, &mut line);
|
|
87
|
+
} else {
|
|
88
|
+
if !line.is_empty() {
|
|
89
|
+
out.push(std::mem::take(&mut line));
|
|
90
|
+
}
|
|
91
|
+
walk_lexical_block(txn, &child, out);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
_ => {} // per-text-node metadata map; embeds we don't read for text
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if !line.is_empty() {
|
|
98
|
+
out.push(line);
|
|
99
|
+
}
|
|
30
100
|
}
|
|
31
101
|
|
|
32
102
|
/// Read a `Y.Map` root as a JSON object string (keys sorted for stable output).
|
|
@@ -185,4 +255,49 @@ mod tests {
|
|
|
185
255
|
let txn = doc.transact();
|
|
186
256
|
assert_eq!(map_json(&txn, &map), "{}");
|
|
187
257
|
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn lexical_complex_doc_extracts_all_nested_text() {
|
|
261
|
+
// A real Lexxy/Lexical doc with every block type: headings, formatted
|
|
262
|
+
// text, an inline link, bullet + NESTED bullet + numbered + check lists,
|
|
263
|
+
// a quote, a code block, a horizontal rule, and a table. Every piece of
|
|
264
|
+
// text -- including list items, the nested sub-list, and table cells --
|
|
265
|
+
// must come through (get_string alone dropped all the nested ones).
|
|
266
|
+
use yrs::updates::decoder::Decode;
|
|
267
|
+
use yrs::{Transact, Update};
|
|
268
|
+
let bytes = include_bytes!("fixtures/lexical_rich.bin");
|
|
269
|
+
let doc = Doc::new();
|
|
270
|
+
{
|
|
271
|
+
let mut txn = doc.transact_mut();
|
|
272
|
+
txn.apply_update(Update::decode_v1(bytes).unwrap()).unwrap();
|
|
273
|
+
}
|
|
274
|
+
let txn = doc.transact();
|
|
275
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
276
|
+
let text = xml_blocks_text(&txn, &frag);
|
|
277
|
+
for expected in [
|
|
278
|
+
"Heading One",
|
|
279
|
+
"Heading Two",
|
|
280
|
+
"Plain, bold, italic, strike, underline, and code.",
|
|
281
|
+
"Visit the website for more.", // link text stays inline
|
|
282
|
+
"First bullet",
|
|
283
|
+
"Second bullet",
|
|
284
|
+
"Nested A", // nested sub-list
|
|
285
|
+
"Nested B",
|
|
286
|
+
"Step one",
|
|
287
|
+
"Step two",
|
|
288
|
+
"Done item",
|
|
289
|
+
"Todo item",
|
|
290
|
+
"A blockquote about CRDTs.",
|
|
291
|
+
"const x = 1;", // code block (keeps its internal newline)
|
|
292
|
+
"console.log(x);",
|
|
293
|
+
"Name", // table header cells
|
|
294
|
+
"Role",
|
|
295
|
+
"Ada", // table body cells
|
|
296
|
+
"Engineer",
|
|
297
|
+
] {
|
|
298
|
+
assert!(text.contains(expected), "missing {expected:?} in:\n{text}");
|
|
299
|
+
}
|
|
300
|
+
// The inline link must NOT have been split onto its own line.
|
|
301
|
+
assert!(text.contains("Visit the website for more."));
|
|
302
|
+
}
|
|
188
303
|
}
|
data/lib/y/version.rb
CHANGED