diff --git a/Cargo.lock b/Cargo.lock index ab8aabb..8de1e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -155,6 +156,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "append-only-bytes" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" + [[package]] name = "ar_archive_writer" version = "0.2.0" @@ -164,6 +171,15 @@ dependencies = [ "object 0.32.2", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -176,6 +192,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" + [[package]] name = "ascii" version = "1.1.0" @@ -564,6 +586,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "block" version = "0.1.6" @@ -604,7 +635,7 @@ version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" dependencies = [ - "darling", + "darling 0.21.3", "ident_case", "prettyplease", "proc-macro2", @@ -1396,14 +1427,38 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.110", ] [[package]] @@ -1420,13 +1475,24 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.110", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.110", ] @@ -1533,6 +1599,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1656,6 +1733,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -2822,7 +2905,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck 0.5.0", "proc-macro2", @@ -2956,6 +3039,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "ensure-cov" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -2968,6 +3069,18 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -3004,7 +3117,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.110", @@ -3491,6 +3604,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-btree" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" +dependencies = [ + "arref", + "heapless 0.9.2", + "itertools 0.11.0", + "loro-thunderdome", + "proc-macro2", + "rustc-hash 2.1.1", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -3846,6 +3973,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3925,13 +4061,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin 0.9.8", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3971,7 +4127,7 @@ dependencies = [ "async-trait", "cfg-if", "data-encoding", - "enum-as-inner", + "enum-as-inner 0.6.1", "futures-channel", "futures-io", "futures-util", @@ -4330,6 +4486,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4455,6 +4626,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -4888,6 +5077,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + [[package]] name = "libappindicator" version = "0.9.0" @@ -5048,10 +5243,150 @@ dependencies = [ "cfg-if", "generator", "scoped-tls", + "serde", + "serde_json", "tracing", "tracing-subscriber", ] +[[package]] +name = "loro" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546b01ae9bb635853dc8beacfc74a7eb5d0b90cdfdb9c63175cdc1e319fae414" +dependencies = [ + "enum-as-inner 0.6.1", + "generic-btree", + "loro-common", + "loro-delta", + "loro-internal", + "loro-kv-store", + "rustc-hash 2.1.1", + "tracing", +] + +[[package]] +name = "loro-common" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354299916ca5b8b50a14d9b6bc04943477f5ac5ad6a1351a99eae25a4bb3fd8" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.1", + "leb128", + "loro-rle", + "nonmax", + "rustc-hash 2.1.1", + "serde", + "serde_columnar", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "loro-delta" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eafa788a72c1cbf0b7dc08a862cd7cc31b96d99c2ef749cdc94c2330f9494d3" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] + +[[package]] +name = "loro-internal" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824ea2d4a3bb4e9d95d145b6d539509d7988a12b9e5e54d20956722d719af17b" +dependencies = [ + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.1", + "enum_dispatch", + "generic-btree", + "getrandom 0.2.16", + "im", + "itertools 0.12.1", + "leb128", + "loom", + "loro-common", + "loro-delta", + "loro-kv-store", + "loro-rle", + "loro_fractional_index", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "parking_lot", + "pest", + "pest_derive", + "postcard", + "pretty_assertions", + "rand 0.8.5", + "rustc-hash 2.1.1", + "serde", + "serde_columnar", + "serde_json", + "smallvec", + "thiserror 1.0.69", + "thread_local", + "tracing", + "wasm-bindgen", + "xxhash-rust", +] + +[[package]] +name = "loro-kv-store" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "849d246b5791e97cf37f1bbd54ad7885f128b690ee3a634ee89cf52a1930fb88" +dependencies = [ + "bytes", + "ensure-cov", + "loro-common", + "lz4_flex", + "once_cell", + "quick_cache", + "rustc-hash 2.1.1", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "loro-rle" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" +dependencies = [ + "append-only-bytes", + "num", + "smallvec", +] + +[[package]] +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "loro_fractional_index" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c8ea186958094052b971fe7e322a934b034c3bf62f0458ccea04fcd687ba1" +dependencies = [ + "once_cell", + "rand 0.8.5", + "serde", +] + [[package]] name = "lru" version = "0.16.2" @@ -5076,6 +5411,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4_flex" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +dependencies = [ + "twox-hash", +] + [[package]] name = "mac" version = "0.1.1" @@ -5274,6 +5618,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "mediatype" version = "0.19.20" @@ -5672,6 +6022,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5681,6 +6037,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -5708,6 +6078,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -5734,6 +6113,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -6144,6 +6534,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pest_meta" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -6378,7 +6811,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", "serde", ] @@ -6412,6 +6845,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -6561,6 +7004,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -6741,6 +7196,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "range-traits" version = "0.3.2" @@ -7311,6 +7775,31 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_columnar" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" +dependencies = [ + "itertools 0.11.0", + "postcard", + "serde", + "serde_columnar_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_columnar_derive" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7469,7 +7958,7 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.110", @@ -7643,6 +8132,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.11" @@ -7693,6 +8192,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smartstring" @@ -8999,6 +9501,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typed-arena" version = "2.0.2" @@ -9011,6 +9519,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" @@ -9451,6 +9965,7 @@ dependencies = [ "js-sys", "jumprope", "lol_alloc", + "loro", "markdown-weaver", "markdown-weaver-escape", "mime-sniffer", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index a9f1155..c201ce8 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -50,6 +50,7 @@ serde_html_form = "0.2.8" tracing.workspace = true serde_ipld_dagcbor = { version = "0.6" } jumprope = { version = "1.1", features = ["wchar_conversion"] } +loro = "1.9.1" markdown-weaver-escape = { workspace = true } [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] @@ -65,7 +66,7 @@ mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d0 chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter"] } +web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList"] } js-sys = "0.3" gloo-storage = "0.3" gloo-timers = "0.3" diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index 567edb2..6eea5a0 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -88,26 +88,23 @@ opacity: 0.6; } -.md-syntax-inline:[hidden] { - color: var(--color-muted); - opacity: 0.6; - width: 0; - user-select: none; /* idk if we want this when its hidden or not */ -} - /* Markdown syntax characters - block level (#, >, -, etc) */ .md-syntax-block { color: var(--color-muted); opacity: 0.7; - width: 0; font-weight: normal; } -.md-syntax-block:[hidden] { - content: attr(data-syntax); - display: inline-block; - margin-right: 4px; - user-select: none; /* idk if we want this when its hidden or not */ +/* Hidden syntax spans - collapsed when cursor is not near */ +.md-syntax-inline.hidden, +.md-syntax-block.hidden { + display: none; +} + +/* Hide HTML list markers when markdown syntax is visible (not hidden) */ +/* This prevents double bullets/numbers when showing "- " or "1. " */ +li:has(.md-syntax-block:not(.hidden)) { + list-style-type: none; } /* Cursor positioning helper after
*/ @@ -119,6 +116,3 @@ line-height: 1em; vertical-align: baseline; } - -/* Future: contextual hiding based on cursor position */ -/* .cursor-active .md-syntax-inline { display: none; } */ diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index f29b741..615d9a1 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -13,6 +13,7 @@ mod render; mod rope_writer; mod storage; mod toolbar; +mod visibility; mod writer; #[cfg(test)] @@ -22,11 +23,12 @@ pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Sele pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; pub use paragraph::ParagraphRender; -pub use render::{RenderCache, render_paragraphs, render_paragraphs_incremental}; +pub use render::{RenderCache, render_paragraphs_incremental}; pub use rope_writer::RopeWriter; pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; pub use toolbar::EditorToolbar; -pub use writer::WriterResult; +pub use visibility::VisibilityState; +pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; use dioxus::prelude::*; @@ -91,6 +93,14 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { .collect::>() }); + // Flatten syntax spans from all paragraphs + let syntax_spans = use_memo(move || { + paragraphs() + .iter() + .flat_map(|p| p.syntax_spans.iter().cloned()) + .collect::>() + }); + // Cache paragraphs for change detection AND for event handlers to access let mut cached_paragraphs = use_signal(|| Vec::::new()); @@ -129,7 +139,17 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } // Store for next comparison AND for event handlers (write-only, no reactive read) - cached_paragraphs.set(new_paras); + cached_paragraphs.set(new_paras.clone()); + + // Update syntax visibility after DOM changes + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + &new_paras, + ); }); // Auto-save with debounce @@ -180,6 +200,15 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { if dominated { let paras = cached_paragraphs(); sync_cursor_from_dom(&mut document, editor_id, ¶s); + // Update syntax visibility after cursor sync + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); } }, @@ -187,6 +216,30 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // After mouse click, sync cursor from DOM let paras = cached_paragraphs(); sync_cursor_from_dom(&mut document, editor_id, ¶s); + // Update syntax visibility after cursor sync + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + onmouseup: move |_evt| { + // After drag selection, sync cursor/selection from DOM + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + // Update syntax visibility after cursor sync + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); }, onpaste: move |evt| { @@ -196,6 +249,10 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { oncut: move |evt| { handle_cut(evt, &mut document); }, + + oncopy: move |evt| { + handle_copy(evt, &document); + }, } @@ -409,6 +466,50 @@ fn sync_cursor_from_dom( // No-op on non-wasm } +/// Update syntax span visibility in the DOM based on cursor position. +/// +/// Toggles the "hidden" class on syntax spans based on calculated visibility. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn update_syntax_visibility( + cursor_offset: usize, + selection: Option<&Selection>, + syntax_spans: &[SyntaxSpanInfo], + paragraphs: &[ParagraphRender], +) { + let visibility = + visibility::VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); + + let Some(window) = web_sys::window() else { + return; + }; + let Some(document) = window.document() else { + return; + }; + + // Update each syntax span's visibility + for span in syntax_spans { + let selector = format!("[data-syn-id='{}']", span.syn_id); + if let Ok(Some(element)) = document.query_selector(&selector) { + let class_list = element.class_list(); + if visibility.is_visible(&span.syn_id) { + let _ = class_list.remove_1("hidden"); + } else { + let _ = class_list.add_1("hidden"); + } + } + } +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +fn update_syntax_visibility( + _cursor_offset: usize, + _selection: Option<&Selection>, + _syntax_spans: &[SyntaxSpanInfo], + _paragraphs: &[ParagraphRender], +) { + // No-op on non-wasm +} + /// Handle paste events and insert text at cursor fn handle_paste(evt: Event, document: &mut Signal) { tracing::info!("[PASTE] handle_paste called"); @@ -444,22 +545,109 @@ fn handle_paste(evt: Event, document: &mut Signal } } -/// Handle cut events - browser copies selection, we delete it from rope -/// Selection is synced via onkeyup/onclick, so doc.selection should be current -fn handle_cut(_evt: Event, document: &mut Signal) { +/// Handle cut events - extract text, write to clipboard, then delete from rope +fn handle_cut(evt: Event, document: &mut Signal) { tracing::info!("[CUT] handle_cut called"); - document.with_mut(|doc| { - if let Some(sel) = doc.selection { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - tracing::info!("[CUT] Deleting selection {}..{}", start, end); - doc.rope.remove(start..end); - doc.cursor.offset = start; - doc.selection = None; + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + document.with_mut(|doc| { + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + if start != end { + // Extract text from rope + let selected_text = extract_rope_slice(&doc.rope, start, end); + tracing::info!( + "[CUT] Extracted {} chars: {:?}", + selected_text.len(), + &selected_text[..selected_text.len().min(50)] + ); + + // Write to clipboard BEFORE deleting + if let Some(data_transfer) = clipboard_evt.clipboard_data() { + if let Err(e) = data_transfer.set_data("text/plain", &selected_text) { + tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); + } + } + + // Now delete from rope + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } + } + }); + } + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let _ = evt; // suppress unused warning + } +} + +/// Handle copy events - extract text from rope, clean it up, write to clipboard +fn handle_copy(evt: Event, document: &Signal) { + tracing::info!("[COPY] handle_copy called"); + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + let doc = document.read(); + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + if start != end { + // Extract text from rope + let selected_text = extract_rope_slice(&doc.rope, start, end); + + // Strip zero-width chars used for gap handling + let clean_text = selected_text + .replace('\u{200C}', "") + .replace('\u{200B}', ""); + + tracing::info!( + "[COPY] Extracted {} chars (cleaned to {})", + selected_text.len(), + clean_text.len() + ); + + // Write to clipboard + if let Some(data_transfer) = clipboard_evt.clipboard_data() { + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { + tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); + } + } + + // Prevent browser's default copy (which would copy rendered HTML) + evt.prevent_default(); + } } } - }); + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let _ = (evt, document); // suppress unused warnings + } +} + +/// Extract a slice of text from the rope as a String +fn extract_rope_slice(rope: &jumprope::JumpRopeBuf, start: usize, end: usize) -> String { + let mut result = String::new(); + let rope_ref = rope.borrow(); + for substr in rope_ref.slice_substrings(start..end) { + result.push_str(substr); + } + result } /// Handle keyboard events and update document state @@ -589,8 +777,48 @@ fn handle_keydown(evt: Event, document: &mut Signal { + format!("\n{}{} ", indent, marker) + } + ListContext::Ordered { indent, number } => { + format!("\n{}{}. ", indent, number + 1) + } + }; + let len = continuation.chars().count(); + doc.rope.insert(doc.cursor.offset, &continuation); + doc.cursor.offset += len; + } } else { - // Enter: paragraph break + // Not in a list - normal paragraph break doc.rope.insert(doc.cursor.offset, "\n\n"); doc.cursor.offset += 2; } @@ -606,6 +834,107 @@ fn handle_keydown(evt: Event, document: &mut Signal Option { + // Find start of current line + let line_start = find_line_start(rope, cursor_offset); + + // Get the line content from start to cursor + let line_end = find_line_end(rope, cursor_offset); + if line_start >= line_end { + return None; + } + + // Extract line text + let mut line = String::new(); + let rope_ref = rope.borrow(); + for substr in rope_ref.slice_substrings(line_start..line_end) { + line.push_str(substr); + } + + // Parse indentation + let indent: String = line + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + let trimmed = &line[indent.len()..]; + + // Check for unordered list marker: "- " or "* " + if trimmed.starts_with("- ") { + return Some(ListContext::Unordered { + indent, + marker: '-', + }); + } + if trimmed.starts_with("* ") { + return Some(ListContext::Unordered { + indent, + marker: '*', + }); + } + + // Check for ordered list marker: "1. ", "2. ", "123. ", etc. + if let Some(dot_pos) = trimmed.find(". ") { + let num_part = &trimmed[..dot_pos]; + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { + if let Ok(number) = num_part.parse::() { + return Some(ListContext::Ordered { indent, number }); + } + } + } + + None +} + +/// Check if the current list item is empty (just the marker, no content after cursor). +/// +/// Used to determine whether Enter should continue the list or exit it. +fn is_list_item_empty( + rope: &jumprope::JumpRopeBuf, + cursor_offset: usize, + ctx: &ListContext, +) -> bool { + let line_start = find_line_start(rope, cursor_offset); + let line_end = find_line_end(rope, cursor_offset); + + // Get line content + let mut line = String::new(); + let rope_ref = rope.borrow(); + for substr in rope_ref.slice_substrings(line_start..line_end) { + line.push_str(substr); + } + + // Calculate expected marker length + let marker_len = match ctx { + ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " + ListContext::Ordered { indent, number } => { + indent.len() + number.to_string().len() + 2 // "1. " + } + }; + + tracing::debug!( + "[LIST] is_empty check: line={:?}, line.len()={}, marker_len={}, result={}", + line, + line.len(), + marker_len, + line.len() <= marker_len + ); + + // Item is empty if line length equals marker length (nothing after marker) + line.len() <= marker_len +} + /// Get character at the given offset in the rope fn get_char_at(rope: &jumprope::JumpRopeBuf, offset: usize) -> Option { if offset >= rope.len_chars() { diff --git a/crates/weaver-app/src/components/editor/offset_map.rs b/crates/weaver-app/src/components/editor/offset_map.rs index 3422081..d228e69 100644 --- a/crates/weaver-app/src/components/editor/offset_map.rs +++ b/crates/weaver-app/src/components/editor/offset_map.rs @@ -122,24 +122,34 @@ pub fn find_mapping_for_char( // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), // prefer the NEXT mapping so cursor goes "down" to new content. - let idx = offset_map - .binary_search_by(|mapping| { - if mapping.char_range.end <= char_offset { - // Cursor is at or after end of this mapping - look forward - std::cmp::Ordering::Less - } else if mapping.char_range.start > char_offset { - // Cursor is before this mapping - std::cmp::Ordering::Greater + let result = offset_map.binary_search_by(|mapping| { + if mapping.char_range.end <= char_offset { + // Cursor is at or after end of this mapping - look forward + std::cmp::Ordering::Less + } else if mapping.char_range.start > char_offset { + // Cursor is before this mapping + std::cmp::Ordering::Greater + } else { + // Cursor is within [start, end) + std::cmp::Ordering::Equal + } + }); + + let mapping = match result { + Ok(idx) => &offset_map[idx], + Err(idx) => { + // No exact match - cursor is at boundary between mappings (or past end) + // If cursor is exactly at end of previous mapping, return that mapping + // This handles cursor at end of document or end of last mapping + if idx > 0 && offset_map[idx - 1].char_range.end == char_offset { + &offset_map[idx - 1] } else { - // Cursor is within [start, end) - std::cmp::Ordering::Equal + return None; } - }) - .ok()?; + } + }; - let mapping = &offset_map[idx]; let should_snap = mapping.is_invisible(); - Some((mapping, should_snap)) } diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs index 4f5921f..dceafeb 100644 --- a/crates/weaver-app/src/components/editor/paragraph.rs +++ b/crates/weaver-app/src/components/editor/paragraph.rs @@ -4,6 +4,7 @@ //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. use super::offset_map::OffsetMapping; +use super::writer::SyntaxSpanInfo; use jumprope::JumpRopeBuf; use std::ops::Range; @@ -22,6 +23,9 @@ pub struct ParagraphRender { /// Offset mappings for this paragraph pub offset_map: Vec, + /// Syntax spans for conditional visibility + pub syntax_spans: Vec, + /// Hash of source text for quick change detection pub source_hash: u64, } diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index fb04a33..52a7fb6 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -7,7 +7,7 @@ use super::document::EditInfo; use super::offset_map::{OffsetMapping, RenderResult}; use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; -use super::writer::EditorWriter; +use super::writer::{EditorWriter, SyntaxSpanInfo}; use jumprope::JumpRopeBuf; use markdown_weaver::Parser; use std::ops::Range; @@ -20,6 +20,8 @@ pub struct RenderCache { pub paragraphs: Vec, /// Next available node ID for fresh renders pub next_node_id: usize, + /// Next available syntax span ID for fresh renders + pub next_syn_id: usize, } /// A cached paragraph render that can be reused if source hasn't changed. @@ -35,199 +37,8 @@ pub struct CachedParagraph { pub html: String, /// Offset mappings for cursor positioning pub offset_map: Vec, -} - -/// Render markdown in paragraph chunks for incremental DOM updates. -/// -/// First renders the whole document to discover paragraph boundaries via -/// markdown events (Tag::Paragraph), then re-renders each paragraph separately. -/// This allows updating only changed paragraphs in the DOM, preserving cursor -/// position naturally. -/// -/// # Returns -/// -/// A vector of `ParagraphRender` structs, each containing: -/// - Source byte and char ranges -/// - Rendered HTML (without wrapper div) -/// - Offset mappings for that paragraph -/// - Source hash for change detection -/// -/// # Phase 2 Benefits -/// - Only re-render changed paragraphs -/// - Browser preserves cursor in unchanged paragraphs naturally -/// - Faster for large documents -/// - No manual cursor restoration needed for most edits -pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { - let source = rope.to_string(); - - // Handle empty rope - return single empty paragraph for cursor positioning - if source.is_empty() { - let empty_node_id = "n0".to_string(); - let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); - - return vec![ParagraphRender { - byte_range: 0..0, - char_range: 0..0, - html: empty_html, - offset_map: vec![], - source_hash: 0, - }]; - } - - // First pass: render whole document to get paragraph boundaries - // TODO: CACHE THIS! - let parser = Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); - let mut scratch_output = String::new(); - - let paragraph_ranges = - match EditorWriter::<_, _, ()>::new(&source, rope, parser, &mut scratch_output).run() { - Ok(result) => result.paragraph_ranges, - Err(_) => return Vec::new(), - }; - - // Second pass: render each paragraph separately - let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); - let mut node_id_offset = 0; // Track total nodes used so far for unique IDs - - for (_idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { - // Extract paragraph source - let para_source = rope_slice_to_string(rope, char_range.clone()); - let source_hash = hash_source(¶_source); - - // Render this paragraph with unique node IDs - let para_rope = JumpRopeBuf::from(para_source.as_str()); - let parser = - Parser::new_ext(¶_source, weaver_renderer::default_md_options()).into_offset_iter(); - let mut output = String::new(); - - let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( - ¶_source, - ¶_rope, - parser, - &mut output, - node_id_offset, - ) - .run() - { - Ok(result) => { - // Update node ID offset for next paragraph - // Count how many unique node IDs were used in this paragraph - let max_node_id = result - .offset_maps - .iter() - .filter_map(|m| { - m.node_id - .strip_prefix("n") - .and_then(|s| s.parse::().ok()) - }) - .max() - .unwrap_or(node_id_offset); - node_id_offset = max_node_id + 1; - - result.offset_maps - } - Err(_) => Vec::new(), - }; - - // Adjust offset map to be relative to document, not paragraph - // Each mapping's ranges need to be shifted by paragraph start - let para_char_start = char_range.start; - let para_byte_start = byte_range.start; - - for mapping in &mut offset_map { - mapping.byte_range.start += para_byte_start; - mapping.byte_range.end += para_byte_start; - mapping.char_range.start += para_char_start; - mapping.char_range.end += para_char_start; - } - - paragraphs.push(ParagraphRender { - byte_range: byte_range.clone(), - char_range: char_range.clone(), - html: output, - offset_map, - source_hash, - }); - } - - // Insert gap paragraphs for EXTRA whitespace between blocks. - // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. - // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. - // Gap IDs are position-based for stability across renders. - const MIN_PARAGRAPH_BREAK: usize = 2; // \n\n - - let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); - let mut prev_end_char = 0usize; - let mut prev_end_byte = 0usize; - - for para in paragraphs { - // Check for gap before this paragraph - only if MORE than minimum break - let gap_size = para.char_range.start.saturating_sub(prev_end_char); - if gap_size > MIN_PARAGRAPH_BREAK { - // Gap covers the EXTRA whitespace beyond the minimum break - let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK; - let gap_end_char = para.char_range.start; - let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK; - let gap_end_byte = para.byte_range.start; - - // Position-based ID: deterministic, stable across cache states - let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); - let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); - - paragraphs_with_gaps.push(ParagraphRender { - byte_range: gap_start_byte..gap_end_byte, - char_range: gap_start_char..gap_end_char, - html: gap_html, - offset_map: vec![OffsetMapping { - byte_range: gap_start_byte..gap_end_byte, - char_range: gap_start_char..gap_end_char, - node_id: gap_node_id, - char_offset_in_node: 0, - child_index: None, - utf16_len: 1, // zero-width space represents the gap - }], - source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), - }); - } - - prev_end_char = para.char_range.end; - prev_end_byte = para.byte_range.end; - paragraphs_with_gaps.push(para); - } - - // Check if rope ends with trailing newlines (empty paragraph at end) - // If so, add an empty paragraph div for cursor positioning - let source = rope.to_string(); - let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); - - if has_trailing_newlines { - let doc_end_char = rope.len_chars(); - let doc_end_byte = rope.len_bytes(); - - // Only add if there's actually a gap at the end - if doc_end_char > prev_end_char { - // Position-based ID for trailing gap - let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); - let trailing_html = format!(r#"{}"#, trailing_node_id, '\u{200B}'); - - paragraphs_with_gaps.push(ParagraphRender { - byte_range: prev_end_byte..doc_end_byte, - char_range: prev_end_char..doc_end_char, - html: trailing_html, - offset_map: vec![OffsetMapping { - byte_range: prev_end_byte..doc_end_byte, - char_range: prev_end_char..doc_end_char, - node_id: trailing_node_id, - char_offset_in_node: 0, - child_index: None, - utf16_len: 1, // zero-width space is 1 UTF-16 code unit - }], - source_hash: 0, // always render this paragraph - }); - } - } - - paragraphs_with_gaps + /// Syntax spans for conditional visibility + pub syntax_spans: Vec, } /// Check if an edit affects paragraph boundaries. @@ -263,6 +74,12 @@ fn adjust_paragraph_positions( mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; } + let mut adjusted_syntax = cached.syntax_spans.clone(); + for span in &mut adjusted_syntax { + span.char_range.start = (span.char_range.start as isize + char_delta) as usize; + span.char_range.end = (span.char_range.end as isize + char_delta) as usize; + } + ParagraphRender { byte_range: (cached.byte_range.start as isize + byte_delta) as usize ..(cached.byte_range.end as isize + byte_delta) as usize, @@ -270,6 +87,7 @@ fn adjust_paragraph_positions( ..(cached.char_range.end as isize + char_delta) as usize, html: cached.html.clone(), offset_map: adjusted_map, + syntax_spans: adjusted_syntax, source_hash: cached.source_hash, } } @@ -303,6 +121,7 @@ pub fn render_paragraphs_incremental( char_range: 0..0, html: empty_html.clone(), offset_map: vec![], + syntax_spans: vec![], source_hash: 0, }; @@ -313,8 +132,10 @@ pub fn render_paragraphs_incremental( char_range: 0..0, html: empty_html, offset_map: vec![], + syntax_spans: vec![], }], next_node_id: 1, + next_syn_id: 0, }; return (vec![para], new_cache); @@ -380,21 +201,34 @@ pub fn render_paragraphs_incremental( } }; + tracing::debug!("[RENDER] Discovered {} paragraph ranges", paragraph_ranges.len()); + for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { + tracing::debug!("[RENDER] Range {}: bytes {:?}, chars {:?}", i, byte_range, char_range); + } + // Render paragraphs, reusing cache where possible let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); + let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); - for (byte_range, char_range) in paragraph_ranges.iter() { + for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { let para_source = rope_slice_to_string(rope, char_range.clone()); let source_hash = hash_source(¶_source); + tracing::debug!( + "[RENDER] Para {}: char_range {:?}, source preview: {:?}", + idx, + char_range, + ¶_source[..para_source.len().min(50)] + ); + // Check if we have a cached render with matching hash let cached_match = cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); - let (html, offset_map) = if let Some(cached) = cached_match { - // Reuse cached HTML and offset map (adjusted for position) + let (html, offset_map, syntax_spans) = if let Some(cached) = cached_match { + // Reuse cached HTML, offset map, and syntax spans (adjusted for position) let char_delta = char_range.start as isize - cached.char_range.start as isize; let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; @@ -408,7 +242,13 @@ pub fn render_paragraphs_incremental( mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; } - (cached.html.clone(), adjusted_map) + let mut adjusted_syntax = cached.syntax_spans.clone(); + for span in &mut adjusted_syntax { + span.char_range.start = (span.char_range.start as isize + char_delta) as usize; + span.char_range.end = (span.char_range.end as isize + char_delta) as usize; + } + + (cached.html.clone(), adjusted_map, adjusted_syntax) } else { // Fresh render needed let para_rope = JumpRopeBuf::from(para_source.as_str()); @@ -416,32 +256,48 @@ pub fn render_paragraphs_incremental( .into_offset_iter(); let mut output = String::new(); - let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( - ¶_source, - ¶_rope, - parser, - &mut output, - node_id_offset, - ) - .run() - { - Ok(result) => { - // Update node ID offset - let max_node_id = result - .offset_maps - .iter() - .filter_map(|m| { - m.node_id - .strip_prefix("n") - .and_then(|s| s.parse::().ok()) - }) - .max() - .unwrap_or(node_id_offset); - node_id_offset = max_node_id + 1; - result.offset_maps - } - Err(_) => Vec::new(), - }; + let (mut offset_map, mut syntax_spans) = + match EditorWriter::<_, _, ()>::new_with_offsets( + ¶_source, + ¶_rope, + parser, + &mut output, + node_id_offset, + syn_id_offset, + ) + .run() + { + Ok(result) => { + // Update node ID offset + let max_node_id = result + .offset_maps + .iter() + .filter_map(|m| { + m.node_id + .strip_prefix("n") + .and_then(|s| s.parse::().ok()) + }) + .max() + .unwrap_or(node_id_offset); + node_id_offset = max_node_id + 1; + + // Update syn ID offset + let max_syn_id = result + .syntax_spans + .iter() + .filter_map(|s| { + s.syn_id + .strip_prefix("s") + .and_then(|id| id.parse::().ok()) + }) + .max() + .unwrap_or(syn_id_offset.saturating_sub(1)); + syn_id_offset = max_syn_id + 1; + + (result.offset_maps, result.syntax_spans) + } + Err(_) => (Vec::new(), Vec::new()), + }; // Adjust offsets to document coordinates let para_char_start = char_range.start; @@ -452,8 +308,12 @@ pub fn render_paragraphs_incremental( mapping.char_range.start += para_char_start; mapping.char_range.end += para_char_start; } + for span in &mut syntax_spans { + span.char_range.start += para_char_start; + span.char_range.end += para_char_start; + } - (output, offset_map) + (output, offset_map, syntax_spans) }; // Store in cache @@ -463,6 +323,7 @@ pub fn render_paragraphs_incremental( char_range: char_range.clone(), html: html.clone(), offset_map: offset_map.clone(), + syntax_spans: syntax_spans.clone(), }); paragraphs.push(ParagraphRender { @@ -470,6 +331,7 @@ pub fn render_paragraphs_incremental( char_range: char_range.clone(), html, offset_map, + syntax_spans, source_hash, }); } @@ -487,7 +349,7 @@ pub fn render_paragraphs_incremental( // Check for gap before this paragraph - only if MORE than minimum break let gap_size = para.char_range.start.saturating_sub(prev_end_char); if gap_size > MIN_PARAGRAPH_BREAK_INCR { - // Gap covers the EXTRA whitespace beyond the minimum break + // Visible gap element covers EXTRA whitespace beyond minimum break let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; let gap_end_char = para.char_range.start; let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; @@ -497,18 +359,21 @@ pub fn render_paragraphs_incremental( let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); + // Gap paragraph covers ALL whitespace (like trailing gaps do) + // so cursor anywhere in the inter-paragraph zone triggers restoration paragraphs_with_gaps.push(ParagraphRender { - byte_range: gap_start_byte..gap_end_byte, - char_range: gap_start_char..gap_end_char, + byte_range: prev_end_byte..gap_end_byte, + char_range: prev_end_char..gap_end_char, html: gap_html, offset_map: vec![OffsetMapping { - byte_range: gap_start_byte..gap_end_byte, - char_range: gap_start_char..gap_end_char, + byte_range: prev_end_byte..gap_end_byte, + char_range: prev_end_char..gap_end_char, node_id: gap_node_id, char_offset_in_node: 0, child_index: None, utf16_len: 1, }], + syntax_spans: vec![], source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), }); } @@ -541,6 +406,7 @@ pub fn render_paragraphs_incremental( child_index: None, utf16_len: 1, }], + syntax_spans: vec![], source_hash: 0, }); } @@ -549,6 +415,7 @@ pub fn render_paragraphs_incremental( let new_cache = RenderCache { paragraphs: new_cached, next_node_id: node_id_offset, + next_syn_id: syn_id_offset, }; (paragraphs_with_gaps, new_cache) diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap index 3ceb2cb..f657a48 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "
\n

> This is a quote\n

\n
\n" + html: "
\n

> This is a quote\n

\n
\n" offset_map: - byte_range: - 43 @@ -52,18 +52,18 @@ expression: result utf16_len: 1 source_hash: 11268153476336590706 - byte_range: - - 20 + - 18 - 22 char_range: - - 20 + - 18 - 22 html: "โ€‹" offset_map: - byte_range: - - 20 + - 18 - 22 char_range: - - 20 + - 18 - 22 node_id: gap-20-22 char_offset_in_node: 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap index e818ceb..3b40ef4 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "

Some **bold** text

\n" + html: "

Some **bold** text

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap index c1ea510..b57b724 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 27 - html: "

Some ***bold italic*** text

\n" + html: "

Some ***bold italic*** text

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap index 4878bc0..69acb81 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 24 - html: "```rust\n
fn main() {}\n
```" + html: "```rust\n
fn main() {}\n
```" offset_map: - byte_range: - 8 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap index f84f3a6..bb7fb5d 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 10 - html: "

# Heading\n

\n" + html: "

# Heading\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap index 13d194d..5ab6180 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 19 - html: "

Line one
โ€‹Line two

\n" + html: "

Line one
โ€‹Line two

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap index c746d96..9b9566b 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 11 - html: "

# Heading 1

\n" + html: "

# Heading 1

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap index b2b949e..a21aca8 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 5 - html: "

# H1\n

\n" + html: "

# H1\n

\n" offset_map: - byte_range: - 0 @@ -57,7 +57,7 @@ expression: result char_range: - 6 - 12 - html: "

## H2\n

\n" + html: "

## H2\n

\n" offset_map: - byte_range: - 6 @@ -106,7 +106,7 @@ expression: result char_range: - 13 - 20 - html: "

### H3\n

\n" + html: "

### H3\n

\n" offset_map: - byte_range: - 13 @@ -155,7 +155,7 @@ expression: result char_range: - 21 - 28 - html: "

#### H4

\n" + html: "

#### H4

\n" offset_map: - byte_range: - 21 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap index 7758c5d..577c385 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 16 - html: "

Some `code` here

\n" + html: "

Some `code` here

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap index 91fafa2..5419936 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "

Some *italic* text

\n" + html: "

Some *italic* text

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap index f7fdcf4..0748f98 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 6 - html: "

First\n

\n" + html: "

First\n

\n" offset_map: - byte_range: - 0 @@ -42,18 +42,18 @@ expression: result utf16_len: 1 source_hash: 6799294212516041738 - byte_range: - - 8 + - 6 - 9 char_range: - - 8 + - 6 - 9 html: "โ€‹" offset_map: - byte_range: - - 8 + - 6 - 9 char_range: - - 8 + - 6 - 9 node_id: gap-8-9 char_offset_in_node: 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap index b23a9f3..9040222 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 32 - html: "

**Bold** and *italic* and `code`

\n" + html: "

**Bold** and *italic* and `code`

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap index c467385..d1b205a 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap @@ -3,18 +3,18 @@ source: crates/weaver-app/src/components/editor/tests.rs expression: result --- - byte_range: - - 2 + - 0 - 11 char_range: - - 2 + - 0 - 11 html: "โ€‹" offset_map: - byte_range: - - 2 + - 0 - 11 char_range: - - 2 + - 0 - 11 node_id: gap-2-11 char_offset_in_node: 0 @@ -27,7 +27,7 @@ expression: result char_range: - 11 - 33 - html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" + html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" offset_map: - byte_range: - 11 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap index 9f22a40..a2fa11f 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 27 - html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" + html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap index fdd9b5b..4e177ed 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 5 - html: "

One.\n

\n" + html: "

One.\n

\n" offset_map: - byte_range: - 0 @@ -47,7 +47,7 @@ expression: result char_range: - 6 - 11 - html: "

Two.\n

\n" + html: "

Two.\n

\n" offset_map: - byte_range: - 6 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap index b89dc00..be54f3b 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 6 - html: "

Hello\n

\n" + html: "

Hello\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap index 122cd50..e306b98 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 6 - html: "

Hello\n

\n" + html: "

Hello\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap index 53b527f..c1bd773 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 17 - html: "

First paragraph.\n

\n" + html: "

First paragraph.\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap index 8500571..437c33c 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 26 - html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" + html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index b403367..07e32ac 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -2,7 +2,7 @@ use super::offset_map::{OffsetMapping, find_mapping_for_char}; use super::paragraph::ParagraphRender; -use super::render::render_paragraphs; +use super::render::render_paragraphs_incremental; use jumprope::JumpRopeBuf; use serde::Serialize; @@ -55,7 +55,7 @@ impl From<&OffsetMapping> for TestOffsetMapping { /// Helper: render markdown and convert to serializable test output. fn render_test(input: &str) -> Vec { let rope = JumpRopeBuf::from(input); - let paragraphs = render_paragraphs(&rope); + let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); paragraphs.iter().map(TestParagraph::from).collect() } @@ -410,22 +410,17 @@ fn regression_bug9_code_blocks_as_paragraph_boundary() { #[test] fn regression_bug11_gap_paragraphs_for_whitespace() { - // Bug #11: Gap paragraphs should be created for inter-block whitespace - let result = render_test("# Title\n\nContent"); - - // Check that char ranges cover the full document without gaps - let mut prev_end = 0; - for para in &result { - // Allow gaps to be filled by gap paragraphs - if para.char_range.0 > prev_end { - // This would be a gap - but gap paragraphs should fill it - panic!( - "Gap in char ranges: {}..{} missing coverage", - prev_end, para.char_range.0 - ); - } - prev_end = para.char_range.1; - } + // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace + // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK + + // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) + let result = render_test("# Title\n\n\n\nContent"); // 4 newlines + assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); + assert!(result[1].html.contains("gap-"), "Middle element should be a gap"); + + // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) + let result2 = render_test("# Title\n\n\nContent"); // 3 newlines + assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent"); } // ============================================================================= @@ -433,36 +428,46 @@ fn regression_bug11_gap_paragraphs_for_whitespace() { // ============================================================================= #[test] -fn test_char_range_full_coverage() { - // Verify that char ranges cover entire document +fn test_char_range_coverage_allows_paragraph_breaks() { + // Verify char ranges cover document content, allowing standard \n\n breaks + // The MIN_PARAGRAPH_BREAK zone (2 chars) is intentionally not covered - + // cursor snaps to adjacent paragraphs for standard breaks. + // Only EXTRA whitespace beyond \n\n gets gap elements. let input = "Hello\n\nWorld"; let rope = JumpRopeBuf::from(input); - let paragraphs = render_paragraphs(&rope); - - let doc_len = rope.len_chars(); - - // Collect all ranges - let mut ranges: Vec<_> = paragraphs.iter().map(|p| p.char_range.clone()).collect(); - ranges.sort_by_key(|r| r.start); - - // Check coverage - let mut covered = 0; - for range in &ranges { - assert!( - range.start <= covered, - "Gap at position {}, next range starts at {}", - covered, - range.start - ); - covered = covered.max(range.end); - } + let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); - assert!( - covered >= doc_len, - "Ranges don't cover full document: covered {} of {}", - covered, - doc_len - ); + // With standard \n\n break, we expect 2 paragraphs (no gap element) + // Paragraph ranges include some trailing whitespace from markdown parsing + assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break"); + + // First paragraph ends before second starts, with gap for \n\n + let gap_start = paragraphs[0].char_range.end; + let gap_end = paragraphs[1].char_range.start; + let gap_size = gap_end - gap_start; + assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size); +} + +#[test] +fn test_char_range_coverage_with_extra_whitespace() { + // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements + // Plain paragraphs don't consume trailing newlines like headings do + let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 + let rope = JumpRopeBuf::from(input); + let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); + + // With extra newlines, we expect 3 elements: para, gap, para + assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace"); + + // Gap element should exist and cover whitespace zone + let gap = ¶graphs[1]; + assert!(gap.html.contains("gap-"), "Second element should be a gap"); + + // Gap should cover ALL whitespace (not just extra) + assert_eq!(gap.char_range.start, paragraphs[0].char_range.end, + "Gap should start where first paragraph ends"); + assert_eq!(gap.char_range.end, paragraphs[2].char_range.start, + "Gap should end where second paragraph starts"); } #[test] @@ -533,45 +538,6 @@ fn test_offset_mappings_reference_own_paragraph() { // Incremental Rendering Tests // ============================================================================= -use super::render::render_paragraphs_incremental; - -#[test] -fn test_incremental_renders_same_as_full() { - // Incremental render with no cache should produce same result as full render - let input = "# Heading\n\nParagraph with **bold**\n\n- List item"; - let rope = JumpRopeBuf::from(input); - - let full = render_paragraphs(&rope); - let (incremental, _cache) = render_paragraphs_incremental(&rope, None, None); - - // Compare HTML output (hashes may differ due to caching internals) - assert_eq!( - full.len(), - incremental.len(), - "Different paragraph count: full={}, incr={}", - full.len(), - incremental.len() - ); - - for (i, (f, inc)) in full.iter().zip(incremental.iter()).enumerate() { - assert_eq!( - f.html, inc.html, - "Paragraph {} HTML differs:\nFull: {}\nIncr: {}", - i, f.html, inc.html - ); - assert_eq!( - f.byte_range, inc.byte_range, - "Paragraph {} byte_range differs", - i - ); - assert_eq!( - f.char_range, inc.char_range, - "Paragraph {} char_range differs", - i - ); - } -} - #[test] fn test_incremental_cache_reuse() { // Verify cache is populated and can be reused @@ -590,3 +556,157 @@ fn test_incremental_cache_reuse() { assert_eq!(p1.html, p2.html); } } + +// ============================================================================= +// Loro CRDT API Spike Tests +// ============================================================================= + +#[test] +fn test_loro_basic_text_operations() { + use loro::LoroDoc; + + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + + // Insert + text.insert(0, "Hello").unwrap(); + assert_eq!(text.to_string(), "Hello"); + assert_eq!(text.len_unicode(), 5); + + // Insert at position + text.insert(5, " world").unwrap(); + assert_eq!(text.to_string(), "Hello world"); + assert_eq!(text.len_unicode(), 11); + + // Delete + text.delete(5, 6).unwrap(); // delete " world" + assert_eq!(text.to_string(), "Hello"); + assert_eq!(text.len_unicode(), 5); +} + +#[test] +fn test_loro_unicode_handling() { + use loro::LoroDoc; + + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + + // Insert unicode + text.insert(0, "Hello ๐ŸŽ‰ ไธ–็•Œ").unwrap(); + + // Check lengths + let content = text.to_string(); + assert_eq!(content, "Hello ๐ŸŽ‰ ไธ–็•Œ"); + + // Unicode length (chars) + assert_eq!(text.len_unicode(), 10); // H e l l o ๐ŸŽ‰ ไธ– ็•Œ + + // UTF-16 length (for DOM) + // ๐ŸŽ‰ is a surrogate pair (2 UTF-16 units), rest are 1 each + assert_eq!(text.len_utf16(), 11); // 6 + 2 + 1 + 2 = 11 + + // UTF-8 length (bytes) + assert_eq!(text.len_utf8(), content.len()); +} + +#[test] +fn test_loro_undo_redo() { + use loro::{LoroDoc, UndoManager}; + + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + let mut undo_mgr = UndoManager::new(&doc); + + // Type some text + text.insert(0, "Hello").unwrap(); + doc.commit(); + + text.insert(5, " world").unwrap(); + doc.commit(); + + assert_eq!(text.to_string(), "Hello world"); + + // Undo last change + assert!(undo_mgr.can_undo()); + undo_mgr.undo().unwrap(); + assert_eq!(text.to_string(), "Hello"); + + // Undo first change + undo_mgr.undo().unwrap(); + assert_eq!(text.to_string(), ""); + + // Redo + assert!(undo_mgr.can_redo()); + undo_mgr.redo().unwrap(); + assert_eq!(text.to_string(), "Hello"); + + undo_mgr.redo().unwrap(); + assert_eq!(text.to_string(), "Hello world"); +} + +#[test] +fn test_loro_char_to_utf16_conversion() { + use loro::LoroDoc; + + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + + text.insert(0, "Hello ๐ŸŽ‰ ไธ–็•Œ").unwrap(); + + // Simulate charโ†’UTF16 conversion for cursor positioning + // Given a char offset, compute UTF-16 offset + fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize { + if char_pos == 0 { + return 0; + } + // Fast path: if all ASCII, char == UTF-16 + if text.len_unicode() == text.len_utf16() { + return char_pos; + } + // Slow path: get slice and count UTF-16 units + match text.slice(0, char_pos) { + Ok(slice) => slice.encode_utf16().count(), + Err(_) => 0, + } + } + + // "Hello ๐ŸŽ‰ ไธ–็•Œ" + // Positions: H(0) e(1) l(2) l(3) o(4) ' '(5) ๐ŸŽ‰(6) ' '(7) ไธ–(8) ็•Œ(9) + // UTF-16: 0 1 2 3 4 5 6,7 8 9 10 + + assert_eq!(char_to_utf16(&text, 0), 0); + assert_eq!(char_to_utf16(&text, 6), 6); // before emoji + assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) + assert_eq!(char_to_utf16(&text, 10), 11); // end +} + +#[test] +fn test_loro_ascii_fast_path() { + use loro::LoroDoc; + + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + + // Pure ASCII content + text.insert(0, "Hello world, this is a test!").unwrap(); + + // Verify fast path condition: all lengths equal for ASCII + assert_eq!(text.len_unicode(), text.len_utf8()); + assert_eq!(text.len_unicode(), text.len_utf16()); + + // Fast path should just return char_pos directly + fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize { + if char_pos == 0 { + return 0; + } + if text.len_unicode() == text.len_utf16() { + return char_pos; // fast path + } + text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0) + } + + // All positions should be identity for ASCII + for i in 0..=text.len_unicode() { + assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i); + } +} diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs new file mode 100644 index 0000000..8251c68 --- /dev/null +++ b/crates/weaver-app/src/components/editor/visibility.rs @@ -0,0 +1,269 @@ +//! Conditional syntax visibility based on cursor position. +//! +//! Implements Obsidian-style formatting character visibility: syntax markers +//! are hidden when cursor is not near them, revealed when cursor approaches. + +use super::document::Selection; +use super::paragraph::ParagraphRender; +use super::writer::{SyntaxSpanInfo, SyntaxType}; +use std::collections::HashSet; +use std::ops::Range; + +/// Determines which syntax spans should be visible based on cursor/selection. +#[derive(Debug, Clone, Default)] +pub struct VisibilityState { + /// Set of syn_ids that should be visible + pub visible_span_ids: HashSet, +} + +impl VisibilityState { + /// Calculate visibility based on cursor position and selection. + /// + /// # Arguments + /// - `cursor_offset`: Current cursor position (char offset) + /// - `selection`: Optional selection range + /// - `syntax_spans`: All syntax spans in the document + /// - `paragraphs`: All paragraphs (for block-level visibility lookup) + pub fn calculate( + cursor_offset: usize, + selection: Option<&Selection>, + syntax_spans: &[SyntaxSpanInfo], + paragraphs: &[ParagraphRender], + ) -> Self { + let mut visible = HashSet::new(); + + for span in syntax_spans { + let should_show = match span.syntax_type { + SyntaxType::Inline => { + // Show if cursor within formatted span content OR adjacent to markers + // "Adjacent" means within 1 char of the syntax boundaries + let extended_range = span.char_range.start.saturating_sub(1) + ..span.char_range.end.saturating_add(1); + + // Also show if cursor is anywhere in the formatted_range + // (the region between paired opening/closing markers) + let in_formatted_region = span + .formatted_range + .as_ref() + .map(|r| r.contains(&cursor_offset)) + .unwrap_or(false); + + extended_range.contains(&cursor_offset) + || in_formatted_region + || selection_overlaps(selection, &span.char_range) + || span + .formatted_range + .as_ref() + .map(|r| selection_overlaps(selection, r)) + .unwrap_or(false) + } + SyntaxType::Block => { + // Show if cursor anywhere in same paragraph + cursor_in_same_paragraph(cursor_offset, &span.char_range, paragraphs) + || selection_overlaps(selection, &span.char_range) + } + }; + + if should_show { + visible.insert(span.syn_id.clone()); + } + } + + Self { + visible_span_ids: visible, + } + } + + /// Check if a specific span should be visible. + pub fn is_visible(&self, syn_id: &str) -> bool { + self.visible_span_ids.contains(syn_id) + } +} + +/// Check if selection overlaps with a char range. +fn selection_overlaps(selection: Option<&Selection>, range: &Range) -> bool { + let Some(sel) = selection else { + return false; + }; + + let sel_start = sel.anchor.min(sel.head); + let sel_end = sel.anchor.max(sel.head); + + // Check if ranges overlap + sel_start < range.end && sel_end > range.start +} + +/// Check if cursor is in the same paragraph as a syntax span. +fn cursor_in_same_paragraph( + cursor_offset: usize, + syntax_range: &Range, + paragraphs: &[ParagraphRender], +) -> bool { + // Find which paragraph contains the syntax span + for para in paragraphs { + // Skip gap paragraphs (they have no syntax spans) + if para.syntax_spans.is_empty() && !para.char_range.is_empty() { + continue; + } + + // Check if this paragraph contains the syntax span + if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { + // Check if cursor is also in this paragraph + return para.char_range.contains(&cursor_offset); + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo { + SyntaxSpanInfo { + syn_id: syn_id.to_string(), + char_range: start..end, + syntax_type, + formatted_range: None, + } + } + + fn make_span_with_range( + syn_id: &str, + start: usize, + end: usize, + syntax_type: SyntaxType, + formatted_range: Range, + ) -> SyntaxSpanInfo { + SyntaxSpanInfo { + syn_id: syn_id.to_string(), + char_range: start..end, + syntax_type, + formatted_range: Some(formatted_range), + } + } + + fn make_para(start: usize, end: usize, syntax_spans: Vec) -> ParagraphRender { + ParagraphRender { + byte_range: start..end, + char_range: start..end, + html: String::new(), + offset_map: vec![], + syntax_spans, + source_hash: 0, + } + } + + #[test] + fn test_inline_visibility_cursor_inside() { + // **bold** at chars 0-2 (opening **) and 6-8 (closing **) + // Text positions: 0-1 = **, 2-5 = bold, 6-7 = ** + // formatted_range is 0..8 (the whole **bold** region) + let spans = vec![ + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** + make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** + ]; + let paras = vec![make_para(0, 8, spans.clone())]; + + // Cursor at position 4 (middle of "bold", inside formatted region) + let vis = VisibilityState::calculate(4, None, &spans, ¶s); + assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region"); + assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region"); + + // Cursor at position 2 (adjacent to opening **, start of "bold") + let vis = VisibilityState::calculate(2, None, &spans, ¶s); + assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold"); + + // Cursor at position 5 (adjacent to closing **, end of "bold") + let vis = VisibilityState::calculate(5, None, &spans, ¶s); + assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold"); + } + + #[test] + fn test_inline_visibility_without_formatted_range() { + // Test without formatted_range - just adjacency-based visibility + let spans = vec![ + make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range) + make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range) + ]; + let paras = vec![make_para(0, 8, spans.clone())]; + + // Cursor at position 4 (middle of "bold", not adjacent to either marker) + let vis = VisibilityState::calculate(4, None, &spans, ¶s); + assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent"); + assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent"); + } + + #[test] + fn test_inline_visibility_cursor_adjacent() { + let spans = vec![ + make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6 + ]; + let paras = vec![make_para(0, 20, spans.clone())]; + + // Cursor at position 4 (one before ** which starts at 5) + let vis = VisibilityState::calculate(4, None, &spans, ¶s); + assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent"); + + // Cursor at position 7 (one after ** which ends at 6, since range is exclusive) + let vis = VisibilityState::calculate(7, None, &spans, ¶s); + assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span"); + } + + #[test] + fn test_inline_visibility_cursor_far() { + let spans = vec![ + make_span("s0", 10, 12, SyntaxType::Inline), + ]; + let paras = vec![make_para(0, 30, spans.clone())]; + + // Cursor at position 0 (far from **) + let vis = VisibilityState::calculate(0, None, &spans, ¶s); + assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away"); + } + + #[test] + fn test_block_visibility_same_paragraph() { + // # at start of heading + let spans = vec![ + make_span("s0", 0, 2, SyntaxType::Block), // "# " + ]; + let paras = vec![ + make_para(0, 10, spans.clone()), // heading paragraph + make_para(12, 30, vec![]), // next paragraph + ]; + + // Cursor at position 5 (inside heading) + let vis = VisibilityState::calculate(5, None, &spans, ¶s); + assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph"); + } + + #[test] + fn test_block_visibility_different_paragraph() { + let spans = vec![ + make_span("s0", 0, 2, SyntaxType::Block), + ]; + let paras = vec![ + make_para(0, 10, spans.clone()), + make_para(12, 30, vec![]), + ]; + + // Cursor at position 20 (in second paragraph) + let vis = VisibilityState::calculate(20, None, &spans, ¶s); + assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph"); + } + + #[test] + fn test_selection_reveals_syntax() { + let spans = vec![ + make_span("s0", 5, 7, SyntaxType::Inline), + ]; + let paras = vec![make_para(0, 20, spans.clone())]; + + // Selection overlaps the syntax span + let selection = Selection { anchor: 3, head: 10 }; + let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s); + assert!(vis.is_visible("s0"), "** should be visible when selection overlaps"); + } +} diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 8dcb0bd..67a03e7 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -27,19 +27,37 @@ pub struct WriterResult { /// Paragraph boundaries in source: (byte_range, char_range) /// These are extracted during rendering by tracking Tag::Paragraph events pub paragraph_ranges: Vec<(Range, Range)>, + + /// Syntax spans that can be conditionally hidden + pub syntax_spans: Vec, } /// Classification of markdown syntax characters -#[derive(Debug, Clone, Copy, PartialEq)] -enum SyntaxClass { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxType { /// Inline formatting: **, *, ~~, `, $, [, ], (, ) Inline, /// Block formatting: #, >, -, *, 1., ```, --- Block, } +/// Information about a syntax span for conditional visibility +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxSpanInfo { + /// Unique identifier for this syntax span (e.g., "s0", "s1") + pub syn_id: String, + /// Source char range this syntax covers (just this marker) + pub char_range: Range, + /// Whether this is inline or block-level syntax + pub syntax_type: SyntaxType, + /// For paired inline syntax (**, *, etc), the full formatted region + /// from opening marker through content to closing marker. + /// When cursor is anywhere in this range, the syntax is visible. + pub formatted_range: Option>, +} + /// Classify syntax text as inline or block level -fn classify_syntax(text: &str) -> SyntaxClass { +fn classify_syntax(text: &str) -> SyntaxType { let trimmed = text.trim_start(); // Check for block-level markers @@ -66,9 +84,9 @@ fn classify_syntax(text: &str) -> SyntaxClass { .unwrap_or(false) && trimmed.contains('.') { - SyntaxClass::Block + SyntaxType::Block } else { - SyntaxClass::Inline + SyntaxType::Inline } } @@ -126,12 +144,20 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, W: St // Paragraph boundary tracking for incremental rendering paragraph_ranges: Vec<(Range, Range)>, // (byte_range, char_range) - current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) + current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) + list_depth: usize, // Track nesting depth to avoid paragraph boundary override inside lists /// When true, skip HTML generation and only track paragraph boundaries. /// Used for fast boundary discovery in incremental rendering. boundary_only: bool, + // Syntax span tracking for conditional visibility + syntax_spans: Vec, + next_syn_id: usize, + /// Stack of pending inline formats: (syn_id of opening span, char start of region) + /// Used to set formatted_range when closing paired inline markers + pending_inline_formats: Vec<(String, usize)>, + _phantom: std::marker::PhantomData<&'a ()>, } @@ -154,6 +180,17 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon events: I, writer: W, node_id_offset: usize, + ) -> Self { + Self::new_with_offsets(source, source_rope, events, writer, node_id_offset, 0) + } + + pub fn new_with_offsets( + source: &'a str, + source_rope: &'a JumpRopeBuf, + events: I, + writer: W, + node_id_offset: usize, + syn_id_offset: usize, ) -> Self { Self { source, @@ -182,7 +219,11 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon current_node_child_count: 0, paragraph_ranges: Vec::new(), current_paragraph_start: None, + list_depth: 0, boundary_only: false, + syntax_spans: Vec::new(), + next_syn_id: syn_id_offset, + pending_inline_formats: Vec::new(), _phantom: std::marker::PhantomData, } } @@ -220,8 +261,12 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon current_node_id: None, current_node_char_offset: 0, current_node_child_count: 0, + syntax_spans: Vec::new(), + next_syn_id: 0, + pending_inline_formats: Vec::new(), paragraph_ranges: Vec::new(), current_paragraph_start: None, + list_depth: 0, boundary_only: true, _phantom: std::marker::PhantomData, } @@ -256,7 +301,11 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon current_node_child_count: self.current_node_child_count, paragraph_ranges: self.paragraph_ranges, current_paragraph_start: self.current_paragraph_start, + list_depth: self.list_depth, boundary_only: self.boundary_only, + syntax_spans: self.syntax_spans, + next_syn_id: self.next_syn_id, + pending_inline_formats: self.pending_inline_formats, _phantom: std::marker::PhantomData, } } @@ -280,6 +329,40 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.writer.write_str(s) } + /// Generate a unique syntax span ID + fn gen_syn_id(&mut self) -> String { + let id = format!("s{}", self.next_syn_id); + self.next_syn_id += 1; + id + } + + /// Finalize a paired inline format (Strong, Emphasis, Strikethrough). + /// Pops the pending format info and sets formatted_range on both opening and closing spans. + fn finalize_paired_inline_format(&mut self) { + if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() { + let format_end = self.last_char_offset; + let formatted_range = format_start..format_end; + + // Update the opening span's formatted_range + if let Some(opening_span) = self + .syntax_spans + .iter_mut() + .find(|s| s.syn_id == opening_syn_id) + { + opening_span.formatted_range = Some(formatted_range.clone()); + } + + // Update the closing span's formatted_range (the most recent one) + // The closing syntax was just emitted by emit_gap_before, so it's the last span + if let Some(closing_span) = self.syntax_spans.last_mut() { + // Only update if it's an inline span (closing syntax should be inline) + if closing_span.syntax_type == SyntaxType::Inline { + closing_span.formatted_range = Some(formatted_range); + } + } + } + } + /// Emit syntax span for a given range and record offset mapping fn emit_syntax(&mut self, range: Range) -> Result<(), W::Error> { if range.start < range.end { @@ -287,47 +370,65 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if !syntax.is_empty() { let char_start = self.last_char_offset; let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; // In boundary_only mode, just update offsets without HTML if self.boundary_only { - self.last_char_offset = char_start + syntax_char_len; + self.last_char_offset = char_end; self.last_byte_offset = range.end; return Ok(()); } - let class = match classify_syntax(syntax) { - SyntaxClass::Inline => "md-syntax-inline", - SyntaxClass::Block => "md-syntax-block", + let syntax_type = classify_syntax(syntax); + let class = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", }; + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); + // If we're outside any node, create a wrapper span for tracking let created_node = if self.current_node_id.is_none() { let node_id = self.gen_node_id(); write!( &mut self.writer, - "", - node_id, class + "", + node_id, class, syn_id, char_start, char_end )?; self.begin_node(node_id); true } else { - self.write("")?; + write!( + &mut self.writer, + "", + class, syn_id, char_start, char_end + )?; false }; escape_html(&mut self.writer, syntax)?; self.write("")?; + // Record syntax span info for visibility toggling + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type, + formatted_range: None, + }); + // Record offset mapping for this syntax - self.record_mapping(range.clone(), char_start..char_start + syntax_char_len); - let new_char = char_start + syntax_char_len; - let new_byte = range.end; - tracing::debug!("[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}", - self.last_char_offset, new_char, self.last_byte_offset, new_byte); - self.last_char_offset = new_char; - self.last_byte_offset = new_byte; // Mark bytes as processed + self.record_mapping(range.clone(), char_start..char_end); + tracing::debug!( + "[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}", + self.last_char_offset, + char_end, + self.last_byte_offset, + range.end + ); + self.last_char_offset = char_end; + self.last_byte_offset = range.end; // Mark bytes as processed // Close wrapper if we created one if created_node { @@ -352,7 +453,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon &mut self, syntax: &str, byte_start: usize, - class: SyntaxClass, + syntax_type: SyntaxType, ) -> Result<(), W::Error> { if syntax.is_empty() { return Ok(()); @@ -360,33 +461,44 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let char_start = self.last_char_offset; let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; let byte_end = byte_start + syntax.len(); // In boundary_only mode, just update offsets if self.boundary_only { - self.last_char_offset = char_start + syntax_char_len; + self.last_char_offset = char_end; self.last_byte_offset = byte_end; return Ok(()); } - let class_str = match class { - SyntaxClass::Inline => "md-syntax-inline", - SyntaxClass::Block => "md-syntax-block", + let class_str = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", }; - self.write("")?; + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + class_str, syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write("")?; + // Record syntax span info for visibility toggling + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type, + formatted_range: None, + }); + // Record offset mapping for cursor positioning - self.record_mapping( - byte_start..byte_end, - char_start..char_start + syntax_char_len, - ); + self.record_mapping(byte_start..byte_end, char_start..char_end); - self.last_char_offset = char_start + syntax_char_len; + self.last_char_offset = char_end; self.last_byte_offset = byte_end; Ok(()) @@ -465,27 +577,27 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon pub fn run(mut self) -> Result { while let Some((event, range)) = self.events.next() { // Log events for debugging - tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}", - match &event { - Event::Start(tag) => format!("Start({:?})", tag), - Event::End(tag) => format!("End({:?})", tag), - Event::Text(t) => format!("Text('{}')", t), - Event::Code(t) => format!("Code('{}')", t), - Event::Html(t) => format!("Html('{}')", t), - Event::InlineHtml(t) => format!("InlineHtml('{}')", t), - Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t), - Event::SoftBreak => "SoftBreak".to_string(), - Event::HardBreak => "HardBreak".to_string(), - Event::Rule => "Rule".to_string(), - Event::TaskListMarker(b) => format!("TaskListMarker({})", b), - Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t), - Event::InlineMath(t) => format!("InlineMath('{}')", t), - Event::DisplayMath(t) => format!("DisplayMath('{}')", t), - }, - &range, - self.last_byte_offset, - self.last_char_offset - ); + // tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}", + // match &event { + // Event::Start(tag) => format!("Start({:?})", tag), + // Event::End(tag) => format!("End({:?})", tag), + // Event::Text(t) => format!("Text('{}')", t), + // Event::Code(t) => format!("Code('{}')", t), + // Event::Html(t) => format!("Html('{}')", t), + // Event::InlineHtml(t) => format!("InlineHtml('{}')", t), + // Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t), + // Event::SoftBreak => "SoftBreak".to_string(), + // Event::HardBreak => "HardBreak".to_string(), + // Event::Rule => "Rule".to_string(), + // Event::TaskListMarker(b) => format!("TaskListMarker({})", b), + // Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t), + // Event::InlineMath(t) => format!("InlineMath('{}')", t), + // Event::DisplayMath(t) => format!("DisplayMath('{}')", t), + // }, + // &range, + // self.last_byte_offset, + // self.last_char_offset + // ); // For End events, emit any trailing content within the event's range // BEFORE calling end_tag (which calls end_node and clears current_node_id) @@ -530,15 +642,30 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let char_start = self.last_char_offset; let trailing_char_len = trailing.chars().count(); - self.write("")?; + let char_end = char_start + trailing_char_len; + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, trailing)?; self.write("")?; + // Record syntax span info + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); + // Record mapping if we have a node if let Some(ref node_id) = self.current_node_id { let mapping = OffsetMapping { byte_range: self.last_byte_offset..doc_byte_len, - char_range: char_start..char_start + trailing_char_len, + char_range: char_start..char_end, node_id: node_id.clone(), char_offset_in_node: self.current_node_char_offset, child_index: None, @@ -556,6 +683,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Ok(WriterResult { offset_maps: self.offset_maps, paragraph_ranges: self.paragraph_ranges, + syntax_spans: self.syntax_spans, }) } @@ -628,7 +756,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } else { // First text in code block - start tracking self.code_buffer_byte_range = Some(range.clone()); - self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len); + self.code_buffer_char_range = + Some(self.last_char_offset..self.last_char_offset + text_char_len); } // Update offsets so paragraph boundary is correct self.last_char_offset += text_char_len; @@ -659,7 +788,19 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Emit opening backtick and track it if raw_text.starts_with('`') { - self.write("`")?; + let syn_id = self.gen_syn_id(); + let backtick_char_end = char_start + 1; + write!( + &mut self.writer, + "`", + syn_id, char_start, backtick_char_end + )?; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..backtick_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); self.last_char_offset += 1; } @@ -678,7 +819,20 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Emit closing backtick and track it if raw_text.ends_with('`') { - self.write("`")?; + let syn_id = self.gen_syn_id(); + let backtick_char_start = self.last_char_offset; + let backtick_char_end = backtick_char_start + 1; + write!( + &mut self.writer, + "`", + syn_id, backtick_char_start, backtick_char_end + )?; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: backtick_char_start..backtick_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); self.last_char_offset += 1; } } @@ -687,7 +841,20 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Emit opening $ and track it if raw_text.starts_with('$') { - self.write("$")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 1; + write!( + &mut self.writer, + "$", + syn_id, char_start, char_end + )?; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); self.last_char_offset += 1; } @@ -699,7 +866,20 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Emit closing $ and track it if raw_text.ends_with('$') { - self.write("$")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 1; + write!( + &mut self.writer, + "$", + syn_id, char_start, char_end + )?; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); self.last_char_offset += 1; } } @@ -708,7 +888,20 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Emit opening $$ and track it if raw_text.starts_with("$$") { - self.write("$$")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 2; + write!( + &mut self.writer, + "$$", + syn_id, char_start, char_end + )?; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); self.last_char_offset += 2; } @@ -720,7 +913,20 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Emit closing $$ and track it if raw_text.ends_with("$$") { - self.write("$$")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 2; + write!( + &mut self.writer, + "$$", + syn_id, char_start, char_end + )?; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); self.last_char_offset += 2; } } @@ -744,18 +950,32 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let spaces = &gap[..gap.len() - 1]; // everything except the \n let char_start = self.last_char_offset; let spaces_char_len = spaces.chars().count(); + let char_end = char_start + spaces_char_len; // Emit and map the visible spaces - self.write("")?; + let syn_id = self.gen_syn_id(); + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, spaces)?; self.write("")?; + // Record syntax span info + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); + // Count this span as a child self.current_node_child_count += 1; self.record_mapping( range.start..range.start + spaces.len(), - char_start..char_start + spaces_char_len, + char_start..char_end, ); // Now the actual line break
@@ -779,7 +999,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon node_id: node_id.clone(), char_offset_in_node: self.current_node_char_offset, child_index: None, // text node - TreeWalker will find it - utf16_len: 1, // zero-width space is 1 UTF-16 unit + utf16_len: 1, // zero-width space is 1 UTF-16 unit }; self.offset_maps.push(mapping); @@ -805,9 +1025,25 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let raw_text = &self.source[range]; let trimmed = raw_text.trim(); if !trimmed.is_empty() { - self.write("")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_len = trimmed.chars().count(); + let char_end = char_start + char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, trimmed)?; self.write("\n")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); } } @@ -830,9 +1066,26 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if let Some(bracket_pos) = raw_text.find('[') { let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; - self.write("")?; + + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write(" ")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); } } @@ -891,26 +1144,52 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon }; if let Some(syntax) = opening_syntax { - let class = match classify_syntax(syntax) { - SyntaxClass::Inline => "md-syntax-inline", - SyntaxClass::Block => "md-syntax-block", + let syntax_type = classify_syntax(syntax); + let class = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", }; let char_start = self.last_char_offset; let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; let syntax_byte_len = syntax.len(); - self.write("")?; + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + class, syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write("")?; + // Record syntax span info for visibility toggling + self.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: char_start..char_end, + syntax_type, + formatted_range: None, // Will be updated when closing tag is emitted + }); + + // For paired inline syntax (Strong, Emphasis, Strikethrough), + // track the opening span so we can set formatted_range when closing + if matches!(tag, Tag::Strong | Tag::Emphasis | Tag::Strikethrough) { + self.pending_inline_formats.push((syn_id, char_start)); + } + // Update tracking - we've consumed this opening syntax - tracing::debug!("[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}", - syntax, self.last_char_offset, char_start + syntax_char_len, - self.last_byte_offset, range.start + syntax_byte_len); - self.last_char_offset = char_start + syntax_char_len; + tracing::debug!( + "[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}", + syntax, + self.last_char_offset, + char_end, + self.last_byte_offset, + range.start + syntax_byte_len + ); + self.last_char_offset = char_end; self.last_byte_offset = range.start + syntax_byte_len; } } @@ -920,7 +1199,10 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Tag::HtmlBlock => Ok(()), Tag::Paragraph => { // Record paragraph start for boundary tracking - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + // BUT skip if inside a list - list owns the paragraph boundary + if self.list_depth == 0 { + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + } let node_id = self.gen_node_id(); if self.end_newline { @@ -964,7 +1246,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let syntax = &raw_text[gt_pos..syntax_end]; let syntax_byte_start = bq_range.start + gt_pos; - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?; + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; } } } @@ -1052,7 +1334,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let syntax = &raw_text[hash_pos..syntax_end]; let syntax_byte_start = range.start + hash_pos; - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?; + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; } } Ok(()) @@ -1141,9 +1423,26 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let syntax = &raw_text[fence_pos..fence_end]; let syntax_char_len = syntax.chars().count() + 1; // +1 for newline let syntax_byte_len = syntax.len() + 1; // +1 for newline - self.write("")?; + + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + syntax_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write("\n")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); + self.last_char_offset += syntax_char_len; self.last_byte_offset = range.start + fence_pos + syntax_byte_len; } @@ -1175,6 +1474,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Tag::List(Some(1)) => { // Track list as paragraph-level block self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.list_depth += 1; if self.end_newline { self.write("
    \n") } else { @@ -1184,6 +1484,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Tag::List(Some(start)) => { // Track list as paragraph-level block self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.list_depth += 1; if self.end_newline { self.write("
      , Range)>, W: StrWrite, E: EmbedCon Tag::List(None) => { // Track list as paragraph-level block self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.list_depth += 1; if self.end_newline { self.write("
        \n") } else { @@ -1234,33 +1536,63 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let char_start = self.last_char_offset; let syntax_char_len = leading_ws_chars + syntax.chars().count(); let syntax_byte_len = leading_ws_bytes + syntax.len(); - self.write("")?; + let char_end = char_start + syntax_char_len; + + let syn_id = self.gen_syn_id(); + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write("")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); + // Record offset mapping for cursor positioning self.record_mapping( range.start..range.start + syntax_byte_len, - char_start..char_start + syntax_char_len, + char_start..char_end, ); - self.last_char_offset = char_start + syntax_char_len; + self.last_char_offset = char_end; self.last_byte_offset = range.start + syntax_byte_len; } else if marker.is_ascii_digit() { - // Ordered list: extract "1. " or similar + // Ordered list: extract "1. " or similar (including trailing space) if let Some(dot_pos) = trimmed.find('.') { let syntax_end = (dot_pos + 2).min(trimmed.len()); - let syntax = &trimmed[..syntax_end].trim_end(); + let syntax = &trimmed[..syntax_end]; let char_start = self.last_char_offset; let syntax_char_len = leading_ws_chars + syntax.chars().count(); let syntax_byte_len = leading_ws_bytes + syntax.len(); - self.write("")?; + let char_end = char_start + syntax_char_len; + + let syn_id = self.gen_syn_id(); + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write("")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); + // Record offset mapping for cursor positioning self.record_mapping( range.start..range.start + syntax_byte_len, - char_start..char_start + syntax_char_len, + char_start..char_end, ); - self.last_char_offset = char_start + syntax_char_len; + self.last_char_offset = char_end; self.last_byte_offset = range.start + syntax_byte_len; } } @@ -1339,7 +1671,22 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if range.start < range.end { let raw_text = &self.source[range.clone()]; if raw_text.starts_with("![") { - self.write("![")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 2; + + write!( + &mut self.writer, + "![", + syn_id, char_start, char_end + )?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); } } @@ -1380,9 +1727,25 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let raw_text = &self.source[range]; if let Some(paren_pos) = raw_text.rfind("](") { let syntax = &raw_text[paren_pos..]; - self.write("")?; + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, syntax)?; self.write("")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); } } Ok(()) @@ -1430,10 +1793,13 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon TagEnd::HtmlBlock => Ok(()), TagEnd::Paragraph => { // Record paragraph end for boundary tracking - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); + // BUT skip if inside a list - list owns the paragraph boundary + if self.list_depth == 0 { + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { + let byte_range = byte_start..self.last_byte_offset; + let char_range = char_start..self.last_char_offset; + self.paragraph_ranges.push((byte_range, char_range)); + } } self.end_node(); @@ -1506,8 +1872,10 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if let Some((lang, buffer)) = self.code_buffer.take() { // Create offset mapping for code block content if we tracked ranges - if let (Some(code_byte_range), Some(code_char_range)) = - (self.code_buffer_byte_range.take(), self.code_buffer_char_range.take()) { + if let (Some(code_byte_range), Some(code_char_range)) = ( + self.code_buffer_byte_range.take(), + self.code_buffer_char_range.take(), + ) { // Record mapping before writing HTML // (current_node_id should be set by start_tag for CodeBlock) self.record_mapping(code_byte_range, code_char_range); @@ -1553,9 +1921,26 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if fence_line.trim().starts_with("```") { let fence = fence_line.trim(); let fence_char_len = fence.chars().count(); - self.write("")?; + + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + fence_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; escape_html(&mut self.writer, fence)?; self.write("")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); + self.last_char_offset += fence_char_len; self.last_byte_offset += fence.len(); } @@ -1572,6 +1957,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Ok(()) } TagEnd::List(true) => { + self.list_depth = self.list_depth.saturating_sub(1); // Record list end for paragraph boundary tracking if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { let byte_range = byte_start..self.last_byte_offset; @@ -1581,6 +1967,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.write("
    \n") } TagEnd::List(false) => { + self.list_depth = self.list_depth.saturating_sub(1); // Record list end for paragraph boundary tracking if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { let byte_range = byte_start..self.last_byte_offset; @@ -1602,11 +1989,20 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.end_node(); self.write("\n") } - TagEnd::Emphasis => self.write(""), + TagEnd::Emphasis => { + self.finalize_paired_inline_format(); + self.write("") + } TagEnd::Superscript => self.write(""), TagEnd::Subscript => self.write(""), - TagEnd::Strong => self.write(""), - TagEnd::Strikethrough => self.write(""), + TagEnd::Strong => { + self.finalize_paired_inline_format(); + self.write("") + } + TagEnd::Strikethrough => { + self.finalize_paired_inline_format(); + self.write("") + } TagEnd::Link => self.write(""), TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event TagEnd::Embed => Ok(()),