bunch of fixes, maybe starting CRDT stuff

Orual f88aa1d1 801eaa03

+2011 -506
+525 -10
Cargo.lock
··· 49 49 checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 50 50 dependencies = [ 51 51 "cfg-if", 52 + "getrandom 0.3.4", 52 53 "once_cell", 53 54 "version_check", 54 55 "zerocopy", ··· 156 157 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 157 158 158 159 [[package]] 160 + name = "append-only-bytes" 161 + version = "0.1.12" 162 + source = "registry+https://github.com/rust-lang/crates.io-index" 163 + checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" 164 + 165 + [[package]] 159 166 name = "ar_archive_writer" 160 167 version = "0.2.0" 161 168 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 165 172 ] 166 173 167 174 [[package]] 175 + name = "arbitrary" 176 + version = "1.4.2" 177 + source = "registry+https://github.com/rust-lang/crates.io-index" 178 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 179 + dependencies = [ 180 + "derive_arbitrary", 181 + ] 182 + 183 + [[package]] 168 184 name = "arraydeque" 169 185 version = "0.5.1" 170 186 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 175 191 version = "0.7.6" 176 192 source = "registry+https://github.com/rust-lang/crates.io-index" 177 193 checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 194 + 195 + [[package]] 196 + name = "arref" 197 + version = "0.1.0" 198 + source = "registry+https://github.com/rust-lang/crates.io-index" 199 + checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" 178 200 179 201 [[package]] 180 202 name = "ascii" ··· 565 587 ] 566 588 567 589 [[package]] 590 + name = "bitmaps" 591 + version = "2.1.0" 592 + source = "registry+https://github.com/rust-lang/crates.io-index" 593 + checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" 594 + dependencies = [ 595 + "typenum", 596 + ] 597 + 598 + [[package]] 568 599 name = "block" 569 600 version = "0.1.6" 570 601 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 604 635 source = "registry+https://github.com/rust-lang/crates.io-index" 605 636 checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" 606 637 dependencies = [ 607 - "darling", 638 + "darling 0.21.3", 608 639 "ident_case", 609 640 "prettyplease", 610 641 "proc-macro2", ··· 1398 1429 1399 1430 [[package]] 1400 1431 name = "darling" 1432 + version = "0.20.11" 1433 + source = "registry+https://github.com/rust-lang/crates.io-index" 1434 + checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 1435 + dependencies = [ 1436 + "darling_core 0.20.11", 1437 + "darling_macro 0.20.11", 1438 + ] 1439 + 1440 + [[package]] 1441 + name = "darling" 1401 1442 version = "0.21.3" 1402 1443 source = "registry+https://github.com/rust-lang/crates.io-index" 1403 1444 checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 1404 1445 dependencies = [ 1405 - "darling_core", 1406 - "darling_macro", 1446 + "darling_core 0.21.3", 1447 + "darling_macro 0.21.3", 1448 + ] 1449 + 1450 + [[package]] 1451 + name = "darling_core" 1452 + version = "0.20.11" 1453 + source = "registry+https://github.com/rust-lang/crates.io-index" 1454 + checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 1455 + dependencies = [ 1456 + "fnv", 1457 + "ident_case", 1458 + "proc-macro2", 1459 + "quote", 1460 + "strsim", 1461 + "syn 2.0.110", 1407 1462 ] 1408 1463 1409 1464 [[package]] ··· 1422 1477 1423 1478 [[package]] 1424 1479 name = "darling_macro" 1480 + version = "0.20.11" 1481 + source = "registry+https://github.com/rust-lang/crates.io-index" 1482 + checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 1483 + dependencies = [ 1484 + "darling_core 0.20.11", 1485 + "quote", 1486 + "syn 2.0.110", 1487 + ] 1488 + 1489 + [[package]] 1490 + name = "darling_macro" 1425 1491 version = "0.21.3" 1426 1492 source = "registry+https://github.com/rust-lang/crates.io-index" 1427 1493 checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 1428 1494 dependencies = [ 1429 - "darling_core", 1495 + "darling_core 0.21.3", 1430 1496 "quote", 1431 1497 "syn 2.0.110", 1432 1498 ] ··· 1534 1600 ] 1535 1601 1536 1602 [[package]] 1603 + name = "derive_arbitrary" 1604 + version = "1.4.2" 1605 + source = "registry+https://github.com/rust-lang/crates.io-index" 1606 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 1607 + dependencies = [ 1608 + "proc-macro2", 1609 + "quote", 1610 + "syn 2.0.110", 1611 + ] 1612 + 1613 + [[package]] 1537 1614 name = "derive_more" 1538 1615 version = "0.99.20" 1539 1616 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1655 1732 dependencies = [ 1656 1733 "syn 2.0.110", 1657 1734 ] 1735 + 1736 + [[package]] 1737 + name = "diff" 1738 + version = "0.1.13" 1739 + source = "registry+https://github.com/rust-lang/crates.io-index" 1740 + checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 1658 1741 1659 1742 [[package]] 1660 1743 name = "digest" ··· 2822 2905 source = "registry+https://github.com/rust-lang/crates.io-index" 2823 2906 checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" 2824 2907 dependencies = [ 2825 - "darling", 2908 + "darling 0.21.3", 2826 2909 "either", 2827 2910 "heck 0.5.0", 2828 2911 "proc-macro2", ··· 2957 3040 checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" 2958 3041 2959 3042 [[package]] 3043 + name = "ensure-cov" 3044 + version = "0.1.0" 3045 + source = "registry+https://github.com/rust-lang/crates.io-index" 3046 + checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" 3047 + 3048 + [[package]] 3049 + name = "enum-as-inner" 3050 + version = "0.5.1" 3051 + source = "registry+https://github.com/rust-lang/crates.io-index" 3052 + checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" 3053 + dependencies = [ 3054 + "heck 0.4.1", 3055 + "proc-macro2", 3056 + "quote", 3057 + "syn 1.0.109", 3058 + ] 3059 + 3060 + [[package]] 2960 3061 name = "enum-as-inner" 2961 3062 version = "0.6.1" 2962 3063 source = "registry+https://github.com/rust-lang/crates.io-index" 2963 3064 checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 2964 3065 dependencies = [ 2965 3066 "heck 0.5.0", 3067 + "proc-macro2", 3068 + "quote", 3069 + "syn 2.0.110", 3070 + ] 3071 + 3072 + [[package]] 3073 + name = "enum_dispatch" 3074 + version = "0.3.13" 3075 + source = "registry+https://github.com/rust-lang/crates.io-index" 3076 + checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" 3077 + dependencies = [ 3078 + "once_cell", 2966 3079 "proc-macro2", 2967 3080 "quote", 2968 3081 "syn 2.0.110", ··· 3004 3117 source = "registry+https://github.com/rust-lang/crates.io-index" 3005 3118 checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" 3006 3119 dependencies = [ 3007 - "darling", 3120 + "darling 0.21.3", 3008 3121 "proc-macro2", 3009 3122 "quote", 3010 3123 "syn 2.0.110", ··· 3492 3605 ] 3493 3606 3494 3607 [[package]] 3608 + name = "generic-btree" 3609 + version = "0.10.7" 3610 + source = "registry+https://github.com/rust-lang/crates.io-index" 3611 + checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" 3612 + dependencies = [ 3613 + "arref", 3614 + "heapless 0.9.2", 3615 + "itertools 0.11.0", 3616 + "loro-thunderdome", 3617 + "proc-macro2", 3618 + "rustc-hash 2.1.1", 3619 + ] 3620 + 3621 + [[package]] 3495 3622 name = "gethostname" 3496 3623 version = "1.1.0" 3497 3624 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3847 3974 ] 3848 3975 3849 3976 [[package]] 3977 + name = "hash32" 3978 + version = "0.3.1" 3979 + source = "registry+https://github.com/rust-lang/crates.io-index" 3980 + checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" 3981 + dependencies = [ 3982 + "byteorder", 3983 + ] 3984 + 3985 + [[package]] 3850 3986 name = "hashbrown" 3851 3987 version = "0.12.3" 3852 3988 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3925 4061 checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" 3926 4062 dependencies = [ 3927 4063 "atomic-polyfill", 3928 - "hash32", 4064 + "hash32 0.2.1", 3929 4065 "rustc_version", 3930 4066 "serde", 3931 4067 "spin 0.9.8", ··· 3933 4069 ] 3934 4070 3935 4071 [[package]] 4072 + name = "heapless" 4073 + version = "0.8.0" 4074 + source = "registry+https://github.com/rust-lang/crates.io-index" 4075 + checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" 4076 + dependencies = [ 4077 + "hash32 0.3.1", 4078 + "stable_deref_trait", 4079 + ] 4080 + 4081 + [[package]] 4082 + name = "heapless" 4083 + version = "0.9.2" 4084 + source = "registry+https://github.com/rust-lang/crates.io-index" 4085 + checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" 4086 + dependencies = [ 4087 + "hash32 0.3.1", 4088 + "stable_deref_trait", 4089 + ] 4090 + 4091 + [[package]] 3936 4092 name = "heck" 3937 4093 version = "0.4.1" 3938 4094 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3971 4127 "async-trait", 3972 4128 "cfg-if", 3973 4129 "data-encoding", 3974 - "enum-as-inner", 4130 + "enum-as-inner 0.6.1", 3975 4131 "futures-channel", 3976 4132 "futures-io", 3977 4133 "futures-util", ··· 4331 4487 ] 4332 4488 4333 4489 [[package]] 4490 + name = "im" 4491 + version = "15.1.0" 4492 + source = "registry+https://github.com/rust-lang/crates.io-index" 4493 + checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" 4494 + dependencies = [ 4495 + "bitmaps", 4496 + "rand_core 0.6.4", 4497 + "rand_xoshiro", 4498 + "serde", 4499 + "sized-chunks", 4500 + "typenum", 4501 + "version_check", 4502 + ] 4503 + 4504 + [[package]] 4334 4505 name = "indexmap" 4335 4506 version = "1.9.3" 4336 4507 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4456 4627 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 4457 4628 4458 4629 [[package]] 4630 + name = "itertools" 4631 + version = "0.11.0" 4632 + source = "registry+https://github.com/rust-lang/crates.io-index" 4633 + checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" 4634 + dependencies = [ 4635 + "either", 4636 + ] 4637 + 4638 + [[package]] 4639 + name = "itertools" 4640 + version = "0.12.1" 4641 + source = "registry+https://github.com/rust-lang/crates.io-index" 4642 + checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 4643 + dependencies = [ 4644 + "either", 4645 + ] 4646 + 4647 + [[package]] 4459 4648 name = "itoa" 4460 4649 version = "1.0.15" 4461 4650 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4889 5078 ] 4890 5079 4891 5080 [[package]] 5081 + name = "leb128" 5082 + version = "0.2.5" 5083 + source = "registry+https://github.com/rust-lang/crates.io-index" 5084 + checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" 5085 + 5086 + [[package]] 4892 5087 name = "libappindicator" 4893 5088 version = "0.9.0" 4894 5089 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5048 5243 "cfg-if", 5049 5244 "generator", 5050 5245 "scoped-tls", 5246 + "serde", 5247 + "serde_json", 5051 5248 "tracing", 5052 5249 "tracing-subscriber", 5053 5250 ] 5054 5251 5055 5252 [[package]] 5253 + name = "loro" 5254 + version = "1.9.1" 5255 + source = "registry+https://github.com/rust-lang/crates.io-index" 5256 + checksum = "546b01ae9bb635853dc8beacfc74a7eb5d0b90cdfdb9c63175cdc1e319fae414" 5257 + dependencies = [ 5258 + "enum-as-inner 0.6.1", 5259 + "generic-btree", 5260 + "loro-common", 5261 + "loro-delta", 5262 + "loro-internal", 5263 + "loro-kv-store", 5264 + "rustc-hash 2.1.1", 5265 + "tracing", 5266 + ] 5267 + 5268 + [[package]] 5269 + name = "loro-common" 5270 + version = "1.9.1" 5271 + source = "registry+https://github.com/rust-lang/crates.io-index" 5272 + checksum = "d354299916ca5b8b50a14d9b6bc04943477f5ac5ad6a1351a99eae25a4bb3fd8" 5273 + dependencies = [ 5274 + "arbitrary", 5275 + "enum-as-inner 0.6.1", 5276 + "leb128", 5277 + "loro-rle", 5278 + "nonmax", 5279 + "rustc-hash 2.1.1", 5280 + "serde", 5281 + "serde_columnar", 5282 + "serde_json", 5283 + "thiserror 1.0.69", 5284 + ] 5285 + 5286 + [[package]] 5287 + name = "loro-delta" 5288 + version = "1.9.1" 5289 + source = "registry+https://github.com/rust-lang/crates.io-index" 5290 + checksum = "8eafa788a72c1cbf0b7dc08a862cd7cc31b96d99c2ef749cdc94c2330f9494d3" 5291 + dependencies = [ 5292 + "arrayvec", 5293 + "enum-as-inner 0.5.1", 5294 + "generic-btree", 5295 + "heapless 0.8.0", 5296 + ] 5297 + 5298 + [[package]] 5299 + name = "loro-internal" 5300 + version = "1.9.1" 5301 + source = "registry+https://github.com/rust-lang/crates.io-index" 5302 + checksum = "824ea2d4a3bb4e9d95d145b6d539509d7988a12b9e5e54d20956722d719af17b" 5303 + dependencies = [ 5304 + "append-only-bytes", 5305 + "arref", 5306 + "bytes", 5307 + "either", 5308 + "ensure-cov", 5309 + "enum-as-inner 0.6.1", 5310 + "enum_dispatch", 5311 + "generic-btree", 5312 + "getrandom 0.2.16", 5313 + "im", 5314 + "itertools 0.12.1", 5315 + "leb128", 5316 + "loom", 5317 + "loro-common", 5318 + "loro-delta", 5319 + "loro-kv-store", 5320 + "loro-rle", 5321 + "loro_fractional_index", 5322 + "md5", 5323 + "nonmax", 5324 + "num", 5325 + "num-traits", 5326 + "once_cell", 5327 + "parking_lot", 5328 + "pest", 5329 + "pest_derive", 5330 + "postcard", 5331 + "pretty_assertions", 5332 + "rand 0.8.5", 5333 + "rustc-hash 2.1.1", 5334 + "serde", 5335 + "serde_columnar", 5336 + "serde_json", 5337 + "smallvec", 5338 + "thiserror 1.0.69", 5339 + "thread_local", 5340 + "tracing", 5341 + "wasm-bindgen", 5342 + "xxhash-rust", 5343 + ] 5344 + 5345 + [[package]] 5346 + name = "loro-kv-store" 5347 + version = "1.9.1" 5348 + source = "registry+https://github.com/rust-lang/crates.io-index" 5349 + checksum = "849d246b5791e97cf37f1bbd54ad7885f128b690ee3a634ee89cf52a1930fb88" 5350 + dependencies = [ 5351 + "bytes", 5352 + "ensure-cov", 5353 + "loro-common", 5354 + "lz4_flex", 5355 + "once_cell", 5356 + "quick_cache", 5357 + "rustc-hash 2.1.1", 5358 + "tracing", 5359 + "xxhash-rust", 5360 + ] 5361 + 5362 + [[package]] 5363 + name = "loro-rle" 5364 + version = "1.6.0" 5365 + source = "registry+https://github.com/rust-lang/crates.io-index" 5366 + checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" 5367 + dependencies = [ 5368 + "append-only-bytes", 5369 + "num", 5370 + "smallvec", 5371 + ] 5372 + 5373 + [[package]] 5374 + name = "loro-thunderdome" 5375 + version = "0.6.2" 5376 + source = "registry+https://github.com/rust-lang/crates.io-index" 5377 + checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" 5378 + 5379 + [[package]] 5380 + name = "loro_fractional_index" 5381 + version = "1.6.0" 5382 + source = "registry+https://github.com/rust-lang/crates.io-index" 5383 + checksum = "427c8ea186958094052b971fe7e322a934b034c3bf62f0458ccea04fcd687ba1" 5384 + dependencies = [ 5385 + "once_cell", 5386 + "rand 0.8.5", 5387 + "serde", 5388 + ] 5389 + 5390 + [[package]] 5056 5391 name = "lru" 5057 5392 version = "0.16.2" 5058 5393 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5075 5410 version = "0.1.2" 5076 5411 source = "registry+https://github.com/rust-lang/crates.io-index" 5077 5412 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 5413 + 5414 + [[package]] 5415 + name = "lz4_flex" 5416 + version = "0.11.5" 5417 + source = "registry+https://github.com/rust-lang/crates.io-index" 5418 + checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" 5419 + dependencies = [ 5420 + "twox-hash", 5421 + ] 5078 5422 5079 5423 [[package]] 5080 5424 name = "mac" ··· 5273 5617 version = "0.8.4" 5274 5618 source = "registry+https://github.com/rust-lang/crates.io-index" 5275 5619 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 5620 + 5621 + [[package]] 5622 + name = "md5" 5623 + version = "0.7.0" 5624 + source = "registry+https://github.com/rust-lang/crates.io-index" 5625 + checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" 5276 5626 5277 5627 [[package]] 5278 5628 name = "mediatype" ··· 5673 6023 ] 5674 6024 5675 6025 [[package]] 6026 + name = "nonmax" 6027 + version = "0.5.5" 6028 + source = "registry+https://github.com/rust-lang/crates.io-index" 6029 + checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" 6030 + 6031 + [[package]] 5676 6032 name = "nu-ansi-term" 5677 6033 version = "0.50.3" 5678 6034 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5682 6038 ] 5683 6039 5684 6040 [[package]] 6041 + name = "num" 6042 + version = "0.4.3" 6043 + source = "registry+https://github.com/rust-lang/crates.io-index" 6044 + checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" 6045 + dependencies = [ 6046 + "num-bigint", 6047 + "num-complex", 6048 + "num-integer", 6049 + "num-iter", 6050 + "num-rational", 6051 + "num-traits", 6052 + ] 6053 + 6054 + [[package]] 5685 6055 name = "num-bigint" 5686 6056 version = "0.4.6" 5687 6057 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5709 6079 ] 5710 6080 5711 6081 [[package]] 6082 + name = "num-complex" 6083 + version = "0.4.6" 6084 + source = "registry+https://github.com/rust-lang/crates.io-index" 6085 + checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" 6086 + dependencies = [ 6087 + "num-traits", 6088 + ] 6089 + 6090 + [[package]] 5712 6091 name = "num-conv" 5713 6092 version = "0.1.0" 5714 6093 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5735 6114 ] 5736 6115 5737 6116 [[package]] 6117 + name = "num-rational" 6118 + version = "0.4.2" 6119 + source = "registry+https://github.com/rust-lang/crates.io-index" 6120 + checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" 6121 + dependencies = [ 6122 + "num-bigint", 6123 + "num-integer", 6124 + "num-traits", 6125 + ] 6126 + 6127 + [[package]] 5738 6128 name = "num-traits" 5739 6129 version = "0.2.19" 5740 6130 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6145 6535 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 6146 6536 6147 6537 [[package]] 6538 + name = "pest" 6539 + version = "2.8.4" 6540 + source = "registry+https://github.com/rust-lang/crates.io-index" 6541 + checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" 6542 + dependencies = [ 6543 + "memchr", 6544 + "ucd-trie", 6545 + ] 6546 + 6547 + [[package]] 6548 + name = "pest_derive" 6549 + version = "2.8.4" 6550 + source = "registry+https://github.com/rust-lang/crates.io-index" 6551 + checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" 6552 + dependencies = [ 6553 + "pest", 6554 + "pest_generator", 6555 + ] 6556 + 6557 + [[package]] 6558 + name = "pest_generator" 6559 + version = "2.8.4" 6560 + source = "registry+https://github.com/rust-lang/crates.io-index" 6561 + checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" 6562 + dependencies = [ 6563 + "pest", 6564 + "pest_meta", 6565 + "proc-macro2", 6566 + "quote", 6567 + "syn 2.0.110", 6568 + ] 6569 + 6570 + [[package]] 6571 + name = "pest_meta" 6572 + version = "2.8.4" 6573 + source = "registry+https://github.com/rust-lang/crates.io-index" 6574 + checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" 6575 + dependencies = [ 6576 + "pest", 6577 + "sha2", 6578 + ] 6579 + 6580 + [[package]] 6148 6581 name = "phf" 6149 6582 version = "0.8.0" 6150 6583 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6378 6811 "cobs", 6379 6812 "embedded-io 0.4.0", 6380 6813 "embedded-io 0.6.1", 6381 - "heapless", 6814 + "heapless 0.7.17", 6382 6815 "serde", 6383 6816 ] 6384 6817 ··· 6411 6844 version = "0.1.1" 6412 6845 source = "registry+https://github.com/rust-lang/crates.io-index" 6413 6846 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 6847 + 6848 + [[package]] 6849 + name = "pretty_assertions" 6850 + version = "1.4.1" 6851 + source = "registry+https://github.com/rust-lang/crates.io-index" 6852 + checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" 6853 + dependencies = [ 6854 + "diff", 6855 + "yansi", 6856 + ] 6414 6857 6415 6858 [[package]] 6416 6859 name = "prettyplease" ··· 6562 7005 ] 6563 7006 6564 7007 [[package]] 7008 + name = "quick_cache" 7009 + version = "0.6.18" 7010 + source = "registry+https://github.com/rust-lang/crates.io-index" 7011 + checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" 7012 + dependencies = [ 7013 + "ahash", 7014 + "equivalent", 7015 + "hashbrown 0.16.1", 7016 + "parking_lot", 7017 + ] 7018 + 7019 + [[package]] 6565 7020 name = "quinn" 6566 7021 version = "0.11.9" 6567 7022 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6739 7194 checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" 6740 7195 dependencies = [ 6741 7196 "rand_core 0.5.1", 7197 + ] 7198 + 7199 + [[package]] 7200 + name = "rand_xoshiro" 7201 + version = "0.6.0" 7202 + source = "registry+https://github.com/rust-lang/crates.io-index" 7203 + checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" 7204 + dependencies = [ 7205 + "rand_core 0.6.4", 6742 7206 ] 6743 7207 6744 7208 [[package]] ··· 7312 7776 ] 7313 7777 7314 7778 [[package]] 7779 + name = "serde_columnar" 7780 + version = "0.3.14" 7781 + source = "registry+https://github.com/rust-lang/crates.io-index" 7782 + checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" 7783 + dependencies = [ 7784 + "itertools 0.11.0", 7785 + "postcard", 7786 + "serde", 7787 + "serde_columnar_derive", 7788 + "thiserror 1.0.69", 7789 + ] 7790 + 7791 + [[package]] 7792 + name = "serde_columnar_derive" 7793 + version = "0.3.7" 7794 + source = "registry+https://github.com/rust-lang/crates.io-index" 7795 + checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" 7796 + dependencies = [ 7797 + "darling 0.20.11", 7798 + "proc-macro2", 7799 + "quote", 7800 + "syn 2.0.110", 7801 + ] 7802 + 7803 + [[package]] 7315 7804 name = "serde_core" 7316 7805 version = "1.0.228" 7317 7806 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7469 7958 source = "registry+https://github.com/rust-lang/crates.io-index" 7470 7959 checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" 7471 7960 dependencies = [ 7472 - "darling", 7961 + "darling 0.21.3", 7473 7962 "proc-macro2", 7474 7963 "quote", 7475 7964 "syn 2.0.110", ··· 7644 8133 checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 7645 8134 7646 8135 [[package]] 8136 + name = "sized-chunks" 8137 + version = "0.6.5" 8138 + source = "registry+https://github.com/rust-lang/crates.io-index" 8139 + checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" 8140 + dependencies = [ 8141 + "bitmaps", 8142 + "typenum", 8143 + ] 8144 + 8145 + [[package]] 7647 8146 name = "slab" 7648 8147 version = "0.4.11" 7649 8148 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7693 8192 version = "1.15.1" 7694 8193 source = "registry+https://github.com/rust-lang/crates.io-index" 7695 8194 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 8195 + dependencies = [ 8196 + "serde", 8197 + ] 7696 8198 7697 8199 [[package]] 7698 8200 name = "smartstring" ··· 9000 9502 ] 9001 9503 9002 9504 [[package]] 9505 + name = "twox-hash" 9506 + version = "2.1.2" 9507 + source = "registry+https://github.com/rust-lang/crates.io-index" 9508 + checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" 9509 + 9510 + [[package]] 9003 9511 name = "typed-arena" 9004 9512 version = "2.0.2" 9005 9513 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9010 9518 version = "1.19.0" 9011 9519 source = "registry+https://github.com/rust-lang/crates.io-index" 9012 9520 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 9521 + 9522 + [[package]] 9523 + name = "ucd-trie" 9524 + version = "0.1.7" 9525 + source = "registry+https://github.com/rust-lang/crates.io-index" 9526 + checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" 9013 9527 9014 9528 [[package]] 9015 9529 name = "uds_windows" ··· 9451 9965 "js-sys", 9452 9966 "jumprope", 9453 9967 "lol_alloc", 9968 + "loro", 9454 9969 "markdown-weaver", 9455 9970 "markdown-weaver-escape", 9456 9971 "mime-sniffer",
+2 -1
crates/weaver-app/Cargo.toml
··· 50 50 tracing.workspace = true 51 51 serde_ipld_dagcbor = { version = "0.6" } 52 52 jumprope = { version = "1.1", features = ["wchar_conversion"] } 53 + loro = "1.9.1" 53 54 markdown-weaver-escape = { workspace = true } 54 55 55 56 [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] ··· 65 66 chrono = { version = "0.4", features = ["wasmbind"] } 66 67 wasm-bindgen = "0.2" 67 68 wasm-bindgen-futures = "0.4" 68 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter"] } 69 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList"] } 69 70 js-sys = "0.3" 70 71 gloo-storage = "0.3" 71 72 gloo-timers = "0.3"
+10 -16
crates/weaver-app/assets/styling/editor.css
··· 88 88 opacity: 0.6; 89 89 } 90 90 91 - .md-syntax-inline:[hidden] { 92 - color: var(--color-muted); 93 - opacity: 0.6; 94 - width: 0; 95 - user-select: none; /* idk if we want this when its hidden or not */ 96 - } 97 - 98 91 /* Markdown syntax characters - block level (#, >, -, etc) */ 99 92 .md-syntax-block { 100 93 color: var(--color-muted); 101 94 opacity: 0.7; 102 - width: 0; 103 95 font-weight: normal; 104 96 } 105 97 106 - .md-syntax-block:[hidden] { 107 - content: attr(data-syntax); 108 - display: inline-block; 109 - margin-right: 4px; 110 - user-select: none; /* idk if we want this when its hidden or not */ 98 + /* Hidden syntax spans - collapsed when cursor is not near */ 99 + .md-syntax-inline.hidden, 100 + .md-syntax-block.hidden { 101 + display: none; 102 + } 103 + 104 + /* Hide HTML list markers when markdown syntax is visible (not hidden) */ 105 + /* This prevents double bullets/numbers when showing "- " or "1. " */ 106 + li:has(.md-syntax-block:not(.hidden)) { 107 + list-style-type: none; 111 108 } 112 109 113 110 /* Cursor positioning helper after <br> */ ··· 119 116 line-height: 1em; 120 117 vertical-align: baseline; 121 118 } 122 - 123 - /* Future: contextual hiding based on cursor position */ 124 - /* .cursor-active .md-syntax-inline { display: none; } */
+345 -16
crates/weaver-app/src/components/editor/mod.rs
··· 13 13 mod rope_writer; 14 14 mod storage; 15 15 mod toolbar; 16 + mod visibility; 16 17 mod writer; 17 18 18 19 #[cfg(test)] ··· 22 23 pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 23 24 pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; 24 25 pub use paragraph::ParagraphRender; 25 - pub use render::{RenderCache, render_paragraphs, render_paragraphs_incremental}; 26 + pub use render::{RenderCache, render_paragraphs_incremental}; 26 27 pub use rope_writer::RopeWriter; 27 28 pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; 28 29 pub use toolbar::EditorToolbar; 29 - pub use writer::WriterResult; 30 + pub use visibility::VisibilityState; 31 + pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; 30 32 31 33 use dioxus::prelude::*; 32 34 ··· 91 93 .collect::<Vec<_>>() 92 94 }); 93 95 96 + // Flatten syntax spans from all paragraphs 97 + let syntax_spans = use_memo(move || { 98 + paragraphs() 99 + .iter() 100 + .flat_map(|p| p.syntax_spans.iter().cloned()) 101 + .collect::<Vec<_>>() 102 + }); 103 + 94 104 // Cache paragraphs for change detection AND for event handlers to access 95 105 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 96 106 ··· 129 139 } 130 140 131 141 // Store for next comparison AND for event handlers (write-only, no reactive read) 132 - cached_paragraphs.set(new_paras); 142 + cached_paragraphs.set(new_paras.clone()); 143 + 144 + // Update syntax visibility after DOM changes 145 + let doc = document(); 146 + let spans = syntax_spans(); 147 + update_syntax_visibility( 148 + doc.cursor.offset, 149 + doc.selection.as_ref(), 150 + &spans, 151 + &new_paras, 152 + ); 133 153 }); 134 154 135 155 // Auto-save with debounce ··· 180 200 if dominated { 181 201 let paras = cached_paragraphs(); 182 202 sync_cursor_from_dom(&mut document, editor_id, &paras); 203 + // Update syntax visibility after cursor sync 204 + let doc = document(); 205 + let spans = syntax_spans(); 206 + update_syntax_visibility( 207 + doc.cursor.offset, 208 + doc.selection.as_ref(), 209 + &spans, 210 + &paras, 211 + ); 183 212 } 184 213 }, 185 214 ··· 187 216 // After mouse click, sync cursor from DOM 188 217 let paras = cached_paragraphs(); 189 218 sync_cursor_from_dom(&mut document, editor_id, &paras); 219 + // Update syntax visibility after cursor sync 220 + let doc = document(); 221 + let spans = syntax_spans(); 222 + update_syntax_visibility( 223 + doc.cursor.offset, 224 + doc.selection.as_ref(), 225 + &spans, 226 + &paras, 227 + ); 228 + }, 229 + 230 + onmouseup: move |_evt| { 231 + // After drag selection, sync cursor/selection from DOM 232 + let paras = cached_paragraphs(); 233 + sync_cursor_from_dom(&mut document, editor_id, &paras); 234 + // Update syntax visibility after cursor sync 235 + let doc = document(); 236 + let spans = syntax_spans(); 237 + update_syntax_visibility( 238 + doc.cursor.offset, 239 + doc.selection.as_ref(), 240 + &spans, 241 + &paras, 242 + ); 190 243 }, 191 244 192 245 onpaste: move |evt| { ··· 195 248 196 249 oncut: move |evt| { 197 250 handle_cut(evt, &mut document); 251 + }, 252 + 253 + oncopy: move |evt| { 254 + handle_copy(evt, &document); 198 255 }, 199 256 } 200 257 ··· 409 466 // No-op on non-wasm 410 467 } 411 468 469 + /// Update syntax span visibility in the DOM based on cursor position. 470 + /// 471 + /// Toggles the "hidden" class on syntax spans based on calculated visibility. 472 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 473 + fn update_syntax_visibility( 474 + cursor_offset: usize, 475 + selection: Option<&Selection>, 476 + syntax_spans: &[SyntaxSpanInfo], 477 + paragraphs: &[ParagraphRender], 478 + ) { 479 + let visibility = 480 + visibility::VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 481 + 482 + let Some(window) = web_sys::window() else { 483 + return; 484 + }; 485 + let Some(document) = window.document() else { 486 + return; 487 + }; 488 + 489 + // Update each syntax span's visibility 490 + for span in syntax_spans { 491 + let selector = format!("[data-syn-id='{}']", span.syn_id); 492 + if let Ok(Some(element)) = document.query_selector(&selector) { 493 + let class_list = element.class_list(); 494 + if visibility.is_visible(&span.syn_id) { 495 + let _ = class_list.remove_1("hidden"); 496 + } else { 497 + let _ = class_list.add_1("hidden"); 498 + } 499 + } 500 + } 501 + } 502 + 503 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 504 + fn update_syntax_visibility( 505 + _cursor_offset: usize, 506 + _selection: Option<&Selection>, 507 + _syntax_spans: &[SyntaxSpanInfo], 508 + _paragraphs: &[ParagraphRender], 509 + ) { 510 + // No-op on non-wasm 511 + } 512 + 412 513 /// Handle paste events and insert text at cursor 413 514 fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 414 515 tracing::info!("[PASTE] handle_paste called"); ··· 444 545 } 445 546 } 446 547 447 - /// Handle cut events - browser copies selection, we delete it from rope 448 - /// Selection is synced via onkeyup/onclick, so doc.selection should be current 449 - fn handle_cut(_evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 548 + /// Handle cut events - extract text, write to clipboard, then delete from rope 549 + fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 450 550 tracing::info!("[CUT] handle_cut called"); 451 551 452 - document.with_mut(|doc| { 453 - if let Some(sel) = doc.selection { 454 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 455 - if start != end { 456 - tracing::info!("[CUT] Deleting selection {}..{}", start, end); 457 - doc.rope.remove(start..end); 458 - doc.cursor.offset = start; 459 - doc.selection = None; 552 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 553 + { 554 + use dioxus::web::WebEventExt; 555 + use wasm_bindgen::JsCast; 556 + 557 + let base_evt = evt.as_web_event(); 558 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 559 + document.with_mut(|doc| { 560 + if let Some(sel) = doc.selection { 561 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 562 + if start != end { 563 + // Extract text from rope 564 + let selected_text = extract_rope_slice(&doc.rope, start, end); 565 + tracing::info!( 566 + "[CUT] Extracted {} chars: {:?}", 567 + selected_text.len(), 568 + &selected_text[..selected_text.len().min(50)] 569 + ); 570 + 571 + // Write to clipboard BEFORE deleting 572 + if let Some(data_transfer) = clipboard_evt.clipboard_data() { 573 + if let Err(e) = data_transfer.set_data("text/plain", &selected_text) { 574 + tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 575 + } 576 + } 577 + 578 + // Now delete from rope 579 + doc.rope.remove(start..end); 580 + doc.cursor.offset = start; 581 + doc.selection = None; 582 + } 583 + } 584 + }); 585 + } 586 + } 587 + 588 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 589 + { 590 + let _ = evt; // suppress unused warning 591 + } 592 + } 593 + 594 + /// Handle copy events - extract text from rope, clean it up, write to clipboard 595 + fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) { 596 + tracing::info!("[COPY] handle_copy called"); 597 + 598 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 599 + { 600 + use dioxus::web::WebEventExt; 601 + use wasm_bindgen::JsCast; 602 + 603 + let base_evt = evt.as_web_event(); 604 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 605 + let doc = document.read(); 606 + if let Some(sel) = doc.selection { 607 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 608 + if start != end { 609 + // Extract text from rope 610 + let selected_text = extract_rope_slice(&doc.rope, start, end); 611 + 612 + // Strip zero-width chars used for gap handling 613 + let clean_text = selected_text 614 + .replace('\u{200C}', "") 615 + .replace('\u{200B}', ""); 616 + 617 + tracing::info!( 618 + "[COPY] Extracted {} chars (cleaned to {})", 619 + selected_text.len(), 620 + clean_text.len() 621 + ); 622 + 623 + // Write to clipboard 624 + if let Some(data_transfer) = clipboard_evt.clipboard_data() { 625 + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 626 + tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 627 + } 628 + } 629 + 630 + // Prevent browser's default copy (which would copy rendered HTML) 631 + evt.prevent_default(); 632 + } 460 633 } 461 634 } 462 - }); 635 + } 636 + 637 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 638 + { 639 + let _ = (evt, document); // suppress unused warnings 640 + } 641 + } 642 + 643 + /// Extract a slice of text from the rope as a String 644 + fn extract_rope_slice(rope: &jumprope::JumpRopeBuf, start: usize, end: usize) -> String { 645 + let mut result = String::new(); 646 + let rope_ref = rope.borrow(); 647 + for substr in rope_ref.slice_substrings(start..end) { 648 + result.push_str(substr); 649 + } 650 + result 463 651 } 464 652 465 653 /// Handle keyboard events and update document state ··· 589 777 // Shift+Enter: hard line break (soft break) 590 778 doc.rope.insert(doc.cursor.offset, " \n\u{200C}"); 591 779 doc.cursor.offset += 3; 780 + } else if let Some(ctx) = detect_list_context(&doc.rope, doc.cursor.offset) { 781 + // We're in a list item 782 + tracing::debug!("[ENTER] List context detected: {:?}", ctx); 783 + tracing::debug!( 784 + "[ENTER] Cursor at {}, rope len {}", 785 + doc.cursor.offset, 786 + doc.rope.len_chars() 787 + ); 788 + if is_list_item_empty(&doc.rope, doc.cursor.offset, &ctx) { 789 + tracing::debug!("[ENTER] Item is empty, exiting list"); 790 + // Empty item - exit list by removing marker and inserting paragraph break 791 + let line_start = find_line_start(&doc.rope, doc.cursor.offset); 792 + let line_end = find_line_end(&doc.rope, doc.cursor.offset); 793 + 794 + // Delete the empty list item line INCLUDING its trailing newline 795 + // line_end points to the newline, so +1 to include it 796 + let delete_end = (line_end + 1).min(doc.rope.len_chars()); 797 + 798 + doc.rope.remove(line_start..delete_end); 799 + doc.cursor.offset = line_start; 800 + 801 + // Insert two newlines, a zero-width whitespace character, and then another 802 + // newline to properly split the list (TODO: clean up the weird whitespace 803 + // char once that new paragraph has content) 804 + doc.rope.insert(doc.cursor.offset, "\n\n\u{200C}\n"); 805 + doc.cursor.offset += 2; 806 + } else { 807 + // Non-empty item - continue list 808 + let continuation = match ctx { 809 + ListContext::Unordered { indent, marker } => { 810 + format!("\n{}{} ", indent, marker) 811 + } 812 + ListContext::Ordered { indent, number } => { 813 + format!("\n{}{}. ", indent, number + 1) 814 + } 815 + }; 816 + let len = continuation.chars().count(); 817 + doc.rope.insert(doc.cursor.offset, &continuation); 818 + doc.cursor.offset += len; 819 + } 592 820 } else { 593 - // Enter: paragraph break 821 + // Not in a list - normal paragraph break 594 822 doc.rope.insert(doc.cursor.offset, "\n\n"); 595 823 doc.cursor.offset += 2; 596 824 } ··· 604 832 _ => {} 605 833 } 606 834 }); 835 + } 836 + 837 + /// Describes what kind of list item the cursor is in, if any 838 + #[derive(Debug, Clone)] 839 + enum ListContext { 840 + /// Unordered list with the given marker char ('-' or '*') and indentation 841 + Unordered { indent: String, marker: char }, 842 + /// Ordered list with the current number and indentation 843 + Ordered { indent: String, number: usize }, 844 + } 845 + 846 + /// Detect if cursor is in a list item and return context for continuation. 847 + /// 848 + /// Scans backwards to find start of current line, then checks for list marker. 849 + fn detect_list_context(rope: &jumprope::JumpRopeBuf, cursor_offset: usize) -> Option<ListContext> { 850 + // Find start of current line 851 + let line_start = find_line_start(rope, cursor_offset); 852 + 853 + // Get the line content from start to cursor 854 + let line_end = find_line_end(rope, cursor_offset); 855 + if line_start >= line_end { 856 + return None; 857 + } 858 + 859 + // Extract line text 860 + let mut line = String::new(); 861 + let rope_ref = rope.borrow(); 862 + for substr in rope_ref.slice_substrings(line_start..line_end) { 863 + line.push_str(substr); 864 + } 865 + 866 + // Parse indentation 867 + let indent: String = line 868 + .chars() 869 + .take_while(|c| *c == ' ' || *c == '\t') 870 + .collect(); 871 + let trimmed = &line[indent.len()..]; 872 + 873 + // Check for unordered list marker: "- " or "* " 874 + if trimmed.starts_with("- ") { 875 + return Some(ListContext::Unordered { 876 + indent, 877 + marker: '-', 878 + }); 879 + } 880 + if trimmed.starts_with("* ") { 881 + return Some(ListContext::Unordered { 882 + indent, 883 + marker: '*', 884 + }); 885 + } 886 + 887 + // Check for ordered list marker: "1. ", "2. ", "123. ", etc. 888 + if let Some(dot_pos) = trimmed.find(". ") { 889 + let num_part = &trimmed[..dot_pos]; 890 + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { 891 + if let Ok(number) = num_part.parse::<usize>() { 892 + return Some(ListContext::Ordered { indent, number }); 893 + } 894 + } 895 + } 896 + 897 + None 898 + } 899 + 900 + /// Check if the current list item is empty (just the marker, no content after cursor). 901 + /// 902 + /// Used to determine whether Enter should continue the list or exit it. 903 + fn is_list_item_empty( 904 + rope: &jumprope::JumpRopeBuf, 905 + cursor_offset: usize, 906 + ctx: &ListContext, 907 + ) -> bool { 908 + let line_start = find_line_start(rope, cursor_offset); 909 + let line_end = find_line_end(rope, cursor_offset); 910 + 911 + // Get line content 912 + let mut line = String::new(); 913 + let rope_ref = rope.borrow(); 914 + for substr in rope_ref.slice_substrings(line_start..line_end) { 915 + line.push_str(substr); 916 + } 917 + 918 + // Calculate expected marker length 919 + let marker_len = match ctx { 920 + ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " 921 + ListContext::Ordered { indent, number } => { 922 + indent.len() + number.to_string().len() + 2 // "1. " 923 + } 924 + }; 925 + 926 + tracing::debug!( 927 + "[LIST] is_empty check: line={:?}, line.len()={}, marker_len={}, result={}", 928 + line, 929 + line.len(), 930 + marker_len, 931 + line.len() <= marker_len 932 + ); 933 + 934 + // Item is empty if line length equals marker length (nothing after marker) 935 + line.len() <= marker_len 607 936 } 608 937 609 938 /// Get character at the given offset in the rope
+24 -14
crates/weaver-app/src/components/editor/offset_map.rs
··· 122 122 // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. 123 123 // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), 124 124 // prefer the NEXT mapping so cursor goes "down" to new content. 125 - let idx = offset_map 126 - .binary_search_by(|mapping| { 127 - if mapping.char_range.end <= char_offset { 128 - // Cursor is at or after end of this mapping - look forward 129 - std::cmp::Ordering::Less 130 - } else if mapping.char_range.start > char_offset { 131 - // Cursor is before this mapping 132 - std::cmp::Ordering::Greater 125 + let result = offset_map.binary_search_by(|mapping| { 126 + if mapping.char_range.end <= char_offset { 127 + // Cursor is at or after end of this mapping - look forward 128 + std::cmp::Ordering::Less 129 + } else if mapping.char_range.start > char_offset { 130 + // Cursor is before this mapping 131 + std::cmp::Ordering::Greater 132 + } else { 133 + // Cursor is within [start, end) 134 + std::cmp::Ordering::Equal 135 + } 136 + }); 137 + 138 + let mapping = match result { 139 + Ok(idx) => &offset_map[idx], 140 + Err(idx) => { 141 + // No exact match - cursor is at boundary between mappings (or past end) 142 + // If cursor is exactly at end of previous mapping, return that mapping 143 + // This handles cursor at end of document or end of last mapping 144 + if idx > 0 && offset_map[idx - 1].char_range.end == char_offset { 145 + &offset_map[idx - 1] 133 146 } else { 134 - // Cursor is within [start, end) 135 - std::cmp::Ordering::Equal 147 + return None; 136 148 } 137 - }) 138 - .ok()?; 149 + } 150 + }; 139 151 140 - let mapping = &offset_map[idx]; 141 152 let should_snap = mapping.is_invisible(); 142 - 143 153 Some((mapping, should_snap)) 144 154 } 145 155
+4
crates/weaver-app/src/components/editor/paragraph.rs
··· 4 4 //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. 5 5 6 6 use super::offset_map::OffsetMapping; 7 + use super::writer::SyntaxSpanInfo; 7 8 use jumprope::JumpRopeBuf; 8 9 use std::ops::Range; 9 10 ··· 21 22 22 23 /// Offset mappings for this paragraph 23 24 pub offset_map: Vec<OffsetMapping>, 25 + 26 + /// Syntax spans for conditional visibility 27 + pub syntax_spans: Vec<SyntaxSpanInfo>, 24 28 25 29 /// Hash of source text for quick change detection 26 30 pub source_hash: u64,
+97 -230
crates/weaver-app/src/components/editor/render.rs
··· 7 7 use super::document::EditInfo; 8 8 use super::offset_map::{OffsetMapping, RenderResult}; 9 9 use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; 10 - use super::writer::EditorWriter; 10 + use super::writer::{EditorWriter, SyntaxSpanInfo}; 11 11 use jumprope::JumpRopeBuf; 12 12 use markdown_weaver::Parser; 13 13 use std::ops::Range; ··· 20 20 pub paragraphs: Vec<CachedParagraph>, 21 21 /// Next available node ID for fresh renders 22 22 pub next_node_id: usize, 23 + /// Next available syntax span ID for fresh renders 24 + pub next_syn_id: usize, 23 25 } 24 26 25 27 /// A cached paragraph render that can be reused if source hasn't changed. ··· 35 37 pub html: String, 36 38 /// Offset mappings for cursor positioning 37 39 pub offset_map: Vec<OffsetMapping>, 38 - } 39 - 40 - /// Render markdown in paragraph chunks for incremental DOM updates. 41 - /// 42 - /// First renders the whole document to discover paragraph boundaries via 43 - /// markdown events (Tag::Paragraph), then re-renders each paragraph separately. 44 - /// This allows updating only changed paragraphs in the DOM, preserving cursor 45 - /// position naturally. 46 - /// 47 - /// # Returns 48 - /// 49 - /// A vector of `ParagraphRender` structs, each containing: 50 - /// - Source byte and char ranges 51 - /// - Rendered HTML (without wrapper div) 52 - /// - Offset mappings for that paragraph 53 - /// - Source hash for change detection 54 - /// 55 - /// # Phase 2 Benefits 56 - /// - Only re-render changed paragraphs 57 - /// - Browser preserves cursor in unchanged paragraphs naturally 58 - /// - Faster for large documents 59 - /// - No manual cursor restoration needed for most edits 60 - pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec<ParagraphRender> { 61 - let source = rope.to_string(); 62 - 63 - // Handle empty rope - return single empty paragraph for cursor positioning 64 - if source.is_empty() { 65 - let empty_node_id = "n0".to_string(); 66 - let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 67 - 68 - return vec![ParagraphRender { 69 - byte_range: 0..0, 70 - char_range: 0..0, 71 - html: empty_html, 72 - offset_map: vec![], 73 - source_hash: 0, 74 - }]; 75 - } 76 - 77 - // First pass: render whole document to get paragraph boundaries 78 - // TODO: CACHE THIS! 79 - let parser = Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); 80 - let mut scratch_output = String::new(); 81 - 82 - let paragraph_ranges = 83 - match EditorWriter::<_, _, ()>::new(&source, rope, parser, &mut scratch_output).run() { 84 - Ok(result) => result.paragraph_ranges, 85 - Err(_) => return Vec::new(), 86 - }; 87 - 88 - // Second pass: render each paragraph separately 89 - let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 90 - let mut node_id_offset = 0; // Track total nodes used so far for unique IDs 91 - 92 - for (_idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 93 - // Extract paragraph source 94 - let para_source = rope_slice_to_string(rope, char_range.clone()); 95 - let source_hash = hash_source(&para_source); 96 - 97 - // Render this paragraph with unique node IDs 98 - let para_rope = JumpRopeBuf::from(para_source.as_str()); 99 - let parser = 100 - Parser::new_ext(&para_source, weaver_renderer::default_md_options()).into_offset_iter(); 101 - let mut output = String::new(); 102 - 103 - let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( 104 - &para_source, 105 - &para_rope, 106 - parser, 107 - &mut output, 108 - node_id_offset, 109 - ) 110 - .run() 111 - { 112 - Ok(result) => { 113 - // Update node ID offset for next paragraph 114 - // Count how many unique node IDs were used in this paragraph 115 - let max_node_id = result 116 - .offset_maps 117 - .iter() 118 - .filter_map(|m| { 119 - m.node_id 120 - .strip_prefix("n") 121 - .and_then(|s| s.parse::<usize>().ok()) 122 - }) 123 - .max() 124 - .unwrap_or(node_id_offset); 125 - node_id_offset = max_node_id + 1; 126 - 127 - result.offset_maps 128 - } 129 - Err(_) => Vec::new(), 130 - }; 131 - 132 - // Adjust offset map to be relative to document, not paragraph 133 - // Each mapping's ranges need to be shifted by paragraph start 134 - let para_char_start = char_range.start; 135 - let para_byte_start = byte_range.start; 136 - 137 - for mapping in &mut offset_map { 138 - mapping.byte_range.start += para_byte_start; 139 - mapping.byte_range.end += para_byte_start; 140 - mapping.char_range.start += para_char_start; 141 - mapping.char_range.end += para_char_start; 142 - } 143 - 144 - paragraphs.push(ParagraphRender { 145 - byte_range: byte_range.clone(), 146 - char_range: char_range.clone(), 147 - html: output, 148 - offset_map, 149 - source_hash, 150 - }); 151 - } 152 - 153 - // Insert gap paragraphs for EXTRA whitespace between blocks. 154 - // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. 155 - // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. 156 - // Gap IDs are position-based for stability across renders. 157 - const MIN_PARAGRAPH_BREAK: usize = 2; // \n\n 158 - 159 - let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); 160 - let mut prev_end_char = 0usize; 161 - let mut prev_end_byte = 0usize; 162 - 163 - for para in paragraphs { 164 - // Check for gap before this paragraph - only if MORE than minimum break 165 - let gap_size = para.char_range.start.saturating_sub(prev_end_char); 166 - if gap_size > MIN_PARAGRAPH_BREAK { 167 - // Gap covers the EXTRA whitespace beyond the minimum break 168 - let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK; 169 - let gap_end_char = para.char_range.start; 170 - let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK; 171 - let gap_end_byte = para.byte_range.start; 172 - 173 - // Position-based ID: deterministic, stable across cache states 174 - let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); 175 - let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}'); 176 - 177 - paragraphs_with_gaps.push(ParagraphRender { 178 - byte_range: gap_start_byte..gap_end_byte, 179 - char_range: gap_start_char..gap_end_char, 180 - html: gap_html, 181 - offset_map: vec![OffsetMapping { 182 - byte_range: gap_start_byte..gap_end_byte, 183 - char_range: gap_start_char..gap_end_char, 184 - node_id: gap_node_id, 185 - char_offset_in_node: 0, 186 - child_index: None, 187 - utf16_len: 1, // zero-width space represents the gap 188 - }], 189 - source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), 190 - }); 191 - } 192 - 193 - prev_end_char = para.char_range.end; 194 - prev_end_byte = para.byte_range.end; 195 - paragraphs_with_gaps.push(para); 196 - } 197 - 198 - // Check if rope ends with trailing newlines (empty paragraph at end) 199 - // If so, add an empty paragraph div for cursor positioning 200 - let source = rope.to_string(); 201 - let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); 202 - 203 - if has_trailing_newlines { 204 - let doc_end_char = rope.len_chars(); 205 - let doc_end_byte = rope.len_bytes(); 206 - 207 - // Only add if there's actually a gap at the end 208 - if doc_end_char > prev_end_char { 209 - // Position-based ID for trailing gap 210 - let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); 211 - let trailing_html = format!(r#"<span id="{}">{}</span>"#, trailing_node_id, '\u{200B}'); 212 - 213 - paragraphs_with_gaps.push(ParagraphRender { 214 - byte_range: prev_end_byte..doc_end_byte, 215 - char_range: prev_end_char..doc_end_char, 216 - html: trailing_html, 217 - offset_map: vec![OffsetMapping { 218 - byte_range: prev_end_byte..doc_end_byte, 219 - char_range: prev_end_char..doc_end_char, 220 - node_id: trailing_node_id, 221 - char_offset_in_node: 0, 222 - child_index: None, 223 - utf16_len: 1, // zero-width space is 1 UTF-16 code unit 224 - }], 225 - source_hash: 0, // always render this paragraph 226 - }); 227 - } 228 - } 229 - 230 - paragraphs_with_gaps 40 + /// Syntax spans for conditional visibility 41 + pub syntax_spans: Vec<SyntaxSpanInfo>, 231 42 } 232 43 233 44 /// Check if an edit affects paragraph boundaries. ··· 261 72 mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; 262 73 mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize; 263 74 mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; 75 + } 76 + 77 + let mut adjusted_syntax = cached.syntax_spans.clone(); 78 + for span in &mut adjusted_syntax { 79 + span.char_range.start = (span.char_range.start as isize + char_delta) as usize; 80 + span.char_range.end = (span.char_range.end as isize + char_delta) as usize; 264 81 } 265 82 266 83 ParagraphRender { ··· 270 87 ..(cached.char_range.end as isize + char_delta) as usize, 271 88 html: cached.html.clone(), 272 89 offset_map: adjusted_map, 90 + syntax_spans: adjusted_syntax, 273 91 source_hash: cached.source_hash, 274 92 } 275 93 } ··· 303 121 char_range: 0..0, 304 122 html: empty_html.clone(), 305 123 offset_map: vec![], 124 + syntax_spans: vec![], 306 125 source_hash: 0, 307 126 }; 308 127 ··· 313 132 char_range: 0..0, 314 133 html: empty_html, 315 134 offset_map: vec![], 135 + syntax_spans: vec![], 316 136 }], 317 137 next_node_id: 1, 138 + next_syn_id: 0, 318 139 }; 319 140 320 141 return (vec![para], new_cache); ··· 380 201 } 381 202 }; 382 203 204 + tracing::debug!("[RENDER] Discovered {} paragraph ranges", paragraph_ranges.len()); 205 + for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 206 + tracing::debug!("[RENDER] Range {}: bytes {:?}, chars {:?}", i, byte_range, char_range); 207 + } 208 + 383 209 // Render paragraphs, reusing cache where possible 384 210 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 385 211 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 386 212 let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); 213 + let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); 387 214 388 - for (byte_range, char_range) in paragraph_ranges.iter() { 215 + for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 389 216 let para_source = rope_slice_to_string(rope, char_range.clone()); 390 217 let source_hash = hash_source(&para_source); 391 218 219 + tracing::debug!( 220 + "[RENDER] Para {}: char_range {:?}, source preview: {:?}", 221 + idx, 222 + char_range, 223 + &para_source[..para_source.len().min(50)] 224 + ); 225 + 392 226 // Check if we have a cached render with matching hash 393 227 let cached_match = 394 228 cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); 395 229 396 - let (html, offset_map) = if let Some(cached) = cached_match { 397 - // Reuse cached HTML and offset map (adjusted for position) 230 + let (html, offset_map, syntax_spans) = if let Some(cached) = cached_match { 231 + // Reuse cached HTML, offset map, and syntax spans (adjusted for position) 398 232 let char_delta = char_range.start as isize - cached.char_range.start as isize; 399 233 let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; 400 234 ··· 408 242 mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; 409 243 } 410 244 411 - (cached.html.clone(), adjusted_map) 245 + let mut adjusted_syntax = cached.syntax_spans.clone(); 246 + for span in &mut adjusted_syntax { 247 + span.char_range.start = (span.char_range.start as isize + char_delta) as usize; 248 + span.char_range.end = (span.char_range.end as isize + char_delta) as usize; 249 + } 250 + 251 + (cached.html.clone(), adjusted_map, adjusted_syntax) 412 252 } else { 413 253 // Fresh render needed 414 254 let para_rope = JumpRopeBuf::from(para_source.as_str()); ··· 416 256 .into_offset_iter(); 417 257 let mut output = String::new(); 418 258 419 - let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( 420 - &para_source, 421 - &para_rope, 422 - parser, 423 - &mut output, 424 - node_id_offset, 425 - ) 426 - .run() 427 - { 428 - Ok(result) => { 429 - // Update node ID offset 430 - let max_node_id = result 431 - .offset_maps 432 - .iter() 433 - .filter_map(|m| { 434 - m.node_id 435 - .strip_prefix("n") 436 - .and_then(|s| s.parse::<usize>().ok()) 437 - }) 438 - .max() 439 - .unwrap_or(node_id_offset); 440 - node_id_offset = max_node_id + 1; 441 - result.offset_maps 442 - } 443 - Err(_) => Vec::new(), 444 - }; 259 + let (mut offset_map, mut syntax_spans) = 260 + match EditorWriter::<_, _, ()>::new_with_offsets( 261 + &para_source, 262 + &para_rope, 263 + parser, 264 + &mut output, 265 + node_id_offset, 266 + syn_id_offset, 267 + ) 268 + .run() 269 + { 270 + Ok(result) => { 271 + // Update node ID offset 272 + let max_node_id = result 273 + .offset_maps 274 + .iter() 275 + .filter_map(|m| { 276 + m.node_id 277 + .strip_prefix("n") 278 + .and_then(|s| s.parse::<usize>().ok()) 279 + }) 280 + .max() 281 + .unwrap_or(node_id_offset); 282 + node_id_offset = max_node_id + 1; 283 + 284 + // Update syn ID offset 285 + let max_syn_id = result 286 + .syntax_spans 287 + .iter() 288 + .filter_map(|s| { 289 + s.syn_id 290 + .strip_prefix("s") 291 + .and_then(|id| id.parse::<usize>().ok()) 292 + }) 293 + .max() 294 + .unwrap_or(syn_id_offset.saturating_sub(1)); 295 + syn_id_offset = max_syn_id + 1; 296 + 297 + (result.offset_maps, result.syntax_spans) 298 + } 299 + Err(_) => (Vec::new(), Vec::new()), 300 + }; 445 301 446 302 // Adjust offsets to document coordinates 447 303 let para_char_start = char_range.start; ··· 452 308 mapping.char_range.start += para_char_start; 453 309 mapping.char_range.end += para_char_start; 454 310 } 311 + for span in &mut syntax_spans { 312 + span.char_range.start += para_char_start; 313 + span.char_range.end += para_char_start; 314 + } 455 315 456 - (output, offset_map) 316 + (output, offset_map, syntax_spans) 457 317 }; 458 318 459 319 // Store in cache ··· 463 323 char_range: char_range.clone(), 464 324 html: html.clone(), 465 325 offset_map: offset_map.clone(), 326 + syntax_spans: syntax_spans.clone(), 466 327 }); 467 328 468 329 paragraphs.push(ParagraphRender { ··· 470 331 char_range: char_range.clone(), 471 332 html, 472 333 offset_map, 334 + syntax_spans, 473 335 source_hash, 474 336 }); 475 337 } ··· 487 349 // Check for gap before this paragraph - only if MORE than minimum break 488 350 let gap_size = para.char_range.start.saturating_sub(prev_end_char); 489 351 if gap_size > MIN_PARAGRAPH_BREAK_INCR { 490 - // Gap covers the EXTRA whitespace beyond the minimum break 352 + // Visible gap element covers EXTRA whitespace beyond minimum break 491 353 let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; 492 354 let gap_end_char = para.char_range.start; 493 355 let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; ··· 497 359 let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); 498 360 let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}'); 499 361 362 + // Gap paragraph covers ALL whitespace (like trailing gaps do) 363 + // so cursor anywhere in the inter-paragraph zone triggers restoration 500 364 paragraphs_with_gaps.push(ParagraphRender { 501 - byte_range: gap_start_byte..gap_end_byte, 502 - char_range: gap_start_char..gap_end_char, 365 + byte_range: prev_end_byte..gap_end_byte, 366 + char_range: prev_end_char..gap_end_char, 503 367 html: gap_html, 504 368 offset_map: vec![OffsetMapping { 505 - byte_range: gap_start_byte..gap_end_byte, 506 - char_range: gap_start_char..gap_end_char, 369 + byte_range: prev_end_byte..gap_end_byte, 370 + char_range: prev_end_char..gap_end_char, 507 371 node_id: gap_node_id, 508 372 char_offset_in_node: 0, 509 373 child_index: None, 510 374 utf16_len: 1, 511 375 }], 376 + syntax_spans: vec![], 512 377 source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), 513 378 }); 514 379 } ··· 541 406 child_index: None, 542 407 utf16_len: 1, 543 408 }], 409 + syntax_spans: vec![], 544 410 source_hash: 0, 545 411 }); 546 412 } ··· 549 415 let new_cache = RenderCache { 550 416 paragraphs: new_cached, 551 417 next_node_id: node_id_offset, 418 + next_syn_id: syn_id_offset, 552 419 }; 553 420 554 421 (paragraphs_with_gaps, new_cache)
+5 -5
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 18 11 - html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\">&gt; </span>This is a quote<span class=\"md-syntax-inline\">\n</span></p>\n</blockquote>\n" 11 + html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">&gt; </span>This is a quote<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"17\" data-char-end=\"18\">\n</span></p>\n</blockquote>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 43 ··· 52 52 utf16_len: 1 53 53 source_hash: 11268153476336590706 54 54 - byte_range: 55 - - 20 55 + - 18 56 56 - 22 57 57 char_range: 58 - - 20 58 + - 18 59 59 - 22 60 60 html: "<span id=\"gap-20-22\">​</span>" 61 61 offset_map: 62 62 - byte_range: 63 - - 20 63 + - 18 64 64 - 22 65 65 char_range: 66 - - 20 66 + - 18 67 67 - 22 68 68 node_id: gap-20-22 69 69 char_offset_in_node: 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 18 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">**</span><strong>bold<span class=\"md-syntax-inline\">**</span></strong> text</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"7\">**</span><strong>bold<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\">**</span></strong> text</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 27 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">*</span><em><span class=\"md-syntax-inline\">**</span><strong>bold italic<span class=\"md-syntax-inline\">**</span></strong><span class=\"md-syntax-inline\">*</span></em> text</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span><strong>bold italic<span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"21\">**</span></strong><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">*</span></em> text</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 24 11 - html: "<span class=\"md-syntax-block\">```rust</span>\n<pre><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\">```</span>" 11 + html: "<span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"8\">```rust</span>\n<pre><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"21\" data-char-end=\"24\">```</span>" 12 12 offset_map: 13 13 - byte_range: 14 14 - 8
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 10 11 - html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\"># </span>Heading<span class=\"md-syntax-inline\">\n</span></h1>\n" 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"10\">\n</span></h1>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 19 11 - html: "<p id=\"n0\">Line one<span class=\"md-syntax-inline\"> </span><br />​Line two</p>\n" 11 + html: "<p id=\"n0\">Line one<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"8\" data-char-end=\"10\"> </span><br />​Line two</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 11 11 - html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\"># </span>Heading 1</h1>\n" 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading 1</h1>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+4 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 5 11 - html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\"># </span>H1<span class=\"md-syntax-inline\">\n</span></h1>\n" 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>H1<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"4\" data-char-end=\"5\">\n</span></h1>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 57 57 char_range: 58 58 - 6 59 59 - 12 60 - html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\">## </span>H2<span class=\"md-syntax-inline\">\n</span></h2>\n" 60 + html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"0\" data-char-end=\"3\">## </span>H2<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"5\" data-char-end=\"6\">\n</span></h2>\n" 61 61 offset_map: 62 62 - byte_range: 63 63 - 6 ··· 106 106 char_range: 107 107 - 13 108 108 - 20 109 - html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\">### </span>H3<span class=\"md-syntax-inline\">\n</span></h3>\n" 109 + html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s4\" data-char-start=\"0\" data-char-end=\"4\">### </span>H3<span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"6\" data-char-end=\"7\">\n</span></h3>\n" 110 110 offset_map: 111 111 - byte_range: 112 112 - 13 ··· 155 155 char_range: 156 156 - 21 157 157 - 28 158 - html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\">#### </span>H4</h4>\n" 158 + html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\" data-syn-id=\"s6\" data-char-start=\"0\" data-char-end=\"5\">#### </span>H4</h4>\n" 159 159 offset_map: 160 160 - byte_range: 161 161 - 21
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 16 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">`</span><code>code</code><span class=\"md-syntax-inline\">`</span> here</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"10\" data-char-end=\"11\">`</span> here</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 18 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">*</span><em>italic<span class=\"md-syntax-inline\">*</span></em> text</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em>italic<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"13\">*</span></em> text</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+5 -5
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 6 11 - html: "<p id=\"n0\">First<span class=\"md-syntax-inline\">\n</span></p>\n" 11 + html: "<p id=\"n0\">First<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">\n</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 42 42 utf16_len: 1 43 43 source_hash: 6799294212516041738 44 44 - byte_range: 45 - - 8 45 + - 6 46 46 - 9 47 47 char_range: 48 - - 8 48 + - 6 49 49 - 9 50 50 html: "<span id=\"gap-8-9\">​</span>" 51 51 offset_map: 52 52 - byte_range: 53 - - 8 53 + - 6 54 54 - 9 55 55 char_range: 56 - - 8 56 + - 6 57 57 - 9 58 58 node_id: gap-8-9 59 59 char_offset_in_node: 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 32 11 - html: "<p id=\"n0\"><span class=\"md-syntax-inline\">**</span><strong>Bold<span class=\"md-syntax-inline\">**</span></strong> and <span class=\"md-syntax-inline\">*</span><em>italic<span class=\"md-syntax-inline\">*</span></em> and <span class=\"md-syntax-inline\">`</span><code>code</code><span class=\"md-syntax-inline\">`</span></p>\n" 11 + html: "<p id=\"n0\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span></strong> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span></em> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\">`</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+5 -5
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
··· 3 3 expression: result 4 4 --- 5 5 - byte_range: 6 - - 2 6 + - 0 7 7 - 11 8 8 char_range: 9 - - 2 9 + - 0 10 10 - 11 11 11 html: "<span id=\"gap-2-11\">​</span>" 12 12 offset_map: 13 13 - byte_range: 14 - - 2 14 + - 0 15 15 - 11 16 16 char_range: 17 - - 2 17 + - 0 18 18 - 11 19 19 node_id: gap-2-11 20 20 char_offset_in_node: 0 ··· 27 27 char_range: 28 28 - 11 29 29 - 33 30 - html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\">- </span>Child 1<span class=\"md-syntax-inline\">\n </span>\n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\">- </span>Child 2<span class=\"md-syntax-inline\">\n</span></li>\n</ul>\n</li>\n</ul>\n" 30 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Child 1<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\">\n </span>\n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"12\" data-char-end=\"14\">- </span>Child 2<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">\n</span></li>\n</ul>\n</li>\n</ul>\n" 31 31 offset_map: 32 32 - byte_range: 33 33 - 11
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 27 11 - html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\">1.</span><span class=\"md-syntax-inline\"> </span>First<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\">2.</span><span class=\"md-syntax-inline\"> </span>Second<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\">3.</span><span class=\"md-syntax-inline\"> </span>Third</li>\n</ol>\n" 11 + html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">1.</span><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"2\" data-char-end=\"3\"> </span>First<span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"8\" data-char-end=\"9\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"9\" data-char-end=\"11\">2.</span><span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"11\" data-char-end=\"12\"> </span>Second<span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"18\" data-char-end=\"19\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s6\" data-char-start=\"19\" data-char-end=\"21\">3.</span><span class=\"md-syntax-inline\" data-syn-id=\"s7\" data-char-start=\"21\" data-char-end=\"22\"> </span>Third</li>\n</ol>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+2 -2
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 5 11 - html: "<p id=\"n0\">One.<span class=\"md-syntax-inline\">\n</span></p>\n" 11 + html: "<p id=\"n0\">One.<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"4\" data-char-end=\"5\">\n</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 47 47 char_range: 48 48 - 6 49 49 - 11 50 - html: "<p id=\"n1\">Two.<span class=\"md-syntax-inline\">\n</span></p>\n" 50 + html: "<p id=\"n1\">Two.<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"4\" data-char-end=\"5\">\n</span></p>\n" 51 51 offset_map: 52 52 - byte_range: 53 53 - 6
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 6 11 - html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\">\n</span></p>\n" 11 + html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">\n</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 6 11 - html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\">\n</span></p>\n" 11 + html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">\n</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 17 11 - html: "<p id=\"n0\">First paragraph.<span class=\"md-syntax-inline\">\n</span></p>\n" 11 + html: "<p id=\"n0\">First paragraph.<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"16\" data-char-end=\"17\">\n</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 26 11 - html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\">- </span>Item 1<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\">- </span>Item 2<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\">- </span>Item 3</li>\n</ul>\n" 11 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Item 1<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"8\" data-char-end=\"9\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"9\" data-char-end=\"11\">- </span>Item 2<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"17\" data-char-end=\"18\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s4\" data-char-start=\"18\" data-char-end=\"20\">- </span>Item 3</li>\n</ul>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+200 -80
crates/weaver-app/src/components/editor/tests.rs
··· 2 2 3 3 use super::offset_map::{OffsetMapping, find_mapping_for_char}; 4 4 use super::paragraph::ParagraphRender; 5 - use super::render::render_paragraphs; 5 + use super::render::render_paragraphs_incremental; 6 6 use jumprope::JumpRopeBuf; 7 7 use serde::Serialize; 8 8 ··· 55 55 /// Helper: render markdown and convert to serializable test output. 56 56 fn render_test(input: &str) -> Vec<TestParagraph> { 57 57 let rope = JumpRopeBuf::from(input); 58 - let paragraphs = render_paragraphs(&rope); 58 + let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); 59 59 paragraphs.iter().map(TestParagraph::from).collect() 60 60 } 61 61 ··· 410 410 411 411 #[test] 412 412 fn regression_bug11_gap_paragraphs_for_whitespace() { 413 - // Bug #11: Gap paragraphs should be created for inter-block whitespace 414 - let result = render_test("# Title\n\nContent"); 413 + // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace 414 + // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK 415 415 416 - // Check that char ranges cover the full document without gaps 417 - let mut prev_end = 0; 418 - for para in &result { 419 - // Allow gaps to be filled by gap paragraphs 420 - if para.char_range.0 > prev_end { 421 - // This would be a gap - but gap paragraphs should fill it 422 - panic!( 423 - "Gap in char ranges: {}..{} missing coverage", 424 - prev_end, para.char_range.0 425 - ); 426 - } 427 - prev_end = para.char_range.1; 428 - } 416 + // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) 417 + let result = render_test("# Title\n\n\n\nContent"); // 4 newlines 418 + assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); 419 + assert!(result[1].html.contains("gap-"), "Middle element should be a gap"); 420 + 421 + // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) 422 + let result2 = render_test("# Title\n\n\nContent"); // 3 newlines 423 + assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent"); 429 424 } 430 425 431 426 // ============================================================================= ··· 433 428 // ============================================================================= 434 429 435 430 #[test] 436 - fn test_char_range_full_coverage() { 437 - // Verify that char ranges cover entire document 431 + fn test_char_range_coverage_allows_paragraph_breaks() { 432 + // Verify char ranges cover document content, allowing standard \n\n breaks 433 + // The MIN_PARAGRAPH_BREAK zone (2 chars) is intentionally not covered - 434 + // cursor snaps to adjacent paragraphs for standard breaks. 435 + // Only EXTRA whitespace beyond \n\n gets gap elements. 438 436 let input = "Hello\n\nWorld"; 439 437 let rope = JumpRopeBuf::from(input); 440 - let paragraphs = render_paragraphs(&rope); 438 + let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); 441 439 442 - let doc_len = rope.len_chars(); 440 + // With standard \n\n break, we expect 2 paragraphs (no gap element) 441 + // Paragraph ranges include some trailing whitespace from markdown parsing 442 + assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break"); 443 443 444 - // Collect all ranges 445 - let mut ranges: Vec<_> = paragraphs.iter().map(|p| p.char_range.clone()).collect(); 446 - ranges.sort_by_key(|r| r.start); 444 + // First paragraph ends before second starts, with gap for \n\n 445 + let gap_start = paragraphs[0].char_range.end; 446 + let gap_end = paragraphs[1].char_range.start; 447 + let gap_size = gap_end - gap_start; 448 + assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size); 449 + } 447 450 448 - // Check coverage 449 - let mut covered = 0; 450 - for range in &ranges { 451 - assert!( 452 - range.start <= covered, 453 - "Gap at position {}, next range starts at {}", 454 - covered, 455 - range.start 456 - ); 457 - covered = covered.max(range.end); 458 - } 451 + #[test] 452 + fn test_char_range_coverage_with_extra_whitespace() { 453 + // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 454 + // Plain paragraphs don't consume trailing newlines like headings do 455 + let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 456 + let rope = JumpRopeBuf::from(input); 457 + let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); 459 458 460 - assert!( 461 - covered >= doc_len, 462 - "Ranges don't cover full document: covered {} of {}", 463 - covered, 464 - doc_len 465 - ); 459 + // With extra newlines, we expect 3 elements: para, gap, para 460 + assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace"); 461 + 462 + // Gap element should exist and cover whitespace zone 463 + let gap = &paragraphs[1]; 464 + assert!(gap.html.contains("gap-"), "Second element should be a gap"); 465 + 466 + // Gap should cover ALL whitespace (not just extra) 467 + assert_eq!(gap.char_range.start, paragraphs[0].char_range.end, 468 + "Gap should start where first paragraph ends"); 469 + assert_eq!(gap.char_range.end, paragraphs[2].char_range.start, 470 + "Gap should end where second paragraph starts"); 466 471 } 467 472 468 473 #[test] ··· 533 538 // Incremental Rendering Tests 534 539 // ============================================================================= 535 540 536 - use super::render::render_paragraphs_incremental; 537 - 538 - #[test] 539 - fn test_incremental_renders_same_as_full() { 540 - // Incremental render with no cache should produce same result as full render 541 - let input = "# Heading\n\nParagraph with **bold**\n\n- List item"; 542 - let rope = JumpRopeBuf::from(input); 543 - 544 - let full = render_paragraphs(&rope); 545 - let (incremental, _cache) = render_paragraphs_incremental(&rope, None, None); 546 - 547 - // Compare HTML output (hashes may differ due to caching internals) 548 - assert_eq!( 549 - full.len(), 550 - incremental.len(), 551 - "Different paragraph count: full={}, incr={}", 552 - full.len(), 553 - incremental.len() 554 - ); 555 - 556 - for (i, (f, inc)) in full.iter().zip(incremental.iter()).enumerate() { 557 - assert_eq!( 558 - f.html, inc.html, 559 - "Paragraph {} HTML differs:\nFull: {}\nIncr: {}", 560 - i, f.html, inc.html 561 - ); 562 - assert_eq!( 563 - f.byte_range, inc.byte_range, 564 - "Paragraph {} byte_range differs", 565 - i 566 - ); 567 - assert_eq!( 568 - f.char_range, inc.char_range, 569 - "Paragraph {} char_range differs", 570 - i 571 - ); 572 - } 573 - } 574 - 575 541 #[test] 576 542 fn test_incremental_cache_reuse() { 577 543 // Verify cache is populated and can be reused ··· 590 556 assert_eq!(p1.html, p2.html); 591 557 } 592 558 } 559 + 560 + // ============================================================================= 561 + // Loro CRDT API Spike Tests 562 + // ============================================================================= 563 + 564 + #[test] 565 + fn test_loro_basic_text_operations() { 566 + use loro::LoroDoc; 567 + 568 + let doc = LoroDoc::new(); 569 + let text = doc.get_text("content"); 570 + 571 + // Insert 572 + text.insert(0, "Hello").unwrap(); 573 + assert_eq!(text.to_string(), "Hello"); 574 + assert_eq!(text.len_unicode(), 5); 575 + 576 + // Insert at position 577 + text.insert(5, " world").unwrap(); 578 + assert_eq!(text.to_string(), "Hello world"); 579 + assert_eq!(text.len_unicode(), 11); 580 + 581 + // Delete 582 + text.delete(5, 6).unwrap(); // delete " world" 583 + assert_eq!(text.to_string(), "Hello"); 584 + assert_eq!(text.len_unicode(), 5); 585 + } 586 + 587 + #[test] 588 + fn test_loro_unicode_handling() { 589 + use loro::LoroDoc; 590 + 591 + let doc = LoroDoc::new(); 592 + let text = doc.get_text("content"); 593 + 594 + // Insert unicode 595 + text.insert(0, "Hello 🎉 世界").unwrap(); 596 + 597 + // Check lengths 598 + let content = text.to_string(); 599 + assert_eq!(content, "Hello 🎉 世界"); 600 + 601 + // Unicode length (chars) 602 + assert_eq!(text.len_unicode(), 10); // H e l l o 🎉 世 界 603 + 604 + // UTF-16 length (for DOM) 605 + // 🎉 is a surrogate pair (2 UTF-16 units), rest are 1 each 606 + assert_eq!(text.len_utf16(), 11); // 6 + 2 + 1 + 2 = 11 607 + 608 + // UTF-8 length (bytes) 609 + assert_eq!(text.len_utf8(), content.len()); 610 + } 611 + 612 + #[test] 613 + fn test_loro_undo_redo() { 614 + use loro::{LoroDoc, UndoManager}; 615 + 616 + let doc = LoroDoc::new(); 617 + let text = doc.get_text("content"); 618 + let mut undo_mgr = UndoManager::new(&doc); 619 + 620 + // Type some text 621 + text.insert(0, "Hello").unwrap(); 622 + doc.commit(); 623 + 624 + text.insert(5, " world").unwrap(); 625 + doc.commit(); 626 + 627 + assert_eq!(text.to_string(), "Hello world"); 628 + 629 + // Undo last change 630 + assert!(undo_mgr.can_undo()); 631 + undo_mgr.undo().unwrap(); 632 + assert_eq!(text.to_string(), "Hello"); 633 + 634 + // Undo first change 635 + undo_mgr.undo().unwrap(); 636 + assert_eq!(text.to_string(), ""); 637 + 638 + // Redo 639 + assert!(undo_mgr.can_redo()); 640 + undo_mgr.redo().unwrap(); 641 + assert_eq!(text.to_string(), "Hello"); 642 + 643 + undo_mgr.redo().unwrap(); 644 + assert_eq!(text.to_string(), "Hello world"); 645 + } 646 + 647 + #[test] 648 + fn test_loro_char_to_utf16_conversion() { 649 + use loro::LoroDoc; 650 + 651 + let doc = LoroDoc::new(); 652 + let text = doc.get_text("content"); 653 + 654 + text.insert(0, "Hello 🎉 世界").unwrap(); 655 + 656 + // Simulate char→UTF16 conversion for cursor positioning 657 + // Given a char offset, compute UTF-16 offset 658 + fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize { 659 + if char_pos == 0 { 660 + return 0; 661 + } 662 + // Fast path: if all ASCII, char == UTF-16 663 + if text.len_unicode() == text.len_utf16() { 664 + return char_pos; 665 + } 666 + // Slow path: get slice and count UTF-16 units 667 + match text.slice(0, char_pos) { 668 + Ok(slice) => slice.encode_utf16().count(), 669 + Err(_) => 0, 670 + } 671 + } 672 + 673 + // "Hello 🎉 世界" 674 + // Positions: H(0) e(1) l(2) l(3) o(4) ' '(5) 🎉(6) ' '(7) 世(8) 界(9) 675 + // UTF-16: 0 1 2 3 4 5 6,7 8 9 10 676 + 677 + assert_eq!(char_to_utf16(&text, 0), 0); 678 + assert_eq!(char_to_utf16(&text, 6), 6); // before emoji 679 + assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) 680 + assert_eq!(char_to_utf16(&text, 10), 11); // end 681 + } 682 + 683 + #[test] 684 + fn test_loro_ascii_fast_path() { 685 + use loro::LoroDoc; 686 + 687 + let doc = LoroDoc::new(); 688 + let text = doc.get_text("content"); 689 + 690 + // Pure ASCII content 691 + text.insert(0, "Hello world, this is a test!").unwrap(); 692 + 693 + // Verify fast path condition: all lengths equal for ASCII 694 + assert_eq!(text.len_unicode(), text.len_utf8()); 695 + assert_eq!(text.len_unicode(), text.len_utf16()); 696 + 697 + // Fast path should just return char_pos directly 698 + fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize { 699 + if char_pos == 0 { 700 + return 0; 701 + } 702 + if text.len_unicode() == text.len_utf16() { 703 + return char_pos; // fast path 704 + } 705 + text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0) 706 + } 707 + 708 + // All positions should be identity for ASCII 709 + for i in 0..=text.len_unicode() { 710 + assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i); 711 + } 712 + }
+269
crates/weaver-app/src/components/editor/visibility.rs
··· 1 + //! Conditional syntax visibility based on cursor position. 2 + //! 3 + //! Implements Obsidian-style formatting character visibility: syntax markers 4 + //! are hidden when cursor is not near them, revealed when cursor approaches. 5 + 6 + use super::document::Selection; 7 + use super::paragraph::ParagraphRender; 8 + use super::writer::{SyntaxSpanInfo, SyntaxType}; 9 + use std::collections::HashSet; 10 + use std::ops::Range; 11 + 12 + /// Determines which syntax spans should be visible based on cursor/selection. 13 + #[derive(Debug, Clone, Default)] 14 + pub struct VisibilityState { 15 + /// Set of syn_ids that should be visible 16 + pub visible_span_ids: HashSet<String>, 17 + } 18 + 19 + impl VisibilityState { 20 + /// Calculate visibility based on cursor position and selection. 21 + /// 22 + /// # Arguments 23 + /// - `cursor_offset`: Current cursor position (char offset) 24 + /// - `selection`: Optional selection range 25 + /// - `syntax_spans`: All syntax spans in the document 26 + /// - `paragraphs`: All paragraphs (for block-level visibility lookup) 27 + pub fn calculate( 28 + cursor_offset: usize, 29 + selection: Option<&Selection>, 30 + syntax_spans: &[SyntaxSpanInfo], 31 + paragraphs: &[ParagraphRender], 32 + ) -> Self { 33 + let mut visible = HashSet::new(); 34 + 35 + for span in syntax_spans { 36 + let should_show = match span.syntax_type { 37 + SyntaxType::Inline => { 38 + // Show if cursor within formatted span content OR adjacent to markers 39 + // "Adjacent" means within 1 char of the syntax boundaries 40 + let extended_range = span.char_range.start.saturating_sub(1) 41 + ..span.char_range.end.saturating_add(1); 42 + 43 + // Also show if cursor is anywhere in the formatted_range 44 + // (the region between paired opening/closing markers) 45 + let in_formatted_region = span 46 + .formatted_range 47 + .as_ref() 48 + .map(|r| r.contains(&cursor_offset)) 49 + .unwrap_or(false); 50 + 51 + extended_range.contains(&cursor_offset) 52 + || in_formatted_region 53 + || selection_overlaps(selection, &span.char_range) 54 + || span 55 + .formatted_range 56 + .as_ref() 57 + .map(|r| selection_overlaps(selection, r)) 58 + .unwrap_or(false) 59 + } 60 + SyntaxType::Block => { 61 + // Show if cursor anywhere in same paragraph 62 + cursor_in_same_paragraph(cursor_offset, &span.char_range, paragraphs) 63 + || selection_overlaps(selection, &span.char_range) 64 + } 65 + }; 66 + 67 + if should_show { 68 + visible.insert(span.syn_id.clone()); 69 + } 70 + } 71 + 72 + Self { 73 + visible_span_ids: visible, 74 + } 75 + } 76 + 77 + /// Check if a specific span should be visible. 78 + pub fn is_visible(&self, syn_id: &str) -> bool { 79 + self.visible_span_ids.contains(syn_id) 80 + } 81 + } 82 + 83 + /// Check if selection overlaps with a char range. 84 + fn selection_overlaps(selection: Option<&Selection>, range: &Range<usize>) -> bool { 85 + let Some(sel) = selection else { 86 + return false; 87 + }; 88 + 89 + let sel_start = sel.anchor.min(sel.head); 90 + let sel_end = sel.anchor.max(sel.head); 91 + 92 + // Check if ranges overlap 93 + sel_start < range.end && sel_end > range.start 94 + } 95 + 96 + /// Check if cursor is in the same paragraph as a syntax span. 97 + fn cursor_in_same_paragraph( 98 + cursor_offset: usize, 99 + syntax_range: &Range<usize>, 100 + paragraphs: &[ParagraphRender], 101 + ) -> bool { 102 + // Find which paragraph contains the syntax span 103 + for para in paragraphs { 104 + // Skip gap paragraphs (they have no syntax spans) 105 + if para.syntax_spans.is_empty() && !para.char_range.is_empty() { 106 + continue; 107 + } 108 + 109 + // Check if this paragraph contains the syntax span 110 + if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { 111 + // Check if cursor is also in this paragraph 112 + return para.char_range.contains(&cursor_offset); 113 + } 114 + } 115 + 116 + false 117 + } 118 + 119 + #[cfg(test)] 120 + mod tests { 121 + use super::*; 122 + 123 + fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo { 124 + SyntaxSpanInfo { 125 + syn_id: syn_id.to_string(), 126 + char_range: start..end, 127 + syntax_type, 128 + formatted_range: None, 129 + } 130 + } 131 + 132 + fn make_span_with_range( 133 + syn_id: &str, 134 + start: usize, 135 + end: usize, 136 + syntax_type: SyntaxType, 137 + formatted_range: Range<usize>, 138 + ) -> SyntaxSpanInfo { 139 + SyntaxSpanInfo { 140 + syn_id: syn_id.to_string(), 141 + char_range: start..end, 142 + syntax_type, 143 + formatted_range: Some(formatted_range), 144 + } 145 + } 146 + 147 + fn make_para(start: usize, end: usize, syntax_spans: Vec<SyntaxSpanInfo>) -> ParagraphRender { 148 + ParagraphRender { 149 + byte_range: start..end, 150 + char_range: start..end, 151 + html: String::new(), 152 + offset_map: vec![], 153 + syntax_spans, 154 + source_hash: 0, 155 + } 156 + } 157 + 158 + #[test] 159 + fn test_inline_visibility_cursor_inside() { 160 + // **bold** at chars 0-2 (opening **) and 6-8 (closing **) 161 + // Text positions: 0-1 = **, 2-5 = bold, 6-7 = ** 162 + // formatted_range is 0..8 (the whole **bold** region) 163 + let spans = vec![ 164 + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** 165 + make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** 166 + ]; 167 + let paras = vec![make_para(0, 8, spans.clone())]; 168 + 169 + // Cursor at position 4 (middle of "bold", inside formatted region) 170 + let vis = VisibilityState::calculate(4, None, &spans, &paras); 171 + assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region"); 172 + assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region"); 173 + 174 + // Cursor at position 2 (adjacent to opening **, start of "bold") 175 + let vis = VisibilityState::calculate(2, None, &spans, &paras); 176 + assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold"); 177 + 178 + // Cursor at position 5 (adjacent to closing **, end of "bold") 179 + let vis = VisibilityState::calculate(5, None, &spans, &paras); 180 + assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold"); 181 + } 182 + 183 + #[test] 184 + fn test_inline_visibility_without_formatted_range() { 185 + // Test without formatted_range - just adjacency-based visibility 186 + let spans = vec![ 187 + make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range) 188 + make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range) 189 + ]; 190 + let paras = vec![make_para(0, 8, spans.clone())]; 191 + 192 + // Cursor at position 4 (middle of "bold", not adjacent to either marker) 193 + let vis = VisibilityState::calculate(4, None, &spans, &paras); 194 + assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent"); 195 + assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent"); 196 + } 197 + 198 + #[test] 199 + fn test_inline_visibility_cursor_adjacent() { 200 + let spans = vec![ 201 + make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6 202 + ]; 203 + let paras = vec![make_para(0, 20, spans.clone())]; 204 + 205 + // Cursor at position 4 (one before ** which starts at 5) 206 + let vis = VisibilityState::calculate(4, None, &spans, &paras); 207 + assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent"); 208 + 209 + // Cursor at position 7 (one after ** which ends at 6, since range is exclusive) 210 + let vis = VisibilityState::calculate(7, None, &spans, &paras); 211 + assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span"); 212 + } 213 + 214 + #[test] 215 + fn test_inline_visibility_cursor_far() { 216 + let spans = vec![ 217 + make_span("s0", 10, 12, SyntaxType::Inline), 218 + ]; 219 + let paras = vec![make_para(0, 30, spans.clone())]; 220 + 221 + // Cursor at position 0 (far from **) 222 + let vis = VisibilityState::calculate(0, None, &spans, &paras); 223 + assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away"); 224 + } 225 + 226 + #[test] 227 + fn test_block_visibility_same_paragraph() { 228 + // # at start of heading 229 + let spans = vec![ 230 + make_span("s0", 0, 2, SyntaxType::Block), // "# " 231 + ]; 232 + let paras = vec![ 233 + make_para(0, 10, spans.clone()), // heading paragraph 234 + make_para(12, 30, vec![]), // next paragraph 235 + ]; 236 + 237 + // Cursor at position 5 (inside heading) 238 + let vis = VisibilityState::calculate(5, None, &spans, &paras); 239 + assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph"); 240 + } 241 + 242 + #[test] 243 + fn test_block_visibility_different_paragraph() { 244 + let spans = vec![ 245 + make_span("s0", 0, 2, SyntaxType::Block), 246 + ]; 247 + let paras = vec![ 248 + make_para(0, 10, spans.clone()), 249 + make_para(12, 30, vec![]), 250 + ]; 251 + 252 + // Cursor at position 20 (in second paragraph) 253 + let vis = VisibilityState::calculate(20, None, &spans, &paras); 254 + assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph"); 255 + } 256 + 257 + #[test] 258 + fn test_selection_reveals_syntax() { 259 + let spans = vec![ 260 + make_span("s0", 5, 7, SyntaxType::Inline), 261 + ]; 262 + let paras = vec![make_para(0, 20, spans.clone())]; 263 + 264 + // Selection overlaps the syntax span 265 + let selection = Selection { anchor: 3, head: 10 }; 266 + let vis = VisibilityState::calculate(10, Some(&selection), &spans, &paras); 267 + assert!(vis.is_visible("s0"), "** should be visible when selection overlaps"); 268 + } 269 + }
+500 -104
crates/weaver-app/src/components/editor/writer.rs
··· 27 27 /// Paragraph boundaries in source: (byte_range, char_range) 28 28 /// These are extracted during rendering by tracking Tag::Paragraph events 29 29 pub paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, 30 + 31 + /// Syntax spans that can be conditionally hidden 32 + pub syntax_spans: Vec<SyntaxSpanInfo>, 30 33 } 31 34 32 35 /// Classification of markdown syntax characters 33 - #[derive(Debug, Clone, Copy, PartialEq)] 34 - enum SyntaxClass { 36 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 37 + pub enum SyntaxType { 35 38 /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 36 39 Inline, 37 40 /// Block formatting: #, >, -, *, 1., ```, --- 38 41 Block, 39 42 } 40 43 44 + /// Information about a syntax span for conditional visibility 45 + #[derive(Debug, Clone, PartialEq, Eq)] 46 + pub struct SyntaxSpanInfo { 47 + /// Unique identifier for this syntax span (e.g., "s0", "s1") 48 + pub syn_id: String, 49 + /// Source char range this syntax covers (just this marker) 50 + pub char_range: Range<usize>, 51 + /// Whether this is inline or block-level syntax 52 + pub syntax_type: SyntaxType, 53 + /// For paired inline syntax (**, *, etc), the full formatted region 54 + /// from opening marker through content to closing marker. 55 + /// When cursor is anywhere in this range, the syntax is visible. 56 + pub formatted_range: Option<Range<usize>>, 57 + } 58 + 41 59 /// Classify syntax text as inline or block level 42 - fn classify_syntax(text: &str) -> SyntaxClass { 60 + fn classify_syntax(text: &str) -> SyntaxType { 43 61 let trimmed = text.trim_start(); 44 62 45 63 // Check for block-level markers ··· 66 84 .unwrap_or(false) 67 85 && trimmed.contains('.') 68 86 { 69 - SyntaxClass::Block 87 + SyntaxType::Block 70 88 } else { 71 - SyntaxClass::Inline 89 + SyntaxType::Inline 72 90 } 73 91 } 74 92 ··· 126 144 127 145 // Paragraph boundary tracking for incremental rendering 128 146 paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range) 129 - current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) 147 + current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) 148 + list_depth: usize, // Track nesting depth to avoid paragraph boundary override inside lists 130 149 131 150 /// When true, skip HTML generation and only track paragraph boundaries. 132 151 /// Used for fast boundary discovery in incremental rendering. 133 152 boundary_only: bool, 153 + 154 + // Syntax span tracking for conditional visibility 155 + syntax_spans: Vec<SyntaxSpanInfo>, 156 + next_syn_id: usize, 157 + /// Stack of pending inline formats: (syn_id of opening span, char start of region) 158 + /// Used to set formatted_range when closing paired inline markers 159 + pending_inline_formats: Vec<(String, usize)>, 134 160 135 161 _phantom: std::marker::PhantomData<&'a ()>, 136 162 } ··· 155 181 writer: W, 156 182 node_id_offset: usize, 157 183 ) -> Self { 184 + Self::new_with_offsets(source, source_rope, events, writer, node_id_offset, 0) 185 + } 186 + 187 + pub fn new_with_offsets( 188 + source: &'a str, 189 + source_rope: &'a JumpRopeBuf, 190 + events: I, 191 + writer: W, 192 + node_id_offset: usize, 193 + syn_id_offset: usize, 194 + ) -> Self { 158 195 Self { 159 196 source, 160 197 source_rope, ··· 182 219 current_node_child_count: 0, 183 220 paragraph_ranges: Vec::new(), 184 221 current_paragraph_start: None, 222 + list_depth: 0, 185 223 boundary_only: false, 224 + syntax_spans: Vec::new(), 225 + next_syn_id: syn_id_offset, 226 + pending_inline_formats: Vec::new(), 186 227 _phantom: std::marker::PhantomData, 187 228 } 188 229 } ··· 220 261 current_node_id: None, 221 262 current_node_char_offset: 0, 222 263 current_node_child_count: 0, 264 + syntax_spans: Vec::new(), 265 + next_syn_id: 0, 266 + pending_inline_formats: Vec::new(), 223 267 paragraph_ranges: Vec::new(), 224 268 current_paragraph_start: None, 269 + list_depth: 0, 225 270 boundary_only: true, 226 271 _phantom: std::marker::PhantomData, 227 272 } ··· 256 301 current_node_child_count: self.current_node_child_count, 257 302 paragraph_ranges: self.paragraph_ranges, 258 303 current_paragraph_start: self.current_paragraph_start, 304 + list_depth: self.list_depth, 259 305 boundary_only: self.boundary_only, 306 + syntax_spans: self.syntax_spans, 307 + next_syn_id: self.next_syn_id, 308 + pending_inline_formats: self.pending_inline_formats, 260 309 _phantom: std::marker::PhantomData, 261 310 } 262 311 } ··· 280 329 self.writer.write_str(s) 281 330 } 282 331 332 + /// Generate a unique syntax span ID 333 + fn gen_syn_id(&mut self) -> String { 334 + let id = format!("s{}", self.next_syn_id); 335 + self.next_syn_id += 1; 336 + id 337 + } 338 + 339 + /// Finalize a paired inline format (Strong, Emphasis, Strikethrough). 340 + /// Pops the pending format info and sets formatted_range on both opening and closing spans. 341 + fn finalize_paired_inline_format(&mut self) { 342 + if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() { 343 + let format_end = self.last_char_offset; 344 + let formatted_range = format_start..format_end; 345 + 346 + // Update the opening span's formatted_range 347 + if let Some(opening_span) = self 348 + .syntax_spans 349 + .iter_mut() 350 + .find(|s| s.syn_id == opening_syn_id) 351 + { 352 + opening_span.formatted_range = Some(formatted_range.clone()); 353 + } 354 + 355 + // Update the closing span's formatted_range (the most recent one) 356 + // The closing syntax was just emitted by emit_gap_before, so it's the last span 357 + if let Some(closing_span) = self.syntax_spans.last_mut() { 358 + // Only update if it's an inline span (closing syntax should be inline) 359 + if closing_span.syntax_type == SyntaxType::Inline { 360 + closing_span.formatted_range = Some(formatted_range); 361 + } 362 + } 363 + } 364 + } 365 + 283 366 /// Emit syntax span for a given range and record offset mapping 284 367 fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), W::Error> { 285 368 if range.start < range.end { ··· 287 370 if !syntax.is_empty() { 288 371 let char_start = self.last_char_offset; 289 372 let syntax_char_len = syntax.chars().count(); 373 + let char_end = char_start + syntax_char_len; 290 374 291 375 // In boundary_only mode, just update offsets without HTML 292 376 if self.boundary_only { 293 - self.last_char_offset = char_start + syntax_char_len; 377 + self.last_char_offset = char_end; 294 378 self.last_byte_offset = range.end; 295 379 return Ok(()); 296 380 } 297 381 298 - let class = match classify_syntax(syntax) { 299 - SyntaxClass::Inline => "md-syntax-inline", 300 - SyntaxClass::Block => "md-syntax-block", 382 + let syntax_type = classify_syntax(syntax); 383 + let class = match syntax_type { 384 + SyntaxType::Inline => "md-syntax-inline", 385 + SyntaxType::Block => "md-syntax-block", 301 386 }; 302 387 388 + // Generate unique ID for this syntax span 389 + let syn_id = self.gen_syn_id(); 390 + 303 391 // If we're outside any node, create a wrapper span for tracking 304 392 let created_node = if self.current_node_id.is_none() { 305 393 let node_id = self.gen_node_id(); 306 394 write!( 307 395 &mut self.writer, 308 - "<span id=\"{}\" class=\"{}\">", 309 - node_id, class 396 + "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 397 + node_id, class, syn_id, char_start, char_end 310 398 )?; 311 399 self.begin_node(node_id); 312 400 true 313 401 } else { 314 - self.write("<span class=\"")?; 315 - self.write(class)?; 316 - self.write("\">")?; 402 + write!( 403 + &mut self.writer, 404 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 405 + class, syn_id, char_start, char_end 406 + )?; 317 407 false 318 408 }; 319 409 320 410 escape_html(&mut self.writer, syntax)?; 321 411 self.write("</span>")?; 322 412 413 + // Record syntax span info for visibility toggling 414 + self.syntax_spans.push(SyntaxSpanInfo { 415 + syn_id, 416 + char_range: char_start..char_end, 417 + syntax_type, 418 + formatted_range: None, 419 + }); 420 + 323 421 // Record offset mapping for this syntax 324 - self.record_mapping(range.clone(), char_start..char_start + syntax_char_len); 325 - let new_char = char_start + syntax_char_len; 326 - let new_byte = range.end; 327 - tracing::debug!("[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}", 328 - self.last_char_offset, new_char, self.last_byte_offset, new_byte); 329 - self.last_char_offset = new_char; 330 - self.last_byte_offset = new_byte; // Mark bytes as processed 422 + self.record_mapping(range.clone(), char_start..char_end); 423 + tracing::debug!( 424 + "[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}", 425 + self.last_char_offset, 426 + char_end, 427 + self.last_byte_offset, 428 + range.end 429 + ); 430 + self.last_char_offset = char_end; 431 + self.last_byte_offset = range.end; // Mark bytes as processed 331 432 332 433 // Close wrapper if we created one 333 434 if created_node { ··· 352 453 &mut self, 353 454 syntax: &str, 354 455 byte_start: usize, 355 - class: SyntaxClass, 456 + syntax_type: SyntaxType, 356 457 ) -> Result<(), W::Error> { 357 458 if syntax.is_empty() { 358 459 return Ok(()); ··· 360 461 361 462 let char_start = self.last_char_offset; 362 463 let syntax_char_len = syntax.chars().count(); 464 + let char_end = char_start + syntax_char_len; 363 465 let byte_end = byte_start + syntax.len(); 364 466 365 467 // In boundary_only mode, just update offsets 366 468 if self.boundary_only { 367 - self.last_char_offset = char_start + syntax_char_len; 469 + self.last_char_offset = char_end; 368 470 self.last_byte_offset = byte_end; 369 471 return Ok(()); 370 472 } 371 473 372 - let class_str = match class { 373 - SyntaxClass::Inline => "md-syntax-inline", 374 - SyntaxClass::Block => "md-syntax-block", 474 + let class_str = match syntax_type { 475 + SyntaxType::Inline => "md-syntax-inline", 476 + SyntaxType::Block => "md-syntax-block", 375 477 }; 376 478 377 - self.write("<span class=\"")?; 378 - self.write(class_str)?; 379 - self.write("\">")?; 479 + // Generate unique ID for this syntax span 480 + let syn_id = self.gen_syn_id(); 481 + 482 + write!( 483 + &mut self.writer, 484 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 485 + class_str, syn_id, char_start, char_end 486 + )?; 380 487 escape_html(&mut self.writer, syntax)?; 381 488 self.write("</span>")?; 382 489 490 + // Record syntax span info for visibility toggling 491 + self.syntax_spans.push(SyntaxSpanInfo { 492 + syn_id, 493 + char_range: char_start..char_end, 494 + syntax_type, 495 + formatted_range: None, 496 + }); 497 + 383 498 // Record offset mapping for cursor positioning 384 - self.record_mapping( 385 - byte_start..byte_end, 386 - char_start..char_start + syntax_char_len, 387 - ); 499 + self.record_mapping(byte_start..byte_end, char_start..char_end); 388 500 389 - self.last_char_offset = char_start + syntax_char_len; 501 + self.last_char_offset = char_end; 390 502 self.last_byte_offset = byte_end; 391 503 392 504 Ok(()) ··· 465 577 pub fn run(mut self) -> Result<WriterResult, W::Error> { 466 578 while let Some((event, range)) = self.events.next() { 467 579 // Log events for debugging 468 - tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}", 469 - match &event { 470 - Event::Start(tag) => format!("Start({:?})", tag), 471 - Event::End(tag) => format!("End({:?})", tag), 472 - Event::Text(t) => format!("Text('{}')", t), 473 - Event::Code(t) => format!("Code('{}')", t), 474 - Event::Html(t) => format!("Html('{}')", t), 475 - Event::InlineHtml(t) => format!("InlineHtml('{}')", t), 476 - Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t), 477 - Event::SoftBreak => "SoftBreak".to_string(), 478 - Event::HardBreak => "HardBreak".to_string(), 479 - Event::Rule => "Rule".to_string(), 480 - Event::TaskListMarker(b) => format!("TaskListMarker({})", b), 481 - Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t), 482 - Event::InlineMath(t) => format!("InlineMath('{}')", t), 483 - Event::DisplayMath(t) => format!("DisplayMath('{}')", t), 484 - }, 485 - &range, 486 - self.last_byte_offset, 487 - self.last_char_offset 488 - ); 580 + // tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}", 581 + // match &event { 582 + // Event::Start(tag) => format!("Start({:?})", tag), 583 + // Event::End(tag) => format!("End({:?})", tag), 584 + // Event::Text(t) => format!("Text('{}')", t), 585 + // Event::Code(t) => format!("Code('{}')", t), 586 + // Event::Html(t) => format!("Html('{}')", t), 587 + // Event::InlineHtml(t) => format!("InlineHtml('{}')", t), 588 + // Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t), 589 + // Event::SoftBreak => "SoftBreak".to_string(), 590 + // Event::HardBreak => "HardBreak".to_string(), 591 + // Event::Rule => "Rule".to_string(), 592 + // Event::TaskListMarker(b) => format!("TaskListMarker({})", b), 593 + // Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t), 594 + // Event::InlineMath(t) => format!("InlineMath('{}')", t), 595 + // Event::DisplayMath(t) => format!("DisplayMath('{}')", t), 596 + // }, 597 + // &range, 598 + // self.last_byte_offset, 599 + // self.last_char_offset 600 + // ); 489 601 490 602 // For End events, emit any trailing content within the event's range 491 603 // BEFORE calling end_tag (which calls end_node and clears current_node_id) ··· 530 642 let char_start = self.last_char_offset; 531 643 let trailing_char_len = trailing.chars().count(); 532 644 533 - self.write("<span class=\"md-syntax-inline\">")?; 645 + let char_end = char_start + trailing_char_len; 646 + let syn_id = self.gen_syn_id(); 647 + 648 + write!( 649 + &mut self.writer, 650 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 651 + syn_id, char_start, char_end 652 + )?; 534 653 escape_html(&mut self.writer, trailing)?; 535 654 self.write("</span>")?; 536 655 656 + // Record syntax span info 657 + self.syntax_spans.push(SyntaxSpanInfo { 658 + syn_id, 659 + char_range: char_start..char_end, 660 + syntax_type: SyntaxType::Inline, 661 + formatted_range: None, 662 + }); 663 + 537 664 // Record mapping if we have a node 538 665 if let Some(ref node_id) = self.current_node_id { 539 666 let mapping = OffsetMapping { 540 667 byte_range: self.last_byte_offset..doc_byte_len, 541 - char_range: char_start..char_start + trailing_char_len, 668 + char_range: char_start..char_end, 542 669 node_id: node_id.clone(), 543 670 char_offset_in_node: self.current_node_char_offset, 544 671 child_index: None, ··· 556 683 Ok(WriterResult { 557 684 offset_maps: self.offset_maps, 558 685 paragraph_ranges: self.paragraph_ranges, 686 + syntax_spans: self.syntax_spans, 559 687 }) 560 688 } 561 689 ··· 628 756 } else { 629 757 // First text in code block - start tracking 630 758 self.code_buffer_byte_range = Some(range.clone()); 631 - self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len); 759 + self.code_buffer_char_range = 760 + Some(self.last_char_offset..self.last_char_offset + text_char_len); 632 761 } 633 762 // Update offsets so paragraph boundary is correct 634 763 self.last_char_offset += text_char_len; ··· 659 788 660 789 // Emit opening backtick and track it 661 790 if raw_text.starts_with('`') { 662 - self.write("<span class=\"md-syntax-inline\">`</span>")?; 791 + let syn_id = self.gen_syn_id(); 792 + let backtick_char_end = char_start + 1; 793 + write!( 794 + &mut self.writer, 795 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 796 + syn_id, char_start, backtick_char_end 797 + )?; 798 + self.syntax_spans.push(SyntaxSpanInfo { 799 + syn_id, 800 + char_range: char_start..backtick_char_end, 801 + syntax_type: SyntaxType::Inline, 802 + formatted_range: None, 803 + }); 663 804 self.last_char_offset += 1; 664 805 } 665 806 ··· 678 819 679 820 // Emit closing backtick and track it 680 821 if raw_text.ends_with('`') { 681 - self.write("<span class=\"md-syntax-inline\">`</span>")?; 822 + let syn_id = self.gen_syn_id(); 823 + let backtick_char_start = self.last_char_offset; 824 + let backtick_char_end = backtick_char_start + 1; 825 + write!( 826 + &mut self.writer, 827 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 828 + syn_id, backtick_char_start, backtick_char_end 829 + )?; 830 + self.syntax_spans.push(SyntaxSpanInfo { 831 + syn_id, 832 + char_range: backtick_char_start..backtick_char_end, 833 + syntax_type: SyntaxType::Inline, 834 + formatted_range: None, 835 + }); 682 836 self.last_char_offset += 1; 683 837 } 684 838 } ··· 687 841 688 842 // Emit opening $ and track it 689 843 if raw_text.starts_with('$') { 690 - self.write("<span class=\"md-syntax-inline\">$</span>")?; 844 + let syn_id = self.gen_syn_id(); 845 + let char_start = self.last_char_offset; 846 + let char_end = char_start + 1; 847 + write!( 848 + &mut self.writer, 849 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 850 + syn_id, char_start, char_end 851 + )?; 852 + self.syntax_spans.push(SyntaxSpanInfo { 853 + syn_id, 854 + char_range: char_start..char_end, 855 + syntax_type: SyntaxType::Inline, 856 + formatted_range: None, 857 + }); 691 858 self.last_char_offset += 1; 692 859 } 693 860 ··· 699 866 700 867 // Emit closing $ and track it 701 868 if raw_text.ends_with('$') { 702 - self.write("<span class=\"md-syntax-inline\">$</span>")?; 869 + let syn_id = self.gen_syn_id(); 870 + let char_start = self.last_char_offset; 871 + let char_end = char_start + 1; 872 + write!( 873 + &mut self.writer, 874 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 875 + syn_id, char_start, char_end 876 + )?; 877 + self.syntax_spans.push(SyntaxSpanInfo { 878 + syn_id, 879 + char_range: char_start..char_end, 880 + syntax_type: SyntaxType::Inline, 881 + formatted_range: None, 882 + }); 703 883 self.last_char_offset += 1; 704 884 } 705 885 } ··· 708 888 709 889 // Emit opening $$ and track it 710 890 if raw_text.starts_with("$$") { 711 - self.write("<span class=\"md-syntax-inline\">$$</span>")?; 891 + let syn_id = self.gen_syn_id(); 892 + let char_start = self.last_char_offset; 893 + let char_end = char_start + 2; 894 + write!( 895 + &mut self.writer, 896 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$$</span>", 897 + syn_id, char_start, char_end 898 + )?; 899 + self.syntax_spans.push(SyntaxSpanInfo { 900 + syn_id, 901 + char_range: char_start..char_end, 902 + syntax_type: SyntaxType::Inline, 903 + formatted_range: None, 904 + }); 712 905 self.last_char_offset += 2; 713 906 } 714 907 ··· 720 913 721 914 // Emit closing $$ and track it 722 915 if raw_text.ends_with("$$") { 723 - self.write("<span class=\"md-syntax-inline\">$$</span>")?; 916 + let syn_id = self.gen_syn_id(); 917 + let char_start = self.last_char_offset; 918 + let char_end = char_start + 2; 919 + write!( 920 + &mut self.writer, 921 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$$</span>", 922 + syn_id, char_start, char_end 923 + )?; 924 + self.syntax_spans.push(SyntaxSpanInfo { 925 + syn_id, 926 + char_range: char_start..char_end, 927 + syntax_type: SyntaxType::Inline, 928 + formatted_range: None, 929 + }); 724 930 self.last_char_offset += 2; 725 931 } 726 932 } ··· 744 950 let spaces = &gap[..gap.len() - 1]; // everything except the \n 745 951 let char_start = self.last_char_offset; 746 952 let spaces_char_len = spaces.chars().count(); 953 + let char_end = char_start + spaces_char_len; 747 954 748 955 // Emit and map the visible spaces 749 - self.write("<span class=\"md-syntax-inline\">")?; 956 + let syn_id = self.gen_syn_id(); 957 + write!( 958 + &mut self.writer, 959 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 960 + syn_id, char_start, char_end 961 + )?; 750 962 escape_html(&mut self.writer, spaces)?; 751 963 self.write("</span>")?; 752 964 965 + // Record syntax span info 966 + self.syntax_spans.push(SyntaxSpanInfo { 967 + syn_id, 968 + char_range: char_start..char_end, 969 + syntax_type: SyntaxType::Inline, 970 + formatted_range: None, 971 + }); 972 + 753 973 // Count this span as a child 754 974 self.current_node_child_count += 1; 755 975 756 976 self.record_mapping( 757 977 range.start..range.start + spaces.len(), 758 - char_start..char_start + spaces_char_len, 978 + char_start..char_end, 759 979 ); 760 980 761 981 // Now the actual line break <br> ··· 779 999 node_id: node_id.clone(), 780 1000 char_offset_in_node: self.current_node_char_offset, 781 1001 child_index: None, // text node - TreeWalker will find it 782 - utf16_len: 1, // zero-width space is 1 UTF-16 unit 1002 + utf16_len: 1, // zero-width space is 1 UTF-16 unit 783 1003 }; 784 1004 self.offset_maps.push(mapping); 785 1005 ··· 805 1025 let raw_text = &self.source[range]; 806 1026 let trimmed = raw_text.trim(); 807 1027 if !trimmed.is_empty() { 808 - self.write("<span class=\"md-syntax-block\">")?; 1028 + let syn_id = self.gen_syn_id(); 1029 + let char_start = self.last_char_offset; 1030 + let char_len = trimmed.chars().count(); 1031 + let char_end = char_start + char_len; 1032 + 1033 + write!( 1034 + &mut self.writer, 1035 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1036 + syn_id, char_start, char_end 1037 + )?; 809 1038 escape_html(&mut self.writer, trimmed)?; 810 1039 self.write("</span>\n")?; 1040 + 1041 + self.syntax_spans.push(SyntaxSpanInfo { 1042 + syn_id, 1043 + char_range: char_start..char_end, 1044 + syntax_type: SyntaxType::Block, 1045 + formatted_range: None, 1046 + }); 811 1047 } 812 1048 } 813 1049 ··· 830 1066 if let Some(bracket_pos) = raw_text.find('[') { 831 1067 let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); 832 1068 let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; 833 - self.write("<span class=\"md-syntax-inline\">")?; 1069 + 1070 + let syn_id = self.gen_syn_id(); 1071 + let char_start = self.last_char_offset; 1072 + let syntax_char_len = syntax.chars().count(); 1073 + let char_end = char_start + syntax_char_len; 1074 + 1075 + write!( 1076 + &mut self.writer, 1077 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1078 + syn_id, char_start, char_end 1079 + )?; 834 1080 escape_html(&mut self.writer, syntax)?; 835 1081 self.write("</span> ")?; 1082 + 1083 + self.syntax_spans.push(SyntaxSpanInfo { 1084 + syn_id, 1085 + char_range: char_start..char_end, 1086 + syntax_type: SyntaxType::Inline, 1087 + formatted_range: None, 1088 + }); 836 1089 } 837 1090 } 838 1091 ··· 891 1144 }; 892 1145 893 1146 if let Some(syntax) = opening_syntax { 894 - let class = match classify_syntax(syntax) { 895 - SyntaxClass::Inline => "md-syntax-inline", 896 - SyntaxClass::Block => "md-syntax-block", 1147 + let syntax_type = classify_syntax(syntax); 1148 + let class = match syntax_type { 1149 + SyntaxType::Inline => "md-syntax-inline", 1150 + SyntaxType::Block => "md-syntax-block", 897 1151 }; 898 1152 899 1153 let char_start = self.last_char_offset; 900 1154 let syntax_char_len = syntax.chars().count(); 1155 + let char_end = char_start + syntax_char_len; 901 1156 let syntax_byte_len = syntax.len(); 902 1157 903 - self.write("<span class=\"")?; 904 - self.write(class)?; 905 - self.write("\">")?; 1158 + // Generate unique ID for this syntax span 1159 + let syn_id = self.gen_syn_id(); 1160 + 1161 + write!( 1162 + &mut self.writer, 1163 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1164 + class, syn_id, char_start, char_end 1165 + )?; 906 1166 escape_html(&mut self.writer, syntax)?; 907 1167 self.write("</span>")?; 908 1168 1169 + // Record syntax span info for visibility toggling 1170 + self.syntax_spans.push(SyntaxSpanInfo { 1171 + syn_id: syn_id.clone(), 1172 + char_range: char_start..char_end, 1173 + syntax_type, 1174 + formatted_range: None, // Will be updated when closing tag is emitted 1175 + }); 1176 + 1177 + // For paired inline syntax (Strong, Emphasis, Strikethrough), 1178 + // track the opening span so we can set formatted_range when closing 1179 + if matches!(tag, Tag::Strong | Tag::Emphasis | Tag::Strikethrough) { 1180 + self.pending_inline_formats.push((syn_id, char_start)); 1181 + } 1182 + 909 1183 // Update tracking - we've consumed this opening syntax 910 - tracing::debug!("[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}", 911 - syntax, self.last_char_offset, char_start + syntax_char_len, 912 - self.last_byte_offset, range.start + syntax_byte_len); 913 - self.last_char_offset = char_start + syntax_char_len; 1184 + tracing::debug!( 1185 + "[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}", 1186 + syntax, 1187 + self.last_char_offset, 1188 + char_end, 1189 + self.last_byte_offset, 1190 + range.start + syntax_byte_len 1191 + ); 1192 + self.last_char_offset = char_end; 914 1193 self.last_byte_offset = range.start + syntax_byte_len; 915 1194 } 916 1195 } ··· 920 1199 Tag::HtmlBlock => Ok(()), 921 1200 Tag::Paragraph => { 922 1201 // Record paragraph start for boundary tracking 923 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1202 + // BUT skip if inside a list - list owns the paragraph boundary 1203 + if self.list_depth == 0 { 1204 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1205 + } 924 1206 925 1207 let node_id = self.gen_node_id(); 926 1208 if self.end_newline { ··· 964 1246 965 1247 let syntax = &raw_text[gt_pos..syntax_end]; 966 1248 let syntax_byte_start = bq_range.start + gt_pos; 967 - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?; 1249 + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 968 1250 } 969 1251 } 970 1252 } ··· 1052 1334 let syntax = &raw_text[hash_pos..syntax_end]; 1053 1335 let syntax_byte_start = range.start + hash_pos; 1054 1336 1055 - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?; 1337 + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 1056 1338 } 1057 1339 } 1058 1340 Ok(()) ··· 1141 1423 let syntax = &raw_text[fence_pos..fence_end]; 1142 1424 let syntax_char_len = syntax.chars().count() + 1; // +1 for newline 1143 1425 let syntax_byte_len = syntax.len() + 1; // +1 for newline 1144 - self.write("<span class=\"md-syntax-block\">")?; 1426 + 1427 + let syn_id = self.gen_syn_id(); 1428 + let char_start = self.last_char_offset; 1429 + let char_end = char_start + syntax_char_len; 1430 + 1431 + write!( 1432 + &mut self.writer, 1433 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1434 + syn_id, char_start, char_end 1435 + )?; 1145 1436 escape_html(&mut self.writer, syntax)?; 1146 1437 self.write("</span>\n")?; 1438 + 1439 + self.syntax_spans.push(SyntaxSpanInfo { 1440 + syn_id, 1441 + char_range: char_start..char_end, 1442 + syntax_type: SyntaxType::Block, 1443 + formatted_range: None, 1444 + }); 1445 + 1147 1446 self.last_char_offset += syntax_char_len; 1148 1447 self.last_byte_offset = range.start + fence_pos + syntax_byte_len; 1149 1448 } ··· 1175 1474 Tag::List(Some(1)) => { 1176 1475 // Track list as paragraph-level block 1177 1476 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1477 + self.list_depth += 1; 1178 1478 if self.end_newline { 1179 1479 self.write("<ol>\n") 1180 1480 } else { ··· 1184 1484 Tag::List(Some(start)) => { 1185 1485 // Track list as paragraph-level block 1186 1486 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1487 + self.list_depth += 1; 1187 1488 if self.end_newline { 1188 1489 self.write("<ol start=\"")?; 1189 1490 } else { ··· 1195 1496 Tag::List(None) => { 1196 1497 // Track list as paragraph-level block 1197 1498 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1499 + self.list_depth += 1; 1198 1500 if self.end_newline { 1199 1501 self.write("<ul>\n") 1200 1502 } else { ··· 1234 1536 let char_start = self.last_char_offset; 1235 1537 let syntax_char_len = leading_ws_chars + syntax.chars().count(); 1236 1538 let syntax_byte_len = leading_ws_bytes + syntax.len(); 1237 - self.write("<span class=\"md-syntax-block\">")?; 1539 + let char_end = char_start + syntax_char_len; 1540 + 1541 + let syn_id = self.gen_syn_id(); 1542 + write!( 1543 + &mut self.writer, 1544 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1545 + syn_id, char_start, char_end 1546 + )?; 1238 1547 escape_html(&mut self.writer, syntax)?; 1239 1548 self.write("</span>")?; 1549 + 1550 + self.syntax_spans.push(SyntaxSpanInfo { 1551 + syn_id, 1552 + char_range: char_start..char_end, 1553 + syntax_type: SyntaxType::Block, 1554 + formatted_range: None, 1555 + }); 1556 + 1240 1557 // Record offset mapping for cursor positioning 1241 1558 self.record_mapping( 1242 1559 range.start..range.start + syntax_byte_len, 1243 - char_start..char_start + syntax_char_len, 1560 + char_start..char_end, 1244 1561 ); 1245 - self.last_char_offset = char_start + syntax_char_len; 1562 + self.last_char_offset = char_end; 1246 1563 self.last_byte_offset = range.start + syntax_byte_len; 1247 1564 } else if marker.is_ascii_digit() { 1248 - // Ordered list: extract "1. " or similar 1565 + // Ordered list: extract "1. " or similar (including trailing space) 1249 1566 if let Some(dot_pos) = trimmed.find('.') { 1250 1567 let syntax_end = (dot_pos + 2).min(trimmed.len()); 1251 - let syntax = &trimmed[..syntax_end].trim_end(); 1568 + let syntax = &trimmed[..syntax_end]; 1252 1569 let char_start = self.last_char_offset; 1253 1570 let syntax_char_len = leading_ws_chars + syntax.chars().count(); 1254 1571 let syntax_byte_len = leading_ws_bytes + syntax.len(); 1255 - self.write("<span class=\"md-syntax-block\">")?; 1572 + let char_end = char_start + syntax_char_len; 1573 + 1574 + let syn_id = self.gen_syn_id(); 1575 + write!( 1576 + &mut self.writer, 1577 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1578 + syn_id, char_start, char_end 1579 + )?; 1256 1580 escape_html(&mut self.writer, syntax)?; 1257 1581 self.write("</span>")?; 1582 + 1583 + self.syntax_spans.push(SyntaxSpanInfo { 1584 + syn_id, 1585 + char_range: char_start..char_end, 1586 + syntax_type: SyntaxType::Block, 1587 + formatted_range: None, 1588 + }); 1589 + 1258 1590 // Record offset mapping for cursor positioning 1259 1591 self.record_mapping( 1260 1592 range.start..range.start + syntax_byte_len, 1261 - char_start..char_start + syntax_char_len, 1593 + char_start..char_end, 1262 1594 ); 1263 - self.last_char_offset = char_start + syntax_char_len; 1595 + self.last_char_offset = char_end; 1264 1596 self.last_byte_offset = range.start + syntax_byte_len; 1265 1597 } 1266 1598 } ··· 1339 1671 if range.start < range.end { 1340 1672 let raw_text = &self.source[range.clone()]; 1341 1673 if raw_text.starts_with("![") { 1342 - self.write("<span class=\"md-syntax-inline\">![</span>")?; 1674 + let syn_id = self.gen_syn_id(); 1675 + let char_start = self.last_char_offset; 1676 + let char_end = char_start + 2; 1677 + 1678 + write!( 1679 + &mut self.writer, 1680 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">![</span>", 1681 + syn_id, char_start, char_end 1682 + )?; 1683 + 1684 + self.syntax_spans.push(SyntaxSpanInfo { 1685 + syn_id, 1686 + char_range: char_start..char_end, 1687 + syntax_type: SyntaxType::Inline, 1688 + formatted_range: None, 1689 + }); 1343 1690 } 1344 1691 } 1345 1692 ··· 1380 1727 let raw_text = &self.source[range]; 1381 1728 if let Some(paren_pos) = raw_text.rfind("](") { 1382 1729 let syntax = &raw_text[paren_pos..]; 1383 - self.write("<span class=\"md-syntax-inline\">")?; 1730 + let syn_id = self.gen_syn_id(); 1731 + let char_start = self.last_char_offset; 1732 + let syntax_char_len = syntax.chars().count(); 1733 + let char_end = char_start + syntax_char_len; 1734 + 1735 + write!( 1736 + &mut self.writer, 1737 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1738 + syn_id, char_start, char_end 1739 + )?; 1384 1740 escape_html(&mut self.writer, syntax)?; 1385 1741 self.write("</span>")?; 1742 + 1743 + self.syntax_spans.push(SyntaxSpanInfo { 1744 + syn_id, 1745 + char_range: char_start..char_end, 1746 + syntax_type: SyntaxType::Inline, 1747 + formatted_range: None, 1748 + }); 1386 1749 } 1387 1750 } 1388 1751 Ok(()) ··· 1430 1793 TagEnd::HtmlBlock => Ok(()), 1431 1794 TagEnd::Paragraph => { 1432 1795 // Record paragraph end for boundary tracking 1433 - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1434 - let byte_range = byte_start..self.last_byte_offset; 1435 - let char_range = char_start..self.last_char_offset; 1436 - self.paragraph_ranges.push((byte_range, char_range)); 1796 + // BUT skip if inside a list - list owns the paragraph boundary 1797 + if self.list_depth == 0 { 1798 + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1799 + let byte_range = byte_start..self.last_byte_offset; 1800 + let char_range = char_start..self.last_char_offset; 1801 + self.paragraph_ranges.push((byte_range, char_range)); 1802 + } 1437 1803 } 1438 1804 1439 1805 self.end_node(); ··· 1506 1872 1507 1873 if let Some((lang, buffer)) = self.code_buffer.take() { 1508 1874 // Create offset mapping for code block content if we tracked ranges 1509 - if let (Some(code_byte_range), Some(code_char_range)) = 1510 - (self.code_buffer_byte_range.take(), self.code_buffer_char_range.take()) { 1875 + if let (Some(code_byte_range), Some(code_char_range)) = ( 1876 + self.code_buffer_byte_range.take(), 1877 + self.code_buffer_char_range.take(), 1878 + ) { 1511 1879 // Record mapping before writing HTML 1512 1880 // (current_node_id should be set by start_tag for CodeBlock) 1513 1881 self.record_mapping(code_byte_range, code_char_range); ··· 1553 1921 if fence_line.trim().starts_with("```") { 1554 1922 let fence = fence_line.trim(); 1555 1923 let fence_char_len = fence.chars().count(); 1556 - self.write("<span class=\"md-syntax-block\">")?; 1924 + 1925 + let syn_id = self.gen_syn_id(); 1926 + let char_start = self.last_char_offset; 1927 + let char_end = char_start + fence_char_len; 1928 + 1929 + write!( 1930 + &mut self.writer, 1931 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1932 + syn_id, char_start, char_end 1933 + )?; 1557 1934 escape_html(&mut self.writer, fence)?; 1558 1935 self.write("</span>")?; 1936 + 1937 + self.syntax_spans.push(SyntaxSpanInfo { 1938 + syn_id, 1939 + char_range: char_start..char_end, 1940 + syntax_type: SyntaxType::Block, 1941 + formatted_range: None, 1942 + }); 1943 + 1559 1944 self.last_char_offset += fence_char_len; 1560 1945 self.last_byte_offset += fence.len(); 1561 1946 } ··· 1572 1957 Ok(()) 1573 1958 } 1574 1959 TagEnd::List(true) => { 1960 + self.list_depth = self.list_depth.saturating_sub(1); 1575 1961 // Record list end for paragraph boundary tracking 1576 1962 if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1577 1963 let byte_range = byte_start..self.last_byte_offset; ··· 1581 1967 self.write("</ol>\n") 1582 1968 } 1583 1969 TagEnd::List(false) => { 1970 + self.list_depth = self.list_depth.saturating_sub(1); 1584 1971 // Record list end for paragraph boundary tracking 1585 1972 if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1586 1973 let byte_range = byte_start..self.last_byte_offset; ··· 1602 1989 self.end_node(); 1603 1990 self.write("</dd>\n") 1604 1991 } 1605 - TagEnd::Emphasis => self.write("</em>"), 1992 + TagEnd::Emphasis => { 1993 + self.finalize_paired_inline_format(); 1994 + self.write("</em>") 1995 + } 1606 1996 TagEnd::Superscript => self.write("</sup>"), 1607 1997 TagEnd::Subscript => self.write("</sub>"), 1608 - TagEnd::Strong => self.write("</strong>"), 1609 - TagEnd::Strikethrough => self.write("</s>"), 1998 + TagEnd::Strong => { 1999 + self.finalize_paired_inline_format(); 2000 + self.write("</strong>") 2001 + } 2002 + TagEnd::Strikethrough => { 2003 + self.finalize_paired_inline_format(); 2004 + self.write("</s>") 2005 + } 1610 2006 TagEnd::Link => self.write("</a>"), 1611 2007 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 1612 2008 TagEnd::Embed => Ok(()),