refactored iroh out of main thread

Orual 51c54057 a89ecef2

+2313 -1464
+1
Cargo.lock
··· 11527 "dioxus-sdk", 11528 "dotenvy", 11529 "fontdb", 11530 "getrandom 0.3.4", 11531 "gloo-storage", 11532 "gloo-timers",
··· 11527 "dioxus-sdk", 11528 "dotenvy", 11529 "fontdb", 11530 + "futures-util", 11531 "getrandom 0.3.4", 11532 "gloo-storage", 11533 "gloo-timers",
+1 -1
build-workers.sh
··· 5 export RUSTFLAGS='--cfg getrandom_backend="wasm_js"' 6 cargo build -p weaver-app --bin editor_worker --bin embed_worker \ 7 --target wasm32-unknown-unknown --release \ 8 - --no-default-features --features "web" 9 10 echo "==> Running wasm-bindgen" 11 wasm-bindgen target/wasm32-unknown-unknown/release/editor_worker.wasm \
··· 5 export RUSTFLAGS='--cfg getrandom_backend="wasm_js"' 6 cargo build -p weaver-app --bin editor_worker --bin embed_worker \ 7 --target wasm32-unknown-unknown --release \ 8 + --no-default-features --features "web","collab-worker" 9 10 echo "==> Running wasm-bindgen" 11 wasm-bindgen target/wasm32-unknown-unknown/release/editor_worker.wasm \
-10
crates/weaver-app/.env-dev
··· 1 - WEAVER_APP_ENV="dev" 2 - WEAVER_APP_HOST="http://localhost" 3 - WEAVER_APP_DOMAIN="" 4 - WEAVER_PORT=8080 5 - WEAVER_APP_SCOPES="atproto transition:generic" 6 - WEAVER_CLIENT_NAME="Weaver" 7 - 8 - WEAVER_LOGO_URI="" 9 - WEAVER_TOS_URI="" 10 - WEAVER_PRIVACY_POLICY_URI=""
···
+10
crates/weaver-app/.env-prod
···
··· 1 + WEAVER_APP_ENV="prod" 2 + WEAVER_APP_HOST="https://alpha.weaver.sh" 3 + WEAVER_APP_DOMAIN="https://alpha.weaver.sh" 4 + WEAVER_PORT=8080 5 + WEAVER_APP_SCOPES="atproto transition:generic" 6 + WEAVER_CLIENT_NAME="Weaver" 7 + 8 + WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico" 9 + WEAVER_TOS_URI="" 10 + WEAVER_PRIVACY_POLICY_URI=""
+2 -1
crates/weaver-app/Cargo.toml
··· 75 urlencoding = "2.1" 76 tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] } 77 dioxus-sdk = { version = "0.7", features = ["time"] } 78 79 # OG image generation (server-only) 80 resvg = { version = "0.44", optional = true } ··· 99 chrono = { version = "0.4", features = ["wasmbind"] } 100 wasm-bindgen = "0.2" 101 wasm-bindgen-futures = "0.4" 102 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList"] } 103 js-sys = "0.3" 104 gloo-storage = "0.3" 105 gloo-timers = "0.3"
··· 75 urlencoding = "2.1" 76 tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] } 77 dioxus-sdk = { version = "0.7", features = ["time"] } 78 + futures-util = "0.3" 79 80 # OG image generation (server-only) 81 resvg = { version = "0.44", optional = true } ··· 100 chrono = { version = "0.4", features = ["wasmbind"] } 101 wasm-bindgen = "0.2" 102 wasm-bindgen-futures = "0.4" 103 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList", "Performance"] } 104 js-sys = "0.3" 105 gloo-storage = "0.3" 106 gloo-timers = "0.3"
+488 -6
crates/weaver-app/public/editor_worker.js
··· 17 ? { register: () => {}, unregister: () => {} } 18 : new FinalizationRegistry(state => state.dtor(state.a, state.b)); 19 20 function getArrayU8FromWasm0(ptr, len) { 21 ptr = ptr >>> 0; 22 return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); ··· 167 168 let WASM_VECTOR_LEN = 0; 169 170 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 171 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 172 } 173 174 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 175 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 176 } 177 178 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { 179 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3); 180 } 181 182 const __wbindgen_enum_ReadableStreamType = ["bytes"]; 183 184 const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') 185 ? { register: () => {}, unregister: () => {} } ··· 361 function __wbg_get_imports() { 362 const imports = {}; 363 imports.wbg = {}; 364 imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) { 365 const ret = typeof(arg0) === 'function'; 366 return ret; ··· 378 const ret = arg0 === undefined; 379 return ret; 380 }; 381 imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) { 382 throw new Error(getStringFromWasm0(arg0, arg1)); 383 }; 384 imports.wbg.__wbg__wbg_cb_unref_87dfb5aaa0cbcea7 = function(arg0) { 385 arg0._wbg_cb_unref(); 386 }; 387 imports.wbg.__wbg_buffer_6cb2fecb1f253d71 = function(arg0) { 388 const ret = arg0.buffer; 389 return ret; ··· 408 const ret = arg0.call(arg1); 409 return ret; 410 }, arguments) }; 411 imports.wbg.__wbg_close_0af5661bf3d335f2 = function() { return handleError(function (arg0) { 412 arg0.close(); 413 }, arguments) }; 414 imports.wbg.__wbg_close_0b472ca2d13f54f7 = function(arg0) { 415 arg0.close(); 416 }; 417 imports.wbg.__wbg_close_3ec111e7b23d94d8 = function() { return handleError(function (arg0) { 418 arg0.close(); 419 }, arguments) }; 420 imports.wbg.__wbg_crypto_574e78ad8b13b65f = function(arg0) { 421 const ret = arg0.crypto; 422 return ret; 423 }; 424 imports.wbg.__wbg_data_8bf4ae669a78a688 = function(arg0) { 425 const ret = arg0.data; 426 return ret; 427 }; 428 imports.wbg.__wbg_enqueue_a7e6b1ee87963aad = function() { return handleError(function (arg0, arg1) { ··· 439 wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 440 } 441 }; 442 imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) { 443 arg0.getRandomValues(arg1); 444 }, arguments) }; 445 imports.wbg.__wbg_instanceof_Window_b5cf7783caa68180 = function(arg0) { 446 let result; 447 try { ··· 450 result = false; 451 } 452 const ret = result; 453 return ret; 454 }; 455 imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) { 456 const ret = arg0.length; 457 return ret; 458 }; 459 imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) { 460 const ret = arg0.msCrypto; 461 return ret; 462 }; 463 imports.wbg.__wbg_new_8a6f238a6ece86ea = function() { 464 const ret = new Error(); 465 return ret; ··· 502 const ret = new Uint8Array(arg0 >>> 0); 503 return ret; 504 }; 505 imports.wbg.__wbg_node_905d3e251edff8a2 = function(arg0) { 506 const ret = arg0.node; 507 return ret; 508 }; 509 imports.wbg.__wbg_now_8a87c5466cc7d560 = function() { 510 const ret = Date.now(); 511 return ret; 512 }; 513 imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { 514 const ret = arg0.now(); 515 return ret; 516 }; 517 imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { ··· 528 imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) { 529 Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); 530 }; 531 imports.wbg.__wbg_queueMicrotask_9b549dfce8865860 = function(arg0) { 532 const ret = arg0.queueMicrotask; 533 return ret; ··· 538 imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) { 539 arg0.randomFillSync(arg1); 540 }, arguments) }; 541 imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () { 542 const ret = module.require; 543 return ret; ··· 549 imports.wbg.__wbg_respond_9f7fc54636c4a3af = function() { return handleError(function (arg0, arg1) { 550 arg0.respond(arg1 >>> 0); 551 }, arguments) }; 552 imports.wbg.__wbg_set_169e13b608078b7b = function(arg0, arg1, arg2) { 553 arg0.set(getArrayU8FromWasm0(arg1, arg2)); 554 }; 555 imports.wbg.__wbg_set_onmessage_5fe29d0fb54cb575 = function(arg0, arg1) { 556 arg0.onmessage = arg1; 557 }; 558 imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) { 559 const ret = arg1.stack; 560 const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); ··· 578 const ret = typeof window === 'undefined' ? null : window; 579 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 580 }; 581 imports.wbg.__wbg_subarray_845f2f5bce7d061a = function(arg0, arg1, arg2) { 582 const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); 583 return ret; 584 }; 585 imports.wbg.__wbg_then_4f95312d68691235 = function(arg0, arg1) { 586 const ret = arg0.then(arg1); 587 return ret; 588 }; 589 imports.wbg.__wbg_versions_c01dfd4722a88165 = function(arg0) { 590 const ret = arg0.versions; 591 return ret; ··· 594 const ret = arg0.view; 595 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 596 }; 597 - imports.wbg.__wbindgen_cast_1201d4f342f74eb8 = function(arg0, arg1) { 598 - // Cast intrinsic for `Closure(Closure { dtor_idx: 909, function: Function { arguments: [Externref], shim_idx: 910, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 599 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 600 return ret; 601 }; 602 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 603 // Cast intrinsic for `Ref(String) -> Externref`. 604 const ret = getStringFromWasm0(arg0, arg1); 605 return ret; 606 }; 607 - imports.wbg.__wbindgen_cast_7486151126cca30f = function(arg0, arg1) { 608 - // Cast intrinsic for `Closure(Closure { dtor_idx: 66, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 67, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 609 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 610 return ret; 611 }; 612 imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) {
··· 17 ? { register: () => {}, unregister: () => {} } 18 : new FinalizationRegistry(state => state.dtor(state.a, state.b)); 19 20 + function debugString(val) { 21 + // primitive types 22 + const type = typeof val; 23 + if (type == 'number' || type == 'boolean' || val == null) { 24 + return `${val}`; 25 + } 26 + if (type == 'string') { 27 + return `"${val}"`; 28 + } 29 + if (type == 'symbol') { 30 + const description = val.description; 31 + if (description == null) { 32 + return 'Symbol'; 33 + } else { 34 + return `Symbol(${description})`; 35 + } 36 + } 37 + if (type == 'function') { 38 + const name = val.name; 39 + if (typeof name == 'string' && name.length > 0) { 40 + return `Function(${name})`; 41 + } else { 42 + return 'Function'; 43 + } 44 + } 45 + // objects 46 + if (Array.isArray(val)) { 47 + const length = val.length; 48 + let debug = '['; 49 + if (length > 0) { 50 + debug += debugString(val[0]); 51 + } 52 + for(let i = 1; i < length; i++) { 53 + debug += ', ' + debugString(val[i]); 54 + } 55 + debug += ']'; 56 + return debug; 57 + } 58 + // Test for built-in 59 + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); 60 + let className; 61 + if (builtInMatches && builtInMatches.length > 1) { 62 + className = builtInMatches[1]; 63 + } else { 64 + // Failed to match the standard '[object ClassName]' 65 + return toString.call(val); 66 + } 67 + if (className == 'Object') { 68 + // we're a user defined class or Object 69 + // JSON.stringify avoids problems with cycles, and is generally much 70 + // easier than looping through ownProperties of `val`. 71 + try { 72 + return 'Object(' + JSON.stringify(val) + ')'; 73 + } catch (_) { 74 + return 'Object'; 75 + } 76 + } 77 + // errors 78 + if (val instanceof Error) { 79 + return `${val.name}: ${val.message}\n${val.stack}`; 80 + } 81 + // TODO we could test for more things here, like `Set`s and `Map`s. 82 + return className; 83 + } 84 + 85 function getArrayU8FromWasm0(ptr, len) { 86 ptr = ptr >>> 0; 87 return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); ··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { 236 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); 237 } 238 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 241 + } 242 + 243 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 244 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 245 + } 246 + 247 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 248 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 249 + } 250 + 251 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { 252 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2); 253 + } 254 + 255 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______2_(arg0, arg1) { 256 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______2_(arg0, arg1); 257 + } 258 + 259 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { 260 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); 261 + } 262 + 263 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 264 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 265 } 266 267 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { 268 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3); 269 } 270 271 + const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; 272 + 273 const __wbindgen_enum_ReadableStreamType = ["bytes"]; 274 + 275 + const __wbindgen_enum_RequestCache = ["default", "no-store", "reload", "no-cache", "force-cache", "only-if-cached"]; 276 + 277 + const __wbindgen_enum_RequestCredentials = ["omit", "same-origin", "include"]; 278 + 279 + const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"]; 280 281 const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') 282 ? { register: () => {}, unregister: () => {} } ··· 458 function __wbg_get_imports() { 459 const imports = {}; 460 imports.wbg = {}; 461 + imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) { 462 + const v = arg0; 463 + const ret = typeof(v) === 'boolean' ? v : undefined; 464 + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; 465 + }; 466 + imports.wbg.__wbg___wbindgen_debug_string_adfb662ae34724b6 = function(arg0, arg1) { 467 + const ret = debugString(arg1); 468 + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 469 + const len1 = WASM_VECTOR_LEN; 470 + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 471 + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 472 + }; 473 imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) { 474 const ret = typeof(arg0) === 'function'; 475 return ret; ··· 487 const ret = arg0 === undefined; 488 return ret; 489 }; 490 + imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) { 491 + const obj = arg1; 492 + const ret = typeof(obj) === 'string' ? obj : undefined; 493 + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 494 + var len1 = WASM_VECTOR_LEN; 495 + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 496 + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 497 + }; 498 imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) { 499 throw new Error(getStringFromWasm0(arg0, arg1)); 500 }; 501 imports.wbg.__wbg__wbg_cb_unref_87dfb5aaa0cbcea7 = function(arg0) { 502 arg0._wbg_cb_unref(); 503 }; 504 + imports.wbg.__wbg_abort_07646c894ebbf2bd = function(arg0) { 505 + arg0.abort(); 506 + }; 507 + imports.wbg.__wbg_abort_399ecbcfd6ef3c8e = function(arg0, arg1) { 508 + arg0.abort(arg1); 509 + }; 510 + imports.wbg.__wbg_addEventListener_e792423147a80626 = function() { return handleError(function (arg0, arg1, arg2, arg3) { 511 + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); 512 + }, arguments) }; 513 + imports.wbg.__wbg_append_c5cbdf46455cc776 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { 514 + arg0.append(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); 515 + }, arguments) }; 516 + imports.wbg.__wbg_arrayBuffer_c04af4fce566092d = function() { return handleError(function (arg0) { 517 + const ret = arg0.arrayBuffer(); 518 + return ret; 519 + }, arguments) }; 520 + imports.wbg.__wbg_body_947b901c33f7fe32 = function(arg0) { 521 + const ret = arg0.body; 522 + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 523 + }; 524 imports.wbg.__wbg_buffer_6cb2fecb1f253d71 = function(arg0) { 525 const ret = arg0.buffer; 526 return ret; ··· 545 const ret = arg0.call(arg1); 546 return ret; 547 }, arguments) }; 548 + imports.wbg.__wbg_cancel_a65cf45dca50ba4c = function(arg0) { 549 + const ret = arg0.cancel(); 550 + return ret; 551 + }; 552 + imports.wbg.__wbg_catch_b9db41d97d42bd02 = function(arg0, arg1) { 553 + const ret = arg0.catch(arg1); 554 + return ret; 555 + }; 556 + imports.wbg.__wbg_clearTimeout_7a42b49784aea641 = function(arg0) { 557 + const ret = clearTimeout(arg0); 558 + return ret; 559 + }; 560 + imports.wbg.__wbg_clearTimeout_f7a6c75a3d228439 = function() { return handleError(function (arg0, arg1) { 561 + arg0.clearTimeout(arg1); 562 + }, arguments) }; 563 imports.wbg.__wbg_close_0af5661bf3d335f2 = function() { return handleError(function (arg0) { 564 arg0.close(); 565 }, arguments) }; 566 imports.wbg.__wbg_close_0b472ca2d13f54f7 = function(arg0) { 567 arg0.close(); 568 }; 569 + imports.wbg.__wbg_close_1db3952de1b5b1cf = function() { return handleError(function (arg0) { 570 + arg0.close(); 571 + }, arguments) }; 572 imports.wbg.__wbg_close_3ec111e7b23d94d8 = function() { return handleError(function (arg0) { 573 arg0.close(); 574 }, arguments) }; 575 + imports.wbg.__wbg_code_85a811fe6ca962be = function(arg0) { 576 + const ret = arg0.code; 577 + return ret; 578 + }; 579 + imports.wbg.__wbg_code_c2a85f2863ec11b3 = function(arg0) { 580 + const ret = arg0.code; 581 + return ret; 582 + }; 583 imports.wbg.__wbg_crypto_574e78ad8b13b65f = function(arg0) { 584 const ret = arg0.crypto; 585 return ret; 586 }; 587 imports.wbg.__wbg_data_8bf4ae669a78a688 = function(arg0) { 588 const ret = arg0.data; 589 + return ret; 590 + }; 591 + imports.wbg.__wbg_done_62ea16af4ce34b24 = function(arg0) { 592 + const ret = arg0.done; 593 return ret; 594 }; 595 imports.wbg.__wbg_enqueue_a7e6b1ee87963aad = function() { return handleError(function (arg0, arg1) { ··· 606 wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 607 } 608 }; 609 + imports.wbg.__wbg_fetch_74a3e84ebd2c9a0e = function(arg0) { 610 + const ret = fetch(arg0); 611 + return ret; 612 + }; 613 + imports.wbg.__wbg_fetch_90447c28cc0b095e = function(arg0, arg1) { 614 + const ret = arg0.fetch(arg1); 615 + return ret; 616 + }; 617 + imports.wbg.__wbg_getRandomValues_1c61fac11405ffdc = function() { return handleError(function (arg0, arg1) { 618 + globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); 619 + }, arguments) }; 620 imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) { 621 arg0.getRandomValues(arg1); 622 }, arguments) }; 623 + imports.wbg.__wbg_getReader_48e00749fe3f6089 = function() { return handleError(function (arg0) { 624 + const ret = arg0.getReader(); 625 + return ret; 626 + }, arguments) }; 627 + imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) { 628 + const ret = Reflect.get(arg0, arg1); 629 + return ret; 630 + }, arguments) }; 631 + imports.wbg.__wbg_get_done_f98a6e62c4e18fb9 = function(arg0) { 632 + const ret = arg0.done; 633 + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; 634 + }; 635 + imports.wbg.__wbg_get_value_63e39884ef11812e = function(arg0) { 636 + const ret = arg0.value; 637 + return ret; 638 + }; 639 + imports.wbg.__wbg_has_0e670569d65d3a45 = function() { return handleError(function (arg0, arg1) { 640 + const ret = Reflect.has(arg0, arg1); 641 + return ret; 642 + }, arguments) }; 643 + imports.wbg.__wbg_headers_654c30e1bcccc552 = function(arg0) { 644 + const ret = arg0.headers; 645 + return ret; 646 + }; 647 + imports.wbg.__wbg_instanceof_ArrayBuffer_f3320d2419cd0355 = function(arg0) { 648 + let result; 649 + try { 650 + result = arg0 instanceof ArrayBuffer; 651 + } catch (_) { 652 + result = false; 653 + } 654 + const ret = result; 655 + return ret; 656 + }; 657 + imports.wbg.__wbg_instanceof_Blob_e9c51ce33a4b6181 = function(arg0) { 658 + let result; 659 + try { 660 + result = arg0 instanceof Blob; 661 + } catch (_) { 662 + result = false; 663 + } 664 + const ret = result; 665 + return ret; 666 + }; 667 + imports.wbg.__wbg_instanceof_Response_cd74d1c2ac92cb0b = function(arg0) { 668 + let result; 669 + try { 670 + result = arg0 instanceof Response; 671 + } catch (_) { 672 + result = false; 673 + } 674 + const ret = result; 675 + return ret; 676 + }; 677 imports.wbg.__wbg_instanceof_Window_b5cf7783caa68180 = function(arg0) { 678 let result; 679 try { ··· 682 result = false; 683 } 684 const ret = result; 685 + return ret; 686 + }; 687 + imports.wbg.__wbg_iterator_27b7c8b35ab3e86b = function() { 688 + const ret = Symbol.iterator; 689 return ret; 690 }; 691 imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) { 692 const ret = arg0.length; 693 return ret; 694 }; 695 + imports.wbg.__wbg_log_0cc1b7768397bcfe = function(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) { 696 + let deferred0_0; 697 + let deferred0_1; 698 + try { 699 + deferred0_0 = arg0; 700 + deferred0_1 = arg1; 701 + console.log(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3), getStringFromWasm0(arg4, arg5), getStringFromWasm0(arg6, arg7)); 702 + } finally { 703 + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 704 + } 705 + }; 706 + imports.wbg.__wbg_log_cb9e190acc5753fb = function(arg0, arg1) { 707 + let deferred0_0; 708 + let deferred0_1; 709 + try { 710 + deferred0_0 = arg0; 711 + deferred0_1 = arg1; 712 + console.log(getStringFromWasm0(arg0, arg1)); 713 + } finally { 714 + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 715 + } 716 + }; 717 + imports.wbg.__wbg_mark_7438147ce31e9d4b = function(arg0, arg1) { 718 + performance.mark(getStringFromWasm0(arg0, arg1)); 719 + }; 720 + imports.wbg.__wbg_measure_fb7825c11612c823 = function() { return handleError(function (arg0, arg1, arg2, arg3) { 721 + let deferred0_0; 722 + let deferred0_1; 723 + let deferred1_0; 724 + let deferred1_1; 725 + try { 726 + deferred0_0 = arg0; 727 + deferred0_1 = arg1; 728 + deferred1_0 = arg2; 729 + deferred1_1 = arg3; 730 + performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3)); 731 + } finally { 732 + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 733 + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); 734 + } 735 + }, arguments) }; 736 + imports.wbg.__wbg_message_a4e9a39ee8f92b17 = function(arg0, arg1) { 737 + const ret = arg1.message; 738 + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 739 + const len1 = WASM_VECTOR_LEN; 740 + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 741 + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 742 + }; 743 imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) { 744 const ret = arg0.msCrypto; 745 return ret; 746 }; 747 + imports.wbg.__wbg_new_1ba21ce319a06297 = function() { 748 + const ret = new Object(); 749 + return ret; 750 + }; 751 + imports.wbg.__wbg_new_25f239778d6112b9 = function() { 752 + const ret = new Array(); 753 + return ret; 754 + }; 755 + imports.wbg.__wbg_new_3c79b3bb1b32b7d3 = function() { return handleError(function () { 756 + const ret = new Headers(); 757 + return ret; 758 + }, arguments) }; 759 + imports.wbg.__wbg_new_6421f6084cc5bc5a = function(arg0) { 760 + const ret = new Uint8Array(arg0); 761 + return ret; 762 + }; 763 + imports.wbg.__wbg_new_7c30d1f874652e62 = function() { return handleError(function (arg0, arg1) { 764 + const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); 765 + return ret; 766 + }, arguments) }; 767 + imports.wbg.__wbg_new_881a222c65f168fc = function() { return handleError(function () { 768 + const ret = new AbortController(); 769 + return ret; 770 + }, arguments) }; 771 imports.wbg.__wbg_new_8a6f238a6ece86ea = function() { 772 const ret = new Error(); 773 return ret; ··· 810 const ret = new Uint8Array(arg0 >>> 0); 811 return ret; 812 }; 813 + imports.wbg.__wbg_new_with_str_and_init_c5748f76f5108934 = function() { return handleError(function (arg0, arg1, arg2) { 814 + const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); 815 + return ret; 816 + }, arguments) }; 817 + imports.wbg.__wbg_new_with_str_sequence_073466a4a5387941 = function() { return handleError(function (arg0, arg1, arg2) { 818 + const ret = new WebSocket(getStringFromWasm0(arg0, arg1), arg2); 819 + return ret; 820 + }, arguments) }; 821 + imports.wbg.__wbg_next_138a17bbf04e926c = function(arg0) { 822 + const ret = arg0.next; 823 + return ret; 824 + }; 825 + imports.wbg.__wbg_next_3cfe5c0fe2a4cc53 = function() { return handleError(function (arg0) { 826 + const ret = arg0.next(); 827 + return ret; 828 + }, arguments) }; 829 imports.wbg.__wbg_node_905d3e251edff8a2 = function(arg0) { 830 const ret = arg0.node; 831 return ret; 832 }; 833 + imports.wbg.__wbg_now_2c95c9de01293173 = function(arg0) { 834 + const ret = arg0.now(); 835 + return ret; 836 + }; 837 + imports.wbg.__wbg_now_69d776cd24f5215b = function() { 838 + const ret = Date.now(); 839 + return ret; 840 + }; 841 imports.wbg.__wbg_now_8a87c5466cc7d560 = function() { 842 const ret = Date.now(); 843 return ret; 844 }; 845 imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { 846 const ret = arg0.now(); 847 + return ret; 848 + }; 849 + imports.wbg.__wbg_performance_7a3ffd0b17f663ad = function(arg0) { 850 + const ret = arg0.performance; 851 return ret; 852 }; 853 imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { ··· 864 imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) { 865 Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); 866 }; 867 + imports.wbg.__wbg_push_7d9be8f38fc13975 = function(arg0, arg1) { 868 + const ret = arg0.push(arg1); 869 + return ret; 870 + }; 871 imports.wbg.__wbg_queueMicrotask_9b549dfce8865860 = function(arg0) { 872 const ret = arg0.queueMicrotask; 873 return ret; ··· 878 imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) { 879 arg0.randomFillSync(arg1); 880 }, arguments) }; 881 + imports.wbg.__wbg_read_39c4b35efcd03c25 = function(arg0) { 882 + const ret = arg0.read(); 883 + return ret; 884 + }; 885 + imports.wbg.__wbg_readyState_9d0976dcad561aa9 = function(arg0) { 886 + const ret = arg0.readyState; 887 + return ret; 888 + }; 889 + imports.wbg.__wbg_reason_d4eb9e40592438c2 = function(arg0, arg1) { 890 + const ret = arg1.reason; 891 + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 892 + const len1 = WASM_VECTOR_LEN; 893 + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 894 + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 895 + }; 896 + imports.wbg.__wbg_releaseLock_a5912f590b185180 = function(arg0) { 897 + arg0.releaseLock(); 898 + }; 899 + imports.wbg.__wbg_removeEventListener_54bf92f4a849bd7d = function() { return handleError(function (arg0, arg1, arg2, arg3) { 900 + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); 901 + }, arguments) }; 902 imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () { 903 const ret = module.require; 904 return ret; ··· 910 imports.wbg.__wbg_respond_9f7fc54636c4a3af = function() { return handleError(function (arg0, arg1) { 911 arg0.respond(arg1 >>> 0); 912 }, arguments) }; 913 + imports.wbg.__wbg_send_7cc36bb628044281 = function() { return handleError(function (arg0, arg1, arg2) { 914 + arg0.send(getStringFromWasm0(arg1, arg2)); 915 + }, arguments) }; 916 + imports.wbg.__wbg_send_ea59e150ab5ebe08 = function() { return handleError(function (arg0, arg1, arg2) { 917 + arg0.send(getArrayU8FromWasm0(arg1, arg2)); 918 + }, arguments) }; 919 + imports.wbg.__wbg_setTimeout_7bb3429662ab1e70 = function(arg0, arg1) { 920 + const ret = setTimeout(arg0, arg1); 921 + return ret; 922 + }; 923 + imports.wbg.__wbg_setTimeout_ceaa8eadc563d26e = function() { return handleError(function (arg0, arg1, arg2) { 924 + const ret = arg0.setTimeout(arg1, arg2); 925 + return ret; 926 + }, arguments) }; 927 imports.wbg.__wbg_set_169e13b608078b7b = function(arg0, arg1, arg2) { 928 arg0.set(getArrayU8FromWasm0(arg1, arg2)); 929 }; 930 + imports.wbg.__wbg_set_binaryType_73e8c75df97825f8 = function(arg0, arg1) { 931 + arg0.binaryType = __wbindgen_enum_BinaryType[arg1]; 932 + }; 933 + imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { 934 + arg0.body = arg1; 935 + }; 936 + imports.wbg.__wbg_set_cache_0e437c7c8e838b9b = function(arg0, arg1) { 937 + arg0.cache = __wbindgen_enum_RequestCache[arg1]; 938 + }; 939 + imports.wbg.__wbg_set_credentials_55ae7c3c106fd5be = function(arg0, arg1) { 940 + arg0.credentials = __wbindgen_enum_RequestCredentials[arg1]; 941 + }; 942 + imports.wbg.__wbg_set_handle_event_14baa3949ef6909d = function(arg0, arg1) { 943 + arg0.handleEvent = arg1; 944 + }; 945 + imports.wbg.__wbg_set_headers_5671cf088e114d2b = function(arg0, arg1) { 946 + arg0.headers = arg1; 947 + }; 948 + imports.wbg.__wbg_set_method_76c69e41b3570627 = function(arg0, arg1, arg2) { 949 + arg0.method = getStringFromWasm0(arg1, arg2); 950 + }; 951 + imports.wbg.__wbg_set_mode_611016a6818fc690 = function(arg0, arg1) { 952 + arg0.mode = __wbindgen_enum_RequestMode[arg1]; 953 + }; 954 + imports.wbg.__wbg_set_onclose_032729b3d7ed7a9e = function(arg0, arg1) { 955 + arg0.onclose = arg1; 956 + }; 957 + imports.wbg.__wbg_set_onerror_7819daa6af176ddb = function(arg0, arg1) { 958 + arg0.onerror = arg1; 959 + }; 960 imports.wbg.__wbg_set_onmessage_5fe29d0fb54cb575 = function(arg0, arg1) { 961 arg0.onmessage = arg1; 962 }; 963 + imports.wbg.__wbg_set_onmessage_71321d0bed69856c = function(arg0, arg1) { 964 + arg0.onmessage = arg1; 965 + }; 966 + imports.wbg.__wbg_set_onopen_6d4abedb27ba5656 = function(arg0, arg1) { 967 + arg0.onopen = arg1; 968 + }; 969 + imports.wbg.__wbg_set_signal_e89be862d0091009 = function(arg0, arg1) { 970 + arg0.signal = arg1; 971 + }; 972 + imports.wbg.__wbg_signal_3c14fbdc89694b39 = function(arg0) { 973 + const ret = arg0.signal; 974 + return ret; 975 + }; 976 imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) { 977 const ret = arg1.stack; 978 const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); ··· 996 const ret = typeof window === 'undefined' ? null : window; 997 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 998 }; 999 + imports.wbg.__wbg_status_9bfc680efca4bdfd = function(arg0) { 1000 + const ret = arg0.status; 1001 + return ret; 1002 + }; 1003 + imports.wbg.__wbg_stringify_655a6390e1f5eb6b = function() { return handleError(function (arg0) { 1004 + const ret = JSON.stringify(arg0); 1005 + return ret; 1006 + }, arguments) }; 1007 imports.wbg.__wbg_subarray_845f2f5bce7d061a = function(arg0, arg1, arg2) { 1008 const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); 1009 return ret; 1010 }; 1011 + imports.wbg.__wbg_then_429f7caf1026411d = function(arg0, arg1, arg2) { 1012 + const ret = arg0.then(arg1, arg2); 1013 + return ret; 1014 + }; 1015 imports.wbg.__wbg_then_4f95312d68691235 = function(arg0, arg1) { 1016 const ret = arg0.then(arg1); 1017 return ret; 1018 }; 1019 + imports.wbg.__wbg_url_b6d11838a4f95198 = function(arg0, arg1) { 1020 + const ret = arg1.url; 1021 + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 1022 + const len1 = WASM_VECTOR_LEN; 1023 + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 1024 + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 1025 + }; 1026 + imports.wbg.__wbg_url_df28eef824b04410 = function(arg0, arg1) { 1027 + const ret = arg1.url; 1028 + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 1029 + const len1 = WASM_VECTOR_LEN; 1030 + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); 1031 + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); 1032 + }; 1033 + imports.wbg.__wbg_value_57b7b035e117f7ee = function(arg0) { 1034 + const ret = arg0.value; 1035 + return ret; 1036 + }; 1037 imports.wbg.__wbg_versions_c01dfd4722a88165 = function(arg0) { 1038 const ret = arg0.versions; 1039 return ret; ··· 1042 const ret = arg0.view; 1043 return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 1044 }; 1045 + imports.wbg.__wbg_wasClean_4154a2d59fdb4dd7 = function(arg0) { 1046 + const ret = arg0.wasClean; 1047 + return ret; 1048 + }; 1049 + imports.wbg.__wbindgen_cast_0040fcf5dbb58dd5 = function(arg0, arg1) { 1050 + // Cast intrinsic for `Closure(Closure { dtor_idx: 5300, function: Function { arguments: [Externref], shim_idx: 5301, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1051 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 1052 return ret; 1053 }; 1054 + imports.wbg.__wbindgen_cast_12196eb5081a2539 = function(arg0, arg1) { 1055 + // Cast intrinsic for `Closure(Closure { dtor_idx: 3492, function: Function { arguments: [], shim_idx: 3493, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 1056 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 1057 + return ret; 1058 + }; 1059 + imports.wbg.__wbindgen_cast_19a2ac9a2d117d63 = function(arg0, arg1) { 1060 + // Cast intrinsic for `Closure(Closure { dtor_idx: 3436, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 3437, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1061 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____); 1062 + return ret; 1063 + }; 1064 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 1065 // Cast intrinsic for `Ref(String) -> Externref`. 1066 const ret = getStringFromWasm0(arg0, arg1); 1067 return ret; 1068 }; 1069 + imports.wbg.__wbindgen_cast_349b5f2a8c1e5597 = function(arg0, arg1) { 1070 + // Cast intrinsic for `Closure(Closure { dtor_idx: 3990, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 3991, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1071 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_); 1072 + return ret; 1073 + }; 1074 + imports.wbg.__wbindgen_cast_70089f6cfcc0d4b7 = function(arg0, arg1) { 1075 + // Cast intrinsic for `Closure(Closure { dtor_idx: 391, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 392, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 1076 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 1077 + return ret; 1078 + }; 1079 + imports.wbg.__wbindgen_cast_89505235e1110442 = function(arg0, arg1) { 1080 + // Cast intrinsic for `Closure(Closure { dtor_idx: 3469, function: Function { arguments: [], shim_idx: 3470, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1081 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 1082 + return ret; 1083 + }; 1084 + imports.wbg.__wbindgen_cast_9ce2f96e3ea0b3c4 = function(arg0, arg1) { 1085 + // Cast intrinsic for `Closure(Closure { dtor_idx: 5120, function: Function { arguments: [], shim_idx: 5121, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1086 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________2_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_); 1087 + return ret; 1088 + }; 1089 + imports.wbg.__wbindgen_cast_b5e2cf29d57ca6af = function(arg0, arg1) { 1090 + // Cast intrinsic for `Closure(Closure { dtor_idx: 5007, function: Function { arguments: [], shim_idx: 5008, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 1091 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______2_); 1092 return ret; 1093 }; 1094 imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) {
+19 -19
crates/weaver-app/public/embed_worker.js
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 236 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 237 - } 238 - 239 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 240 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 241 - } 242 - 243 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 244 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 245 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 248 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 249 } 250 251 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { ··· 809 const ret = getStringFromWasm0(arg0, arg1); 810 return ret; 811 }; 812 - imports.wbg.__wbindgen_cast_58535cde6944cfce = function(arg0, arg1) { 813 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2267, function: Function { arguments: [Externref], shim_idx: 2268, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 814 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 815 return ret; 816 }; 817 - imports.wbg.__wbindgen_cast_bd3d7d0f0ebc68be = function(arg0, arg1) { 818 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1185, function: Function { arguments: [], shim_idx: 1186, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 819 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 820 return ret; 821 }; 822 - imports.wbg.__wbindgen_cast_c5ee62b4aaae9530 = function(arg0, arg1) { 823 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1445, function: Function { arguments: [], shim_idx: 1446, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 824 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 825 return ret; 826 }; 827 - imports.wbg.__wbindgen_cast_cf4ca89562bb2a76 = function(arg0, arg1) { 828 - // Cast intrinsic for `Closure(Closure { dtor_idx: 419, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 420, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 829 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 830 return ret; 831 }; 832 imports.wbg.__wbindgen_init_externref_table = function() {
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 237 } 238 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 241 + } 242 + 243 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 244 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 245 + } 246 + 247 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 248 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 249 } 250 251 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { ··· 809 const ret = getStringFromWasm0(arg0, arg1); 810 return ret; 811 }; 812 + imports.wbg.__wbindgen_cast_28fcab414066dd47 = function(arg0, arg1) { 813 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2276, function: Function { arguments: [Externref], shim_idx: 2277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 814 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 815 return ret; 816 }; 817 + imports.wbg.__wbindgen_cast_80e60da953ba3dd9 = function(arg0, arg1) { 818 + // Cast intrinsic for `Closure(Closure { dtor_idx: 310, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 311, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 819 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 820 return ret; 821 }; 822 + imports.wbg.__wbindgen_cast_a731521d4dc80277 = function(arg0, arg1) { 823 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1194, function: Function { arguments: [], shim_idx: 1195, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 824 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 825 return ret; 826 }; 827 + imports.wbg.__wbindgen_cast_e0273882c29884ec = function(arg0, arg1) { 828 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1454, function: Function { arguments: [], shim_idx: 1455, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 829 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 830 return ret; 831 }; 832 imports.wbg.__wbindgen_init_externref_table = function() {
+28 -2
crates/weaver-app/src/bin/editor_worker.rs
··· 6 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 fn main() { 8 console_error_panic_hook::set_once(); 9 10 use gloo_worker::Registrable; 11 - use weaver_app::components::editor::EditorWorker; 12 13 - EditorWorker::registrar().register(); 14 } 15 16 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
··· 6 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 fn main() { 8 console_error_panic_hook::set_once(); 9 + use tracing::Level; 10 + use tracing::subscriber::set_global_default; 11 + use tracing_subscriber::Registry; 12 + use tracing_subscriber::filter::EnvFilter; 13 + use tracing_subscriber::layer::SubscriberExt; 14 + 15 + let console_level = if cfg!(debug_assertions) { 16 + Level::DEBUG 17 + } else { 18 + Level::DEBUG 19 + }; 20 + 21 + let wasm_layer = tracing_wasm::WASMLayer::new( 22 + tracing_wasm::WASMLayerConfigBuilder::new() 23 + .set_max_level(console_level) 24 + .build(), 25 + ); 26 + 27 + // Filter out noisy crates 28 + let filter = EnvFilter::new( 29 + "debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info,iroh=info", 30 + ); 31 + 32 + let reg = Registry::default().with(filter).with(wasm_layer); 33 + 34 + let _ = set_global_default(reg); 35 36 use gloo_worker::Registrable; 37 + use weaver_app::components::editor::EditorReactor; 38 39 + EditorReactor::registrar().register(); 40 } 41 42 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
+4 -95
crates/weaver-app/src/collab_context.rs
··· 1 - //! Real-time collaboration context for P2P editing sessions. 2 - //! 3 - //! This module provides the CollabNode as a Dioxus context, allowing editor 4 - //! components to join gossip sessions for real-time collaboration. 5 //! 6 - //! The CollabNode is only active in WASM builds where iroh works via relays. 7 8 use dioxus::prelude::*; 9 - use std::sync::Arc; 10 - use weaver_common::transport::CollabNode; 11 12 /// Debug state for the collab session, displayed in editor debug panel. 13 #[derive(Clone, Default)] ··· 28 pub last_error: Option<String>, 29 } 30 31 - /// Context state for the collaboration node. 32 - /// 33 - /// This is provided as a Dioxus context and can be accessed by editor components 34 - /// to join/leave collaborative editing sessions. 35 - #[derive(Clone)] 36 - pub struct CollabContext { 37 - /// The collaboration node, if successfully spawned. 38 - /// None while loading or if spawn failed. 39 - pub node: Option<Arc<CollabNode>>, 40 - /// Error message if spawn failed. 41 - pub error: Option<String>, 42 - } 43 - 44 - impl Default for CollabContext { 45 - fn default() -> Self { 46 - Self { 47 - node: None, 48 - error: None, 49 - } 50 - } 51 - } 52 - 53 - /// Provider component that spawns the CollabNode and provides it as context. 54 - /// 55 - /// Should be placed near the root of the app, wrapping any components that 56 - /// need access to real-time collaboration. 57 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 58 - #[component] 59 - pub fn CollabProvider(children: Element) -> Element { 60 - let mut collab_ctx = use_signal(CollabContext::default); 61 - let debug_state = use_signal(CollabDebugState::default); 62 - 63 - // Spawn the CollabNode on mount 64 - let _spawn_result = use_resource(move || async move { 65 - tracing::info!("Spawning CollabNode..."); 66 - 67 - match CollabNode::spawn(None).await { 68 - Ok(node) => { 69 - tracing::info!(node_id = %node.node_id_string(), "CollabNode spawned"); 70 - collab_ctx.set(CollabContext { 71 - node: Some(node), 72 - error: None, 73 - }); 74 - } 75 - Err(e) => { 76 - tracing::error!("Failed to spawn CollabNode: {}", e); 77 - collab_ctx.set(CollabContext { 78 - node: None, 79 - error: Some(e.to_string()), 80 - }); 81 - } 82 - } 83 - }); 84 - 85 - // Provide the contexts 86 - use_context_provider(|| collab_ctx); 87 - use_context_provider(|| debug_state); 88 - 89 - rsx! { {children} } 90 - } 91 - 92 - /// No-op provider for non-WASM builds. 93 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 94 - #[component] 95 - pub fn CollabProvider(children: Element) -> Element { 96 - // On server/native, provide an empty context (collab happens in browser) 97 - let collab_ctx = use_signal(CollabContext::default); 98 - let debug_state = use_signal(CollabDebugState::default); 99 - use_context_provider(|| collab_ctx); 100 - use_context_provider(|| debug_state); 101 - rsx! { {children} } 102 - } 103 - 104 - /// Hook to get the CollabNode from context. 105 - /// 106 - /// Returns None if the node hasn't spawned yet or failed to spawn. 107 - pub fn use_collab_node() -> Option<Arc<CollabNode>> { 108 - let ctx = use_context::<Signal<CollabContext>>(); 109 - ctx.read().node.clone() 110 - } 111 - 112 - /// Hook to check if collab is available. 113 - pub fn use_collab_available() -> bool { 114 - let ctx = use_context::<Signal<CollabContext>>(); 115 - ctx.read().node.is_some() 116 - } 117 - 118 /// Hook to get the collab debug state signal. 119 - /// Returns None if called outside CollabProvider. 120 pub fn try_use_collab_debug() -> Option<Signal<CollabDebugState>> { 121 try_use_context::<Signal<CollabDebugState>>() 122 }
··· 1 + //! Real-time collaboration debug state. 2 //! 3 + //! This module provides CollabDebugState which is set as context by 4 + //! the CollabCoordinator component for display in the editor debug panel. 5 6 use dioxus::prelude::*; 7 8 /// Debug state for the collab session, displayed in editor debug panel. 9 #[derive(Clone, Default)] ··· 24 pub last_error: Option<String>, 25 } 26 27 /// Hook to get the collab debug state signal. 28 + /// Returns None if called outside CollabCoordinator. 29 pub fn try_use_collab_debug() -> Option<Signal<CollabDebugState>> { 30 try_use_context::<Signal<CollabDebugState>>() 31 }
+545
crates/weaver-app/src/components/editor/collab.rs
···
··· 1 + //! Collab coordinator - bridges EditorWorker and authenticated PDS ops. 2 + //! 3 + //! This component handles the main-thread side of real-time collaboration: 4 + //! - Spawns the editor worker and manages its lifecycle 5 + //! - Performs authenticated PDS operations (session records, peer discovery) 6 + //! - Forwards local Loro updates to the worker for gossip broadcast 7 + //! - Receives remote updates from worker and applies to main document 8 + //! - Provides CollabDebugState context for debug UI 9 + //! 10 + //! The worker handles all iroh/gossip networking off the main thread. 11 + 12 + // Only compile for WASM - no-op stub provided at end 13 + 14 + use super::document::EditorDocument; 15 + 16 + use dioxus::prelude::*; 17 + 18 + #[cfg(target_arch = "wasm32")] 19 + use jacquard::types::string::AtUri; 20 + 21 + use weaver_common::transport::PresenceSnapshot; 22 + 23 + /// Session record TTL in minutes. 24 + #[cfg(target_arch = "wasm32")] 25 + const SESSION_TTL_MINUTES: u32 = 15; 26 + 27 + /// How often to refresh session record (ms). 28 + #[cfg(target_arch = "wasm32")] 29 + const SESSION_REFRESH_INTERVAL_MS: u32 = 5 * 60 * 1000; // 5 minutes 30 + 31 + /// How often to poll for new peers (ms). 32 + #[cfg(target_arch = "wasm32")] 33 + const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds 34 + 35 + /// Props for the CollabCoordinator component. 36 + #[derive(Props, Clone, PartialEq)] 37 + pub struct CollabCoordinatorProps { 38 + /// The editor document to sync 39 + pub document: EditorDocument, 40 + /// Resource URI for the document being edited 41 + pub resource_uri: String, 42 + /// Presence state signal (updated by coordinator) 43 + pub presence: Signal<PresenceSnapshot>, 44 + /// Children to render (this component wraps the editor) 45 + pub children: Element, 46 + } 47 + 48 + /// Coordinator state machine states. 49 + #[cfg(target_arch = "wasm32")] 50 + #[derive(Debug, Clone, PartialEq)] 51 + enum CoordinatorState { 52 + /// Initial state - waiting for worker to be ready 53 + Initializing, 54 + /// Creating session record on PDS 55 + CreatingSession { 56 + node_id: String, 57 + relay_url: Option<String>, 58 + }, 59 + /// Active collab session 60 + Active { session_uri: AtUri<'static> }, 61 + /// Error state 62 + Error(String), 63 + } 64 + 65 + /// Coordinator component that bridges worker and PDS. 66 + /// 67 + /// This is a wrapper component that: 68 + /// 1. Provides CollabDebugState context 69 + /// 2. Manages collab lifecycle (worker, PDS records, peer discovery) 70 + /// 3. Renders children 71 + /// 72 + /// Lifecycle: 73 + /// 1. Worker spawned on mount, sends CollabReady with node_id 74 + /// 2. Coordinator creates session record on PDS 75 + /// 3. Coordinator discovers existing peers 76 + /// 4. Worker joins gossip session 77 + /// 5. Local updates forwarded to worker via subscribe_local_update 78 + /// 6. Remote updates from worker applied to main document 79 + /// 7. Session record deleted on unmount 80 + #[component] 81 + pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { 82 + #[cfg(target_arch = "wasm32")] 83 + { 84 + use super::worker::{WorkerInput, WorkerOutput}; 85 + use crate::collab_context::CollabDebugState; 86 + use crate::fetch::Fetcher; 87 + use futures_util::stream::SplitSink; 88 + use futures_util::{SinkExt, StreamExt}; 89 + use gloo_worker::Spawnable; 90 + use gloo_worker::reactor::ReactorBridge; 91 + use jacquard::IntoStatic; 92 + use weaver_common::WeaverExt; 93 + 94 + use super::worker::EditorReactor; 95 + 96 + let fetcher = use_context::<Fetcher>(); 97 + 98 + // Provide debug state context 99 + let mut debug_state = use_signal(CollabDebugState::default); 100 + use_context_provider(|| debug_state); 101 + 102 + // Coordinator state 103 + let mut state: Signal<CoordinatorState> = use_signal(|| CoordinatorState::Initializing); 104 + 105 + // Worker sink for sending messages - Signal persists across renders 106 + type WorkerSink = SplitSink<ReactorBridge<EditorReactor>, WorkerInput>; 107 + let mut worker_sink: Signal<Option<WorkerSink>> = use_signal(|| None); 108 + 109 + // Session record URI for cleanup 110 + let mut session_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 111 + 112 + // Loro subscription handle (keep alive) 113 + let mut loro_sub: Signal<Option<loro::Subscription>> = use_signal(|| None); 114 + 115 + // Clone for closures 116 + let resource_uri = props.resource_uri.clone(); 117 + let mut doc = props.document.clone(); 118 + let mut presence = props.presence; 119 + 120 + // Spawn worker and set up message handling 121 + let fetcher_for_spawn = fetcher.clone(); 122 + let resource_uri_for_spawn = resource_uri.clone(); 123 + use_effect(move || { 124 + let mut worker_sink = worker_sink; 125 + let fetcher = fetcher_for_spawn.clone(); 126 + let resource_uri = resource_uri_for_spawn.clone(); 127 + // Channel for local updates (Loro callback is Send+Sync, but ReactorBridge isn't) 128 + let (local_update_tx, mut local_update_rx) = 129 + tokio::sync::mpsc::unbounded_channel::<Vec<u8>>(); 130 + 131 + let tx = local_update_tx.clone(); 132 + 133 + // Subscribe to local Loro updates - just send to channel (Send+Sync) 134 + let sub = doc 135 + .loro_doc() 136 + .subscribe_local_update(Box::new(move |update| { 137 + let _ = tx.send(update.to_vec()); 138 + true // Keep subscription active 139 + })); 140 + 141 + loro_sub.set(Some(sub)); 142 + 143 + // Spawn the reactor 144 + let bridge = EditorReactor::spawner().spawn("/editor_worker.js"); 145 + let (sink, mut stream) = bridge.split(); 146 + worker_sink.set(Some(sink)); 147 + 148 + // Initialize worker with current document snapshot 149 + let snapshot = doc.export_snapshot(); 150 + let draft_key = resource_uri.clone(); // Use resource URI as the key 151 + spawn(async move { 152 + if let Some(ref mut sink) = *worker_sink.write() { 153 + if let Err(e) = sink 154 + .send(WorkerInput::Init { 155 + snapshot, 156 + draft_key, 157 + }) 158 + .await 159 + { 160 + tracing::error!("Failed to send Init to worker: {e}"); 161 + } 162 + } 163 + }); 164 + 165 + // Task 1: Forward local updates from channel to worker 166 + spawn(async move { 167 + while let Some(data) = local_update_rx.recv().await { 168 + if let Some(ref mut s) = *worker_sink.write() { 169 + if let Err(e) = s.send(WorkerInput::BroadcastUpdate { data }).await { 170 + tracing::warn!("Failed to send BroadcastUpdate to worker: {e}"); 171 + } 172 + } 173 + } 174 + }); 175 + 176 + // Task 2: Handle worker output messages 177 + let doc_for_handler = doc.clone(); 178 + spawn(async move { 179 + let mut doc = doc_for_handler; 180 + while let Some(output) = stream.next().await { 181 + match output { 182 + WorkerOutput::Ready => { 183 + tracing::info!("CollabCoordinator: worker ready, starting collab"); 184 + 185 + // Compute topic from resource URI 186 + let hash = weaver_common::blake3::hash(resource_uri.as_bytes()); 187 + let topic: [u8; 32] = *hash.as_bytes(); 188 + 189 + // Send StartCollab to worker immediately (no blocking on profile fetch) 190 + if let Some(ref mut s) = *worker_sink.write() { 191 + if let Err(e) = s 192 + .send(WorkerInput::StartCollab { 193 + topic, 194 + bootstrap_peers: vec![], 195 + }) 196 + .await 197 + { 198 + tracing::error!("Failed to send StartCollab to worker: {e}"); 199 + } 200 + } 201 + } 202 + 203 + WorkerOutput::CollabReady { node_id, relay_url } => { 204 + tracing::info!( 205 + node_id = %node_id, 206 + relay_url = ?relay_url, 207 + "CollabCoordinator: collab node ready" 208 + ); 209 + 210 + // Update debug state 211 + debug_state.with_mut(|ds| { 212 + ds.node_id = Some(node_id.clone()); 213 + ds.relay_url = relay_url.clone(); 214 + }); 215 + 216 + state.set(CoordinatorState::CreatingSession { 217 + node_id: node_id.clone(), 218 + relay_url: relay_url.clone(), 219 + }); 220 + 221 + // Create session record on PDS 222 + let fetcher = fetcher.clone(); 223 + let resource_uri = resource_uri.clone(); 224 + 225 + spawn(async move { 226 + // Parse resource URI to get StrongRef 227 + let uri = match AtUri::new(&resource_uri) { 228 + Ok(u) => u.into_static(), 229 + Err(e) => { 230 + let err = format!("Invalid resource URI: {e}"); 231 + debug_state 232 + .with_mut(|ds| ds.last_error = Some(err.clone())); 233 + state.set(CoordinatorState::Error(err)); 234 + return; 235 + } 236 + }; 237 + 238 + // Get StrongRef for the resource 239 + let strong_ref = match fetcher.confirm_record_ref(&uri).await { 240 + Ok(r) => r, 241 + Err(e) => { 242 + let err = format!("Failed to get resource ref: {e}"); 243 + debug_state 244 + .with_mut(|ds| ds.last_error = Some(err.clone())); 245 + state.set(CoordinatorState::Error(err)); 246 + return; 247 + } 248 + }; 249 + 250 + // Create session record 251 + match fetcher 252 + .create_collab_session( 253 + &strong_ref, 254 + &node_id, 255 + relay_url.as_deref(), 256 + Some(SESSION_TTL_MINUTES), 257 + ) 258 + .await 259 + { 260 + Ok(session_record_uri) => { 261 + tracing::info!( 262 + uri = %session_record_uri, 263 + "CollabCoordinator: session record created" 264 + ); 265 + session_uri.set(Some(session_record_uri.clone())); 266 + debug_state.with_mut(|ds| { 267 + ds.session_record_uri = 268 + Some(session_record_uri.to_string()); 269 + }); 270 + 271 + // Discover existing peers 272 + let bootstrap_peers = match fetcher 273 + .find_session_peers(&uri) 274 + .await 275 + { 276 + Ok(peers) => { 277 + tracing::info!( 278 + count = peers.len(), 279 + "CollabCoordinator: found peers" 280 + ); 281 + debug_state.with_mut(|ds| { 282 + ds.discovered_peers = peers.len(); 283 + }); 284 + peers 285 + .into_iter() 286 + .map(|p| p.node_id) 287 + .collect::<Vec<_>>() 288 + } 289 + Err(e) => { 290 + tracing::warn!( 291 + "CollabCoordinator: peer discovery failed: {e}" 292 + ); 293 + vec![] 294 + } 295 + }; 296 + 297 + // Send discovered peers to worker 298 + if !bootstrap_peers.is_empty() { 299 + tracing::info!( 300 + count = bootstrap_peers.len(), 301 + peers = ?bootstrap_peers, 302 + "CollabCoordinator: sending AddPeers to worker" 303 + ); 304 + if let Some(ref mut s) = *worker_sink.write() { 305 + if let Err(e) = s 306 + .send(WorkerInput::AddPeers { 307 + peers: bootstrap_peers, 308 + }) 309 + .await 310 + { 311 + tracing::error!("CollabCoordinator: AddPeers send failed: {e}"); 312 + } 313 + } else { 314 + tracing::error!("CollabCoordinator: sink is None!"); 315 + } 316 + } else { 317 + tracing::info!("CollabCoordinator: no peers to add"); 318 + } 319 + 320 + state.set(CoordinatorState::Active { 321 + session_uri: session_record_uri, 322 + }); 323 + } 324 + Err(e) => { 325 + let err = format!("Failed to create session: {e}"); 326 + debug_state 327 + .with_mut(|ds| ds.last_error = Some(err.clone())); 328 + state.set(CoordinatorState::Error(err)); 329 + } 330 + } 331 + }); 332 + } 333 + 334 + WorkerOutput::CollabJoined => { 335 + tracing::info!("CollabCoordinator: joined gossip session"); 336 + debug_state.with_mut(|ds| ds.is_joined = true); 337 + } 338 + 339 + WorkerOutput::RemoteUpdates { data } => { 340 + if let Err(e) = doc.import_updates(&data) { 341 + tracing::warn!( 342 + "CollabCoordinator: failed to import updates: {:?}", 343 + e 344 + ); 345 + } 346 + } 347 + 348 + WorkerOutput::PresenceUpdate(snapshot) => { 349 + debug_state.with_mut(|ds| { 350 + ds.connected_peers = snapshot.peer_count; 351 + }); 352 + presence.set(snapshot); 353 + } 354 + 355 + WorkerOutput::CollabStopped => { 356 + tracing::info!("CollabCoordinator: collab stopped"); 357 + debug_state.with_mut(|ds| { 358 + ds.is_joined = false; 359 + ds.connected_peers = 0; 360 + }); 361 + } 362 + 363 + WorkerOutput::PeerConnected => { 364 + tracing::info!("CollabCoordinator: peer connected, sending our Join"); 365 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 366 + 367 + let fetcher = fetcher.clone(); 368 + 369 + // Get our profile info and send BroadcastJoin 370 + let (our_did, our_display_name) = match fetcher.current_did().await { 371 + Some(did) => { 372 + let display_name = match fetcher.fetch_profile(&did.clone().into()).await { 373 + Ok(profile) => { 374 + match &profile.inner { 375 + ProfileDataViewInner::ProfileView(p) => { 376 + p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) 377 + } 378 + ProfileDataViewInner::ProfileViewDetailed(p) => { 379 + p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) 380 + } 381 + ProfileDataViewInner::TangledProfileView(p) => { 382 + p.handle.to_string() 383 + } 384 + _ => did.to_string(), 385 + } 386 + } 387 + Err(_) => did.to_string(), 388 + }; 389 + (did.to_string(), display_name) 390 + } 391 + None => { 392 + tracing::warn!("CollabCoordinator: no current DID for Join message"); 393 + ("unknown".to_string(), "Anonymous".to_string()) 394 + } 395 + }; 396 + 397 + if let Some(ref mut s) = *worker_sink.write() { 398 + if let Err(e) = s 399 + .send(WorkerInput::BroadcastJoin { 400 + did: our_did, 401 + display_name: our_display_name, 402 + }) 403 + .await 404 + { 405 + tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}"); 406 + } 407 + } 408 + } 409 + 410 + WorkerOutput::Error { message } => { 411 + tracing::error!("CollabCoordinator: worker error: {message}"); 412 + debug_state.with_mut(|ds| ds.last_error = Some(message.clone())); 413 + state.set(CoordinatorState::Error(message)); 414 + } 415 + 416 + WorkerOutput::Snapshot { .. } => {} 417 + } 418 + } 419 + tracing::info!("CollabCoordinator: worker stream ended"); 420 + }); 421 + 422 + tracing::info!("CollabCoordinator: spawned worker"); 423 + }); 424 + 425 + // Forward cursor updates to worker - memo re-runs when cursor/selection signals change 426 + let cursor_signal = props.document.cursor; 427 + let selection_signal = props.document.selection; 428 + 429 + let _cursor_broadcaster = use_memo(move || { 430 + let cursor = cursor_signal.read(); 431 + let selection = *selection_signal.read(); 432 + let position = cursor.offset; 433 + let sel = selection.map(|s| (s.anchor, s.head)); 434 + 435 + tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting"); 436 + 437 + spawn(async move { 438 + if let Some(ref mut s) = *worker_sink.write() { 439 + tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker"); 440 + if let Err(e) = s 441 + .send(WorkerInput::BroadcastCursor { 442 + position, 443 + selection: sel, 444 + }) 445 + .await 446 + { 447 + tracing::warn!("Failed to send BroadcastCursor to worker: {e}"); 448 + } 449 + } else { 450 + tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast"); 451 + } 452 + }); 453 + }); 454 + 455 + // Periodic peer discovery 456 + let fetcher_for_discovery = fetcher.clone(); 457 + let resource_uri_for_discovery = resource_uri.clone(); 458 + dioxus_sdk::time::use_interval( 459 + std::time::Duration::from_millis(PEER_DISCOVERY_INTERVAL_MS as u64), 460 + move |_| { 461 + let fetcher = fetcher_for_discovery.clone(); 462 + let resource_uri = resource_uri_for_discovery.clone(); 463 + 464 + spawn(async move { 465 + let uri = match AtUri::new(&resource_uri) { 466 + Ok(u) => u, 467 + Err(_) => return, 468 + }; 469 + 470 + match fetcher.find_session_peers(&uri).await { 471 + Ok(peers) => { 472 + debug_state.with_mut(|ds| ds.discovered_peers = peers.len()); 473 + if !peers.is_empty() { 474 + let peer_ids: Vec<String> = 475 + peers.into_iter().map(|p| p.node_id).collect(); 476 + 477 + if let Some(ref mut s) = *worker_sink.write() { 478 + if let Err(e) = 479 + s.send(WorkerInput::AddPeers { peers: peer_ids }).await 480 + { 481 + tracing::warn!("Periodic AddPeers send failed: {e}"); 482 + } 483 + } 484 + } 485 + } 486 + Err(e) => { 487 + tracing::debug!("Peer discovery failed: {e}"); 488 + } 489 + } 490 + }); 491 + }, 492 + ); 493 + 494 + // Periodic session refresh 495 + let fetcher_for_refresh = fetcher.clone(); 496 + dioxus_sdk::time::use_interval( 497 + std::time::Duration::from_millis(SESSION_REFRESH_INTERVAL_MS as u64), 498 + move |_| { 499 + let fetcher = fetcher_for_refresh.clone(); 500 + 501 + if let Some(ref uri) = *session_uri.peek() { 502 + let uri = uri.clone(); 503 + spawn(async move { 504 + match fetcher 505 + .refresh_collab_session(&uri, SESSION_TTL_MINUTES) 506 + .await 507 + { 508 + Ok(_) => { 509 + tracing::debug!("Session refreshed"); 510 + } 511 + Err(e) => { 512 + tracing::warn!("Session refresh failed: {e}"); 513 + } 514 + } 515 + }); 516 + } 517 + }, 518 + ); 519 + 520 + // Cleanup on unmount 521 + let fetcher_for_cleanup = fetcher.clone(); 522 + use_drop(move || { 523 + // Stop collab in worker 524 + spawn(async move { 525 + if let Some(ref mut s) = *worker_sink.write() { 526 + if let Err(e) = s.send(WorkerInput::StopCollab).await { 527 + tracing::warn!("Failed to send StopCollab to worker: {e}"); 528 + } 529 + } 530 + }); 531 + 532 + // Delete session record 533 + if let Some(uri) = session_uri.peek().clone() { 534 + let fetcher = fetcher_for_cleanup.clone(); 535 + spawn(async move { 536 + if let Err(e) = fetcher.delete_collab_session(&uri).await { 537 + tracing::warn!("Failed to delete session record: {e}"); 538 + } 539 + }); 540 + } 541 + }); 542 + } 543 + // Render children - this component is a wrapper that provides context 544 + rsx! { {props.children} } 545 + }
+708 -686
crates/weaver-app/src/components/editor/component.rs
··· 11 use weaver_api::sh_weaver::embed::images::Image; 12 use weaver_common::WeaverExt; 13 14 - use crate::auth::AuthState; 15 - use crate::components::collab::CollaboratorAvatars; 16 - use crate::components::editor::ReportButton; 17 - use crate::fetch::Fetcher; 18 - 19 use super::actions::{ 20 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 21 handle_keydown_with_bindings, ··· 39 use super::toolbar::EditorToolbar; 40 use super::visibility::update_syntax_visibility; 41 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 42 43 /// Result of loading document state. 44 enum LoadResult { ··· 425 // Use pre-resolved content from loaded state (avoids embed pop-in) 426 let resolved_content = use_signal(|| loaded_state.resolved_content.clone()); 427 428 - // Presence tracker for remote collaborators (shared with RealTimeSync) 429 - let presence = use_signal(weaver_common::transport::PresenceTracker::new); 430 431 - // Resolve StrongRef for real-time P2P sync 432 - // - For entries: use entry_ref directly 433 - // - For drafts: resolve via confirm_record_ref on draft URI 434 - let realtime_ref_resource = { 435 - let doc_for_ref = document.clone(); 436 - let fetcher_for_ref = fetcher.clone(); 437 - let draft_key_for_ref = draft_key.to_string(); 438 - use_resource(move || { 439 - let doc = doc_for_ref.clone(); 440 - let fetcher = fetcher_for_ref.clone(); 441 - let draft_key = draft_key_for_ref.clone(); 442 - async move { 443 - // Published entry - use entry_ref directly 444 - if let Some(entry_ref) = doc.entry_ref() { 445 - return Some(entry_ref.clone()); 446 - } 447 - // Draft with edit_root - resolve draft StrongRef 448 - if doc.edit_root().is_some() { 449 - if let Some(did) = fetcher.current_did().await { 450 - let draft_uri = super::sync::build_draft_uri(&did, &draft_key); 451 - match fetcher.confirm_record_ref(&draft_uri).await { 452 - Ok(strong_ref) => return Some(strong_ref), 453 - Err(e) => { 454 - tracing::warn!("Failed to resolve draft ref: {}", e); 455 - } 456 - } 457 - } 458 - } 459 - None 460 - } 461 - }) 462 - }; 463 464 let doc_for_memo = document.clone(); 465 let doc_for_refs = document.clone(); ··· 516 if !results.is_empty() { 517 let mut rc = resolved_content_for_fetch.write_unchecked(); 518 for (uri_str, html) in results { 519 - if let Ok(at_uri) = 520 - jacquard::types::string::AtUri::new_owned(uri_str) 521 - { 522 rc.add_embed(at_uri, html, None); 523 } 524 } ··· 724 // Worker-based autosave (offloads export + encode to worker thread) 725 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 726 { 727 - use super::worker::{EditorWorker, WorkerInput, WorkerOutput}; 728 use gloo_storage::Storage; 729 use gloo_worker::Spawnable; 730 731 // Track if worker is available (false = fallback to main thread) 732 let use_worker: Signal<bool> = use_signal(|| true); 733 - // Worker bridge handle 734 - let mut worker_bridge: Signal<Option<gloo_worker::WorkerBridge<EditorWorker>>> = 735 - use_signal(|| None); 736 // Track version vector sent to worker (for incremental updates) 737 let mut last_worker_vv: Signal<Option<loro::VersionVector>> = use_signal(|| None); 738 739 // Spawn worker on mount 740 let doc_for_worker_init = document.clone(); 741 let draft_key_for_worker = draft_key.clone(); 742 use_effect(move || { 743 let doc = doc_for_worker_init.clone(); 744 let draft_key = draft_key_for_worker.clone(); 745 746 // Callback for worker responses 747 - let on_output = move |output: WorkerOutput| { 748 match output { 749 WorkerOutput::Ready => { 750 tracing::info!("Editor worker ready"); ··· 781 WorkerOutput::Error { message } => { 782 tracing::error!("Worker error: {}", message); 783 } 784 } 785 }; 786 787 - // Spawn worker (panics on failure in debug, returns bridge directly) 788 - let bridge = EditorWorker::spawner() 789 - .callback(on_output) 790 - .spawn("/editor_worker.js"); 791 792 // Initialize with current document snapshot 793 let snapshot = doc.export_snapshot(); 794 - bridge.send(WorkerInput::Init { 795 - snapshot, 796 - draft_key: draft_key.clone(), 797 }); 798 - worker_bridge.set(Some(bridge)); 799 - tracing::info!("Editor worker spawned"); 800 }); 801 802 // Autosave interval 803 let doc_for_autosave = document.clone(); 804 let draft_key_for_autosave = draft_key.clone(); 805 use_effect(move || { 806 let mut doc = doc_for_autosave.clone(); 807 let draft_key = draft_key_for_autosave.clone(); 808 809 let interval = gloo_timers::callback::Interval::new(500, move || { 810 let callback_start = crate::perf::now(); ··· 826 doc.sync_loro_cursor(); 827 828 // Try worker path first 829 - if *use_worker.peek() { 830 - if let Some(ref bridge) = *worker_bridge.peek() { 831 - // Send updates to worker (or full snapshot if first time) 832 - let current_vv = doc.version_vector(); 833 - let updates = if let Some(ref last_vv) = *last_worker_vv.peek() { 834 - doc.export_updates_from(last_vv).unwrap_or_default() 835 - } else { 836 - doc.export_snapshot() 837 - }; 838 839 - if !updates.is_empty() { 840 - bridge.send(WorkerInput::ApplyUpdates { updates }); 841 } 842 843 - // Request snapshot export 844 - bridge.send(WorkerInput::ExportSnapshot { 845 - cursor_offset: doc.cursor.read().offset, 846 - editing_uri: doc.entry_ref().map(|r| r.uri.to_string()), 847 - editing_cid: doc.entry_ref().map(|r| r.cid.to_string()), 848 - }); 849 850 - last_worker_vv.set(Some(current_vv)); 851 - last_saved_frontiers.set(Some(current_frontiers)); 852 - 853 - let callback_ms = crate::perf::now() - callback_start; 854 - tracing::debug!(callback_ms, "autosave via worker"); 855 - return; 856 - } 857 } 858 859 // Fallback: main thread save ··· 997 998 rsx! { 999 Stylesheet { href: asset!("/assets/styling/editor.css") } 1000 - div { class: "markdown-editor-container", 1001 - // Title bar 1002 - div { class: "editor-title-bar", 1003 - input { 1004 - r#type: "text", 1005 - class: "title-input", 1006 - placeholder: "Entry title...", 1007 - value: "{document.title()}", 1008 - oninput: { 1009 - let doc = document.clone(); 1010 - move |e| { 1011 - doc.set_title(&e.value()); 1012 - } 1013 - }, 1014 - } 1015 - } 1016 - 1017 - // Meta row - path, tags, publish 1018 - div { class: "editor-meta-row", 1019 - div { class: "meta-path", 1020 - label { "Path" } 1021 - input { 1022 - r#type: "text", 1023 - class: "path-input", 1024 - placeholder: "url-slug", 1025 - value: "{document.path()}", 1026 - oninput: { 1027 - let doc = document.clone(); 1028 - move |e| { 1029 - doc.set_path(&e.value()); 1030 - } 1031 - }, 1032 - } 1033 } 1034 1035 - div { class: "meta-tags", 1036 - label { "Tags" } 1037 - div { class: "tags-container", 1038 - for tag in document.tags() { 1039 - span { 1040 - class: "tag-chip", 1041 - "{tag}" 1042 - button { 1043 - class: "tag-remove", 1044 - onclick: { 1045 - let doc = document.clone(); 1046 - let tag_to_remove = tag.clone(); 1047 - move |_| { 1048 - doc.remove_tag(&tag_to_remove); 1049 - } 1050 - }, 1051 - "×" 1052 - } 1053 - } 1054 - } 1055 input { 1056 r#type: "text", 1057 - class: "tag-input", 1058 - placeholder: "Add tag...", 1059 - value: "{new_tag}", 1060 - oninput: move |e| new_tag.set(e.value()), 1061 - onkeydown: { 1062 let doc = document.clone(); 1063 move |e| { 1064 - use dioxus::prelude::keyboard_types::Key; 1065 - if e.key() == Key::Enter && !new_tag().trim().is_empty() { 1066 - e.prevent_default(); 1067 - let tag = new_tag().trim().to_string(); 1068 - doc.add_tag(&tag); 1069 - new_tag.set(String::new()); 1070 - } 1071 } 1072 }, 1073 } 1074 } 1075 - } 1076 1077 - div { class: "meta-actions", 1078 - // Show collaborator avatars when editing an existing entry 1079 - if let Some(entry_ref) = document.entry_ref() { 1080 - { 1081 - let title = document.title(); 1082 - rsx! { 1083 - CollaboratorAvatars { 1084 - resource_uri: entry_ref.uri.clone(), 1085 - resource_cid: entry_ref.cid.to_string(), 1086 - resource_title: if title.is_empty() { None } else { Some(title) }, 1087 } 1088 } 1089 } 1090 } 1091 1092 - { 1093 - // Enable collaborative sync for any published entry (both owners and collaborators) 1094 - let is_published = document.entry_ref().is_some(); 1095 1096 - // Refresh callback: fetch and merge collaborator changes (incremental) 1097 - let on_refresh = if is_published { 1098 - let fetcher_for_refresh = fetcher.clone(); 1099 - let mut doc_for_refresh = document.clone(); 1100 - let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); 1101 1102 - Some(EventHandler::new(move |_| { 1103 - let fetcher = fetcher_for_refresh.clone(); 1104 - let mut doc = doc_for_refresh.clone(); 1105 - let uri = entry_uri.clone(); 1106 1107 - spawn(async move { 1108 - if let Some(uri) = uri { 1109 - // Get last seen diffs for incremental sync 1110 - let last_seen = doc.last_seen_diffs.read().clone(); 1111 1112 - match super::sync::load_all_edit_states_from_pds(&fetcher, &uri, &last_seen).await { 1113 - Ok(Some(pds_state)) => { 1114 - if let Err(e) = doc.import_updates(&pds_state.root_snapshot) { 1115 - tracing::error!("Failed to import collaborator updates: {:?}", e); 1116 - } else { 1117 - tracing::info!("Successfully merged collaborator updates"); 1118 - // Update the last seen diffs for next incremental sync 1119 - *doc.last_seen_diffs.write() = pds_state.last_seen_diffs; 1120 } 1121 } 1122 - Ok(None) => { 1123 - tracing::debug!("No collaborator updates found"); 1124 - } 1125 - Err(e) => { 1126 - tracing::error!("Failed to fetch collaborator updates: {}", e); 1127 - } 1128 } 1129 - } 1130 - }); 1131 - })) 1132 - } else { 1133 - None 1134 - }; 1135 1136 - // Get resolved StrongRef for real-time P2P sync 1137 - let realtime_ref = realtime_ref_resource.read().clone().flatten(); 1138 - 1139 - rsx! { 1140 - SyncStatus { 1141 - document: document.clone(), 1142 - draft_key: draft_key.to_string(), 1143 - on_refresh, 1144 - is_collaborative: is_published, 1145 - } 1146 - // Real-time P2P sync (works for both published entries and drafts) 1147 - super::sync::RealTimeSync { 1148 - document: document.clone(), 1149 - resource_ref: realtime_ref, 1150 - presence, 1151 } 1152 } 1153 - } 1154 1155 - PublishButton { 1156 - document: document.clone(), 1157 - draft_key: draft_key.to_string(), 1158 - target_notebook: target_notebook.as_ref().map(|s| s.to_string()), 1159 } 1160 } 1161 - } 1162 1163 - // Editor content 1164 - div { class: "editor-content-wrapper", 1165 - // Remote collaborator cursors overlay 1166 - RemoteCursors { presence, document: document.clone(), render_cache } 1167 - div { 1168 - id: "{editor_id}", 1169 - class: "editor-content", 1170 - contenteditable: "true", 1171 1172 - onkeydown: { 1173 - let mut doc = document.clone(); 1174 - let keybindings = KeybindingConfig::default_for_platform(&platform::platform()); 1175 - move |evt| { 1176 - use dioxus::prelude::keyboard_types::Key; 1177 - use std::time::Duration; 1178 1179 - let plat = platform::platform(); 1180 - let mods = evt.modifiers(); 1181 - let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 1182 1183 - // During IME composition: 1184 - // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 1185 - // - Allow Escape to cancel composition 1186 - // - Block text input (let browser handle composition preview) 1187 - if doc.composition.read().is_some() { 1188 - if evt.key() == Key::Escape { 1189 - tracing::debug!("Escape pressed - cancelling composition"); 1190 - doc.composition.set(None); 1191 - return; 1192 - } 1193 1194 - // Allow modifier shortcuts through during composition 1195 - if !has_modifier { 1196 - tracing::debug!( 1197 - key = ?evt.key(), 1198 - "keydown during composition - delegating to browser" 1199 - ); 1200 - return; 1201 - } 1202 - // Fall through to handle the shortcut 1203 - } 1204 - 1205 - // Safari workaround: After Japanese IME composition ends, both 1206 - // compositionend and keydown fire for Enter. Ignore keydown 1207 - // within 500ms of composition end to prevent double-newline. 1208 - if plat.safari && evt.key() == Key::Enter { 1209 - if let Some(ended_at) = *doc.composition_ended_at.read() { 1210 - if ended_at.elapsed() < Duration::from_millis(500) { 1211 tracing::debug!( 1212 - "Safari: ignoring Enter within 500ms of compositionend" 1213 ); 1214 return; 1215 } 1216 } 1217 - } 1218 1219 - // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) 1220 - let combo = KeyCombo::from_keyboard_event(&evt.data()); 1221 - let cursor_offset = doc.cursor.read().offset; 1222 - let selection = *doc.selection.read(); 1223 - let range = selection 1224 - .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 1225 - .unwrap_or_else(|| Range::caret(cursor_offset)); 1226 - match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) { 1227 - KeydownResult::Handled => { 1228 - evt.prevent_default(); 1229 - return; 1230 } 1231 - KeydownResult::PassThrough => { 1232 - // Navigation keys - let browser handle, sync in keyup 1233 - return; 1234 } 1235 - KeydownResult::NotHandled => { 1236 - // Text input - let beforeinput handle it 1237 - } 1238 } 1239 1240 - // Text input keys: let beforeinput handle them 1241 - // We don't prevent default here - beforeinput will do that 1242 - } 1243 - }, 1244 1245 - onkeyup: { 1246 - let mut doc = document.clone(); 1247 - move |evt| { 1248 - use dioxus::prelude::keyboard_types::Key; 1249 1250 - // Arrow keys with direction hint for snapping 1251 - let direction_hint = match evt.key() { 1252 - Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), 1253 - Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), 1254 - _ => None, 1255 - }; 1256 1257 - // Navigation keys (with or without Shift for selection) 1258 - let navigation = matches!( 1259 - evt.key(), 1260 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 1261 - Key::Home | Key::End | Key::PageUp | Key::PageDown 1262 - ); 1263 1264 - // Cmd/Ctrl+A for select all 1265 - let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 1266 - && matches!(evt.key(), Key::Character(ref c) if c == "a"); 1267 1268 - if navigation || select_all { 1269 let paras = cached_paragraphs(); 1270 - if let Some(dir) = direction_hint { 1271 - sync_cursor_from_dom_with_direction(&mut doc, editor_id, &paras, Some(dir)); 1272 - } else { 1273 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1274 - } 1275 let spans = syntax_spans(); 1276 let cursor_offset = doc.cursor.read().offset; 1277 let selection = *doc.selection.read(); ··· 1282 &paras, 1283 ); 1284 } 1285 - } 1286 - }, 1287 1288 - onselect: { 1289 - let mut doc = document.clone(); 1290 - move |_evt| { 1291 - tracing::trace!("onselect fired"); 1292 - let paras = cached_paragraphs(); 1293 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1294 - let spans = syntax_spans(); 1295 - let cursor_offset = doc.cursor.read().offset; 1296 - let selection = *doc.selection.read(); 1297 - update_syntax_visibility( 1298 - cursor_offset, 1299 - selection.as_ref(), 1300 - &spans, 1301 - &paras, 1302 - ); 1303 - } 1304 - }, 1305 - 1306 - onselectstart: { 1307 - let mut doc = document.clone(); 1308 - move |_evt| { 1309 - tracing::trace!("onselectstart fired"); 1310 - let paras = cached_paragraphs(); 1311 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1312 - let spans = syntax_spans(); 1313 - let cursor_offset = doc.cursor.read().offset; 1314 - let selection = *doc.selection.read(); 1315 - update_syntax_visibility( 1316 - cursor_offset, 1317 - selection.as_ref(), 1318 - &spans, 1319 - &paras, 1320 - ); 1321 - } 1322 - }, 1323 1324 - onselectionchange: { 1325 - let mut doc = document.clone(); 1326 - move |_evt| { 1327 - tracing::trace!("onselectionchange fired"); 1328 - let paras = cached_paragraphs(); 1329 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1330 - let spans = syntax_spans(); 1331 - let cursor_offset = doc.cursor.read().offset; 1332 - let selection = *doc.selection.read(); 1333 - update_syntax_visibility( 1334 - cursor_offset, 1335 - selection.as_ref(), 1336 - &spans, 1337 - &paras, 1338 - ); 1339 - } 1340 - }, 1341 1342 - onclick: { 1343 - let mut doc = document.clone(); 1344 - move |evt| { 1345 - tracing::trace!("onclick fired"); 1346 - let paras = cached_paragraphs(); 1347 1348 - // Check if click target is a math-clickable element 1349 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1350 - { 1351 - use dioxus::web::WebEventExt; 1352 - use wasm_bindgen::JsCast; 1353 1354 - let web_evt = evt.as_web_event(); 1355 - if let Some(target) = web_evt.target() { 1356 - if let Some(element) = target.dyn_ref::<web_sys::Element>() { 1357 - // Check element or ancestors for math-clickable 1358 - if let Ok(Some(math_el)) = element.closest(".math-clickable") { 1359 - if let Some(char_target) = math_el.get_attribute("data-char-target") { 1360 - if let Ok(offset) = char_target.parse::<usize>() { 1361 - tracing::debug!("math-clickable clicked, moving cursor to {}", offset); 1362 - doc.cursor.write().offset = offset; 1363 - *doc.selection.write() = None; 1364 - // Update visibility FIRST so math-source is visible 1365 - let spans = syntax_spans(); 1366 - update_syntax_visibility(offset, None, &spans, &paras); 1367 - // Then set DOM selection 1368 - let map = offset_map(); 1369 - let _ = crate::components::editor::cursor::restore_cursor_position( 1370 - offset, 1371 - &map, 1372 - editor_id, 1373 - None, 1374 - ); 1375 - return; 1376 } 1377 } 1378 } 1379 } 1380 } 1381 } 1382 1383 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1384 - let spans = syntax_spans(); 1385 - let cursor_offset = doc.cursor.read().offset; 1386 - let selection = *doc.selection.read(); 1387 - update_syntax_visibility( 1388 - cursor_offset, 1389 - selection.as_ref(), 1390 - &spans, 1391 - &paras, 1392 - ); 1393 - } 1394 - }, 1395 1396 - // Android workaround: Handle Enter in keypress instead of keydown. 1397 - // Chrome Android fires confused composition events on Enter in keydown, 1398 - // but keypress fires after composition state settles. 1399 - onkeypress: { 1400 - let mut doc = document.clone(); 1401 - move |evt| { 1402 - use dioxus::prelude::keyboard_types::Key; 1403 1404 - let plat = platform::platform(); 1405 - if plat.android && evt.key() == Key::Enter { 1406 - tracing::debug!("Android: handling Enter in keypress"); 1407 - evt.prevent_default(); 1408 1409 - // Get current range 1410 - let range = if let Some(sel) = *doc.selection.read() { 1411 - Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 1412 - } else { 1413 - Range::caret(doc.cursor.read().offset) 1414 - }; 1415 1416 - let action = EditorAction::InsertParagraph { range }; 1417 - execute_action(&mut doc, &action); 1418 } 1419 - } 1420 - }, 1421 1422 - onpaste: { 1423 - let mut doc = document.clone(); 1424 - move |evt| { 1425 - handle_paste(evt, &mut doc); 1426 - } 1427 - }, 1428 - 1429 - oncut: { 1430 - let mut doc = document.clone(); 1431 - move |evt| { 1432 - handle_cut(evt, &mut doc); 1433 - } 1434 - }, 1435 1436 - oncopy: { 1437 - let doc = document.clone(); 1438 - move |evt| { 1439 - handle_copy(evt, &doc); 1440 - } 1441 - }, 1442 1443 - onblur: { 1444 - let mut doc = document.clone(); 1445 - move |_| { 1446 - // Cancel any in-progress IME composition on focus loss 1447 - let had_composition = doc.composition.read().is_some(); 1448 - if had_composition { 1449 - tracing::debug!("onblur: clearing active composition"); 1450 } 1451 - doc.composition.set(None); 1452 - } 1453 - }, 1454 1455 - oncompositionstart: { 1456 - let mut doc = document.clone(); 1457 - move |evt: CompositionEvent| { 1458 - let data = evt.data().data(); 1459 - tracing::trace!( 1460 - data = %data, 1461 - "compositionstart" 1462 - ); 1463 - // Delete selection if present (composition replaces it) 1464 - let sel = doc.selection.write().take(); 1465 - if let Some(sel) = sel { 1466 - let (start, end) = 1467 - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1468 tracing::trace!( 1469 - start, 1470 - end, 1471 - "compositionstart: deleting selection" 1472 ); 1473 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 1474 - doc.cursor.write().offset = start; 1475 - } 1476 1477 - let cursor_offset = doc.cursor.read().offset; 1478 - tracing::trace!( 1479 - cursor = cursor_offset, 1480 - "compositionstart: setting composition state" 1481 - ); 1482 - doc.composition.set(Some(CompositionState { 1483 - start_offset: cursor_offset, 1484 - text: data, 1485 - })); 1486 - } 1487 - }, 1488 1489 - oncompositionupdate: { 1490 - let mut doc = document.clone(); 1491 - move |evt: CompositionEvent| { 1492 - let data = evt.data().data(); 1493 - tracing::trace!( 1494 - data = %data, 1495 - "compositionupdate" 1496 - ); 1497 - let mut comp_guard = doc.composition.write(); 1498 - if let Some(ref mut comp) = *comp_guard { 1499 - comp.text = data; 1500 - } else { 1501 - tracing::debug!("compositionupdate without active composition state"); 1502 } 1503 - } 1504 - }, 1505 1506 - oncompositionend: { 1507 - let mut doc = document.clone(); 1508 - move |evt: CompositionEvent| { 1509 - let final_text = evt.data().data(); 1510 - tracing::trace!( 1511 - data = %final_text, 1512 - "compositionend" 1513 - ); 1514 - // Record when composition ended for Safari timing workaround 1515 - doc.composition_ended_at.set(Some(web_time::Instant::now())); 1516 1517 - let comp = doc.composition.write().take(); 1518 - if let Some(comp) = comp { 1519 - tracing::debug!( 1520 - start_offset = comp.start_offset, 1521 - final_text = %final_text, 1522 - chars = final_text.chars().count(), 1523 - "compositionend: inserting text" 1524 - ); 1525 1526 - if !final_text.is_empty() { 1527 - let mut delete_start = comp.start_offset; 1528 - while delete_start > 0 { 1529 - match get_char_at(doc.loro_text(), delete_start - 1) { 1530 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 1531 - _ => break, 1532 } 1533 - } 1534 1535 - let cursor_offset = doc.cursor.read().offset; 1536 - let zw_count = cursor_offset - delete_start; 1537 - if zw_count > 0 { 1538 - // Splice: delete zero-width chars and insert new char in one op 1539 - let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 1540 - doc.cursor.write().offset = delete_start + final_text.chars().count(); 1541 - } else if cursor_offset == doc.len_chars() { 1542 - // Fast path: append at end 1543 - let _ = doc.push_tracked(&final_text); 1544 - doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 1545 - } else { 1546 - let _ = doc.insert_tracked(cursor_offset, &final_text); 1547 - doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 1548 } 1549 } 1550 - } else { 1551 - tracing::debug!("compositionend without active composition state"); 1552 } 1553 } 1554 - }, 1555 - } 1556 - div { class: "editor-debug", 1557 - div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 1558 - // Collab debug info 1559 - { 1560 - if let Some(debug_state) = crate::collab_context::try_use_collab_debug() { 1561 - let ds = debug_state.read(); 1562 - rsx! { 1563 - div { class: "collab-debug", 1564 - if let Some(ref node_id) = ds.node_id { 1565 - span { title: "{node_id}", "Node: {&node_id[..8.min(node_id.len())]}…" } 1566 - } 1567 - if ds.is_joined { 1568 - span { class: "joined", "✓ Joined" } 1569 - } 1570 - span { "Peers: {ds.discovered_peers}" } 1571 - if let Some(ref err) = ds.last_error { 1572 - span { class: "error", title: "{err}", "⚠" } 1573 } 1574 } 1575 } 1576 - } else { 1577 - rsx! {} 1578 } 1579 - }, 1580 - ReportButton { 1581 - email: "editor-bugs@weaver.sh".to_string(), 1582 - editor_id: "markdown-editor".to_string(), 1583 } 1584 } 1585 - } 1586 1587 - EditorToolbar { 1588 - on_format: { 1589 - let mut doc = document.clone(); 1590 - move |action| { 1591 - formatting::apply_formatting(&mut doc, action); 1592 - } 1593 - }, 1594 - on_image: { 1595 - let mut doc = document.clone(); 1596 - move |uploaded: super::image_upload::UploadedImage| { 1597 - // Build data URL for immediate preview 1598 - use base64::{Engine, engine::general_purpose::STANDARD}; 1599 - let data_url = format!( 1600 - "data:{};base64,{}", 1601 - uploaded.mime_type, 1602 - STANDARD.encode(&uploaded.data) 1603 - ); 1604 1605 - // Add to resolver for immediate display 1606 - let name = uploaded.name.clone(); 1607 - image_resolver.with_mut(|resolver| { 1608 - resolver.add_pending(name.clone(), data_url); 1609 - }); 1610 1611 - // Insert markdown image syntax at cursor 1612 - let alt_text = if uploaded.alt.is_empty() { 1613 - name.clone() 1614 - } else { 1615 - uploaded.alt.clone() 1616 - }; 1617 1618 - // Check if authenticated and get DID for draft path 1619 - let auth = auth_state.read(); 1620 - let did_for_path = auth.did.clone(); 1621 - let is_authenticated = auth.is_authenticated(); 1622 - drop(auth); 1623 1624 - // Pre-generate TID for the blob rkey (used in draft path and upload) 1625 - let blob_tid = jacquard::types::tid::Ticker::new().next(None); 1626 1627 - // Build markdown with proper draft path if authenticated 1628 - let markdown = if let Some(ref did) = did_for_path { 1629 - format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) 1630 - } else { 1631 - // Fallback for unauthenticated - simple path (won't be publishable anyway) 1632 - format!("![{}](/image/{})", alt_text, name) 1633 - }; 1634 1635 - let pos = doc.cursor.read().offset; 1636 - let _ = doc.insert_tracked(pos, &markdown); 1637 - doc.cursor.write().offset = pos + markdown.chars().count(); 1638 1639 - // Upload to PDS in background if authenticated 1640 - if is_authenticated { 1641 - let fetcher = fetcher.clone(); 1642 - let name_for_upload = name.clone(); 1643 - let alt_for_upload = alt_text.clone(); 1644 - let data = uploaded.data.clone(); 1645 - let mut doc_for_spawn = doc.clone(); 1646 1647 - spawn(async move { 1648 - let client = fetcher.get_client(); 1649 1650 - // Clone data for cache pre-warming 1651 - let data_for_cache = data.clone(); 1652 1653 - // Use pre-generated TID as rkey for the blob record 1654 - let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) 1655 - .expect("TID is valid record key"); 1656 1657 - // Upload blob and create temporary PublishedBlob record 1658 - match client.publish_blob(data, &name_for_upload, Some(rkey)).await { 1659 - Ok((strong_ref, published_blob)) => { 1660 - // Get DID from fetcher 1661 - let did = match fetcher.current_did().await { 1662 - Some(d) => d, 1663 - None => { 1664 - tracing::warn!("No DID available"); 1665 - return; 1666 - } 1667 - }; 1668 1669 - // Extract rkey from the AT-URI 1670 - let blob_rkey = match strong_ref.uri.rkey() { 1671 - Some(rkey) => rkey.0.clone().into_static(), 1672 - None => { 1673 - tracing::warn!("No rkey in PublishedBlob URI"); 1674 - return; 1675 - } 1676 - }; 1677 1678 - let cid = published_blob.upload.blob().cid().clone().into_static(); 1679 1680 - let name_for_resolver = name_for_upload.clone(); 1681 - let image = Image::new() 1682 - .alt(alt_for_upload.to_cowstr()) 1683 - .image(published_blob.upload) 1684 - .name(name_for_upload.to_cowstr()) 1685 - .build(); 1686 - doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); 1687 1688 - // Promote from pending to uploaded in resolver 1689 - let ident = AtIdentifier::Did(did); 1690 - image_resolver.with_mut(|resolver| { 1691 - resolver.promote_to_uploaded( 1692 - &name_for_resolver, 1693 - blob_rkey, 1694 - ident, 1695 - ); 1696 - }); 1697 1698 - tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 1699 1700 - // Pre-warm server cache with blob bytes 1701 - #[cfg(feature = "fullstack-server")] 1702 - { 1703 - use jacquard::smol_str::ToSmolStr; 1704 - if let Err(e) = crate::data::cache_blob_bytes( 1705 - cid.to_smolstr(), 1706 - Some(name_for_resolver.into()), 1707 - None, 1708 - data_for_cache.into(), 1709 - ).await { 1710 - tracing::warn!(error = %e, "Failed to pre-warm blob cache"); 1711 } 1712 } 1713 - } 1714 - Err(e) => { 1715 - tracing::error!(error = %e, "Failed to upload image"); 1716 - // Image stays as data URL - will work for preview but not publish 1717 } 1718 - } 1719 - }); 1720 - } else { 1721 - tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); 1722 } 1723 - } 1724 - }, 1725 - } 1726 1727 } 1728 } 1729 } ··· 1734 /// Uses the same offset mapping as local cursor restoration. 1735 #[component] 1736 fn RemoteCursors( 1737 - presence: Signal<weaver_common::transport::PresenceTracker>, 1738 document: EditorDocument, 1739 render_cache: Signal<render::RenderCache>, 1740 ) -> Element { 1741 let presence_read = presence.read(); 1742 - let cursor_count = presence_read.len(); 1743 let cursors: Vec<_> = presence_read 1744 - .cursors() 1745 - .map(|(c, cur)| (c.display_name.clone(), c.color, cur.position, cur.selection)) 1746 .collect(); 1747 1748 if cursor_count > 0 {
··· 11 use weaver_api::sh_weaver::embed::images::Image; 12 use weaver_common::WeaverExt; 13 14 use super::actions::{ 15 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 16 handle_keydown_with_bindings, ··· 34 use super::toolbar::EditorToolbar; 35 use super::visibility::update_syntax_visibility; 36 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 37 + use crate::auth::AuthState; 38 + use crate::components::collab::CollaboratorAvatars; 39 + use crate::components::editor::ReportButton; 40 + use crate::components::editor::collab::CollabCoordinator; 41 + use crate::fetch::Fetcher; 42 43 /// Result of loading document state. 44 enum LoadResult { ··· 425 // Use pre-resolved content from loaded state (avoids embed pop-in) 426 let resolved_content = use_signal(|| loaded_state.resolved_content.clone()); 427 428 + // Presence snapshot for remote collaborators (updated by collab coordinator) 429 + let presence = use_signal(weaver_common::transport::PresenceSnapshot::default); 430 431 + // Resource URI for real-time collab (entry URI if editing published entry) 432 + let collab_resource_uri = document.entry_ref().map(|r| r.uri.to_string()); 433 434 let doc_for_memo = document.clone(); 435 let doc_for_refs = document.clone(); ··· 486 if !results.is_empty() { 487 let mut rc = resolved_content_for_fetch.write_unchecked(); 488 for (uri_str, html) in results { 489 + if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri_str) { 490 rc.add_embed(at_uri, html, None); 491 } 492 } ··· 692 // Worker-based autosave (offloads export + encode to worker thread) 693 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 694 { 695 + use super::worker::{EditorReactor, WorkerInput, WorkerOutput}; 696 use gloo_storage::Storage; 697 use gloo_worker::Spawnable; 698 + use gloo_worker::reactor::ReactorBridge; 699 + 700 + use futures_util::stream::{SplitSink, SplitStream}; 701 702 // Track if worker is available (false = fallback to main thread) 703 let use_worker: Signal<bool> = use_signal(|| true); 704 + // Worker sink for sending (split from bridge) 705 + type WorkerSink = SplitSink<ReactorBridge<EditorReactor>, WorkerInput>; 706 + let worker_sink: std::rc::Rc<std::cell::RefCell<Option<WorkerSink>>> = 707 + std::rc::Rc::new(std::cell::RefCell::new(None)); 708 // Track version vector sent to worker (for incremental updates) 709 let mut last_worker_vv: Signal<Option<loro::VersionVector>> = use_signal(|| None); 710 711 // Spawn worker on mount 712 let doc_for_worker_init = document.clone(); 713 let draft_key_for_worker = draft_key.clone(); 714 + let worker_sink_for_spawn = worker_sink.clone(); 715 + let mut presence_for_worker = presence; 716 use_effect(move || { 717 let doc = doc_for_worker_init.clone(); 718 let draft_key = draft_key_for_worker.clone(); 719 + let worker_sink = worker_sink_for_spawn.clone(); 720 721 // Callback for worker responses 722 + let mut on_output = move |output: WorkerOutput| { 723 match output { 724 WorkerOutput::Ready => { 725 tracing::info!("Editor worker ready"); ··· 756 WorkerOutput::Error { message } => { 757 tracing::error!("Worker error: {}", message); 758 } 759 + WorkerOutput::PresenceUpdate(snapshot) => { 760 + tracing::debug!( 761 + collaborators = snapshot.collaborators.len(), 762 + peers = snapshot.peer_count, 763 + "presence update from worker" 764 + ); 765 + presence_for_worker.set(snapshot); 766 + } 767 + // Ignore other collab outputs for now (handled by CollabCoordinator) 768 + WorkerOutput::CollabReady { .. } 769 + | WorkerOutput::CollabJoined 770 + | WorkerOutput::RemoteUpdates { .. } 771 + | WorkerOutput::CollabStopped 772 + | WorkerOutput::PeerConnected => {} 773 } 774 }; 775 776 + // Spawn reactor and split into sink/stream 777 + use futures_util::StreamExt; 778 + let bridge = EditorReactor::spawner().spawn("/editor_worker.js"); 779 + let (sink, mut stream) = bridge.split(); 780 + 781 + // Store sink for sending 782 + *worker_sink.borrow_mut() = Some(sink); 783 784 // Initialize with current document snapshot 785 let snapshot = doc.export_snapshot(); 786 + let sink_for_init = worker_sink.clone(); 787 + wasm_bindgen_futures::spawn_local(async move { 788 + use futures_util::SinkExt; 789 + if let Some(ref mut sink) = *sink_for_init.borrow_mut() { 790 + let _ = sink 791 + .send(WorkerInput::Init { 792 + snapshot, 793 + draft_key, 794 + }) 795 + .await; 796 + } 797 }); 798 + 799 + // Spawn receiver task to poll stream for outputs 800 + wasm_bindgen_futures::spawn_local(async move { 801 + while let Some(msg) = stream.next().await { 802 + on_output(msg); 803 + } 804 + tracing::info!("Editor reactor stream ended"); 805 + }); 806 + 807 + tracing::info!("Editor reactor spawned"); 808 }); 809 810 // Autosave interval 811 let doc_for_autosave = document.clone(); 812 let draft_key_for_autosave = draft_key.clone(); 813 + let worker_sink_for_autosave = worker_sink.clone(); 814 use_effect(move || { 815 let mut doc = doc_for_autosave.clone(); 816 let draft_key = draft_key_for_autosave.clone(); 817 + let worker_sink = worker_sink_for_autosave.clone(); 818 819 let interval = gloo_timers::callback::Interval::new(500, move || { 820 let callback_start = crate::perf::now(); ··· 836 doc.sync_loro_cursor(); 837 838 // Try worker path first 839 + if *use_worker.peek() && worker_sink.borrow().is_some() { 840 + // Send updates to worker (or full snapshot if first time) 841 + let current_vv = doc.version_vector(); 842 + let updates = if let Some(ref last_vv) = *last_worker_vv.peek() { 843 + doc.export_updates_from(last_vv).unwrap_or_default() 844 + } else { 845 + doc.export_snapshot() 846 + }; 847 + 848 + let cursor_offset = doc.cursor.read().offset; 849 + let editing_uri = doc.entry_ref().map(|r| r.uri.to_string()); 850 + let editing_cid = doc.entry_ref().map(|r| r.cid.to_string()); 851 852 + let sink_clone = worker_sink.clone(); 853 + 854 + // Spawn async sends 855 + wasm_bindgen_futures::spawn_local(async move { 856 + use futures_util::SinkExt; 857 + if let Some(ref mut sink) = *sink_clone.borrow_mut() { 858 + if !updates.is_empty() { 859 + let _ = sink.send(WorkerInput::ApplyUpdates { updates }).await; 860 + } 861 + 862 + // Request snapshot export 863 + let _ = sink 864 + .send(WorkerInput::ExportSnapshot { 865 + cursor_offset, 866 + editing_uri, 867 + editing_cid, 868 + }) 869 + .await; 870 } 871 + }); 872 873 + last_worker_vv.set(Some(current_vv)); 874 + last_saved_frontiers.set(Some(current_frontiers)); 875 876 + let callback_ms = crate::perf::now() - callback_start; 877 + tracing::debug!(callback_ms, "autosave via worker"); 878 + return; 879 } 880 881 // Fallback: main thread save ··· 1019 1020 rsx! { 1021 Stylesheet { href: asset!("/assets/styling/editor.css") } 1022 + CollabCoordinator { 1023 + document: document.clone(), 1024 + resource_uri: collab_resource_uri.clone().unwrap_or(draft_key.clone()), 1025 + presence, 1026 + div { class: "markdown-editor-container", 1027 + // Title bar 1028 + div { class: "editor-title-bar", 1029 + input { 1030 + r#type: "text", 1031 + class: "title-input", 1032 + placeholder: "Entry title...", 1033 + value: "{document.title()}", 1034 + oninput: { 1035 + let doc = document.clone(); 1036 + move |e| { 1037 + doc.set_title(&e.value()); 1038 + } 1039 + }, 1040 } 1041 + } 1042 1043 + // Meta row - path, tags, publish 1044 + div { class: "editor-meta-row", 1045 + div { class: "meta-path", 1046 + label { "Path" } 1047 input { 1048 r#type: "text", 1049 + class: "path-input", 1050 + placeholder: "url-slug", 1051 + value: "{document.path()}", 1052 + oninput: { 1053 let doc = document.clone(); 1054 move |e| { 1055 + doc.set_path(&e.value()); 1056 } 1057 }, 1058 } 1059 } 1060 1061 + div { class: "meta-tags", 1062 + label { "Tags" } 1063 + div { class: "tags-container", 1064 + for tag in document.tags() { 1065 + span { 1066 + class: "tag-chip", 1067 + "{tag}" 1068 + button { 1069 + class: "tag-remove", 1070 + onclick: { 1071 + let doc = document.clone(); 1072 + let tag_to_remove = tag.clone(); 1073 + move |_| { 1074 + doc.remove_tag(&tag_to_remove); 1075 + } 1076 + }, 1077 + "×" 1078 + } 1079 } 1080 } 1081 + input { 1082 + r#type: "text", 1083 + class: "tag-input", 1084 + placeholder: "Add tag...", 1085 + value: "{new_tag}", 1086 + oninput: move |e| new_tag.set(e.value()), 1087 + onkeydown: { 1088 + let doc = document.clone(); 1089 + move |e| { 1090 + use dioxus::prelude::keyboard_types::Key; 1091 + if e.key() == Key::Enter && !new_tag().trim().is_empty() { 1092 + e.prevent_default(); 1093 + let tag = new_tag().trim().to_string(); 1094 + doc.add_tag(&tag); 1095 + new_tag.set(String::new()); 1096 + } 1097 + } 1098 + }, 1099 + } 1100 } 1101 } 1102 1103 + div { class: "meta-actions", 1104 + // Show collaborator avatars when editing an existing entry 1105 + if let Some(entry_ref) = document.entry_ref() { 1106 + { 1107 + let title = document.title(); 1108 + rsx! { 1109 + CollaboratorAvatars { 1110 + resource_uri: entry_ref.uri.clone(), 1111 + resource_cid: entry_ref.cid.to_string(), 1112 + resource_title: if title.is_empty() { None } else { Some(title) }, 1113 + } 1114 + } 1115 + } 1116 + } 1117 1118 + { 1119 + // Enable collaborative sync for any published entry (both owners and collaborators) 1120 + let is_published = document.entry_ref().is_some(); 1121 + 1122 + // Refresh callback: fetch and merge collaborator changes (incremental) 1123 + let on_refresh = if is_published { 1124 + let fetcher_for_refresh = fetcher.clone(); 1125 + let mut doc_for_refresh = document.clone(); 1126 + let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); 1127 1128 + Some(EventHandler::new(move |_| { 1129 + let fetcher = fetcher_for_refresh.clone(); 1130 + let mut doc = doc_for_refresh.clone(); 1131 + let uri = entry_uri.clone(); 1132 1133 + spawn(async move { 1134 + if let Some(uri) = uri { 1135 + // Get last seen diffs for incremental sync 1136 + let last_seen = doc.last_seen_diffs.read().clone(); 1137 1138 + match super::sync::load_all_edit_states_from_pds(&fetcher, &uri, &last_seen).await { 1139 + Ok(Some(pds_state)) => { 1140 + if let Err(e) = doc.import_updates(&pds_state.root_snapshot) { 1141 + tracing::error!("Failed to import collaborator updates: {:?}", e); 1142 + } else { 1143 + tracing::info!("Successfully merged collaborator updates"); 1144 + // Update the last seen diffs for next incremental sync 1145 + *doc.last_seen_diffs.write() = pds_state.last_seen_diffs; 1146 + } 1147 + } 1148 + Ok(None) => { 1149 + tracing::debug!("No collaborator updates found"); 1150 + } 1151 + Err(e) => { 1152 + tracing::error!("Failed to fetch collaborator updates: {}", e); 1153 } 1154 } 1155 } 1156 + }); 1157 + })) 1158 + } else { 1159 + None 1160 + }; 1161 1162 + rsx! { 1163 + SyncStatus { 1164 + document: document.clone(), 1165 + draft_key: draft_key.to_string(), 1166 + on_refresh, 1167 + is_collaborative: is_published, 1168 + } 1169 } 1170 } 1171 1172 + PublishButton { 1173 + document: document.clone(), 1174 + draft_key: draft_key.to_string(), 1175 + target_notebook: target_notebook.as_ref().map(|s| s.to_string()), 1176 + } 1177 } 1178 } 1179 1180 + // Editor content 1181 + div { class: "editor-content-wrapper", 1182 + // Remote collaborator cursors overlay 1183 + RemoteCursors { presence, document: document.clone(), render_cache } 1184 + div { 1185 + id: "{editor_id}", 1186 + class: "editor-content", 1187 + contenteditable: "true", 1188 1189 + onkeydown: { 1190 + let mut doc = document.clone(); 1191 + let keybindings = KeybindingConfig::default_for_platform(&platform::platform()); 1192 + move |evt| { 1193 + use dioxus::prelude::keyboard_types::Key; 1194 + use std::time::Duration; 1195 1196 + let plat = platform::platform(); 1197 + let mods = evt.modifiers(); 1198 + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 1199 1200 + // During IME composition: 1201 + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 1202 + // - Allow Escape to cancel composition 1203 + // - Block text input (let browser handle composition preview) 1204 + if doc.composition.read().is_some() { 1205 + if evt.key() == Key::Escape { 1206 + tracing::debug!("Escape pressed - cancelling composition"); 1207 + doc.composition.set(None); 1208 + return; 1209 + } 1210 1211 + // Allow modifier shortcuts through during composition 1212 + if !has_modifier { 1213 tracing::debug!( 1214 + key = ?evt.key(), 1215 + "keydown during composition - delegating to browser" 1216 ); 1217 return; 1218 } 1219 + // Fall through to handle the shortcut 1220 } 1221 1222 + // Safari workaround: After Japanese IME composition ends, both 1223 + // compositionend and keydown fire for Enter. Ignore keydown 1224 + // within 500ms of composition end to prevent double-newline. 1225 + if plat.safari && evt.key() == Key::Enter { 1226 + if let Some(ended_at) = *doc.composition_ended_at.read() { 1227 + if ended_at.elapsed() < Duration::from_millis(500) { 1228 + tracing::debug!( 1229 + "Safari: ignoring Enter within 500ms of compositionend" 1230 + ); 1231 + return; 1232 + } 1233 + } 1234 } 1235 + 1236 + // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) 1237 + let combo = KeyCombo::from_keyboard_event(&evt.data()); 1238 + let cursor_offset = doc.cursor.read().offset; 1239 + let selection = *doc.selection.read(); 1240 + let range = selection 1241 + .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 1242 + .unwrap_or_else(|| Range::caret(cursor_offset)); 1243 + match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) { 1244 + KeydownResult::Handled => { 1245 + evt.prevent_default(); 1246 + return; 1247 + } 1248 + KeydownResult::PassThrough => { 1249 + // Navigation keys - let browser handle, sync in keyup 1250 + return; 1251 + } 1252 + KeydownResult::NotHandled => { 1253 + // Text input - let beforeinput handle it 1254 + } 1255 } 1256 + 1257 + // Text input keys: let beforeinput handle them 1258 + // We don't prevent default here - beforeinput will do that 1259 } 1260 + }, 1261 1262 + onkeyup: { 1263 + let mut doc = document.clone(); 1264 + move |evt| { 1265 + use dioxus::prelude::keyboard_types::Key; 1266 1267 + // Arrow keys with direction hint for snapping 1268 + let direction_hint = match evt.key() { 1269 + Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), 1270 + Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), 1271 + _ => None, 1272 + }; 1273 1274 + // Navigation keys (with or without Shift for selection) 1275 + let navigation = matches!( 1276 + evt.key(), 1277 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 1278 + Key::Home | Key::End | Key::PageUp | Key::PageDown 1279 + ); 1280 1281 + // Cmd/Ctrl+A for select all 1282 + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 1283 + && matches!(evt.key(), Key::Character(ref c) if c == "a"); 1284 1285 + if navigation || select_all { 1286 + let paras = cached_paragraphs(); 1287 + if let Some(dir) = direction_hint { 1288 + sync_cursor_from_dom_with_direction(&mut doc, editor_id, &paras, Some(dir)); 1289 + } else { 1290 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 1291 + } 1292 + let spans = syntax_spans(); 1293 + let cursor_offset = doc.cursor.read().offset; 1294 + let selection = *doc.selection.read(); 1295 + update_syntax_visibility( 1296 + cursor_offset, 1297 + selection.as_ref(), 1298 + &spans, 1299 + &paras, 1300 + ); 1301 + } 1302 + } 1303 + }, 1304 1305 + onselect: { 1306 + let mut doc = document.clone(); 1307 + move |_evt| { 1308 + tracing::trace!("onselect fired"); 1309 let paras = cached_paragraphs(); 1310 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 1311 let spans = syntax_spans(); 1312 let cursor_offset = doc.cursor.read().offset; 1313 let selection = *doc.selection.read(); ··· 1318 &paras, 1319 ); 1320 } 1321 + }, 1322 1323 + onselectstart: { 1324 + let mut doc = document.clone(); 1325 + move |_evt| { 1326 + tracing::trace!("onselectstart fired"); 1327 + let paras = cached_paragraphs(); 1328 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 1329 + let spans = syntax_spans(); 1330 + let cursor_offset = doc.cursor.read().offset; 1331 + let selection = *doc.selection.read(); 1332 + update_syntax_visibility( 1333 + cursor_offset, 1334 + selection.as_ref(), 1335 + &spans, 1336 + &paras, 1337 + ); 1338 + } 1339 + }, 1340 1341 + onselectionchange: { 1342 + let mut doc = document.clone(); 1343 + move |_evt| { 1344 + tracing::trace!("onselectionchange fired"); 1345 + let paras = cached_paragraphs(); 1346 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 1347 + let spans = syntax_spans(); 1348 + let cursor_offset = doc.cursor.read().offset; 1349 + let selection = *doc.selection.read(); 1350 + update_syntax_visibility( 1351 + cursor_offset, 1352 + selection.as_ref(), 1353 + &spans, 1354 + &paras, 1355 + ); 1356 + } 1357 + }, 1358 1359 + onclick: { 1360 + let mut doc = document.clone(); 1361 + move |evt| { 1362 + tracing::trace!("onclick fired"); 1363 + let paras = cached_paragraphs(); 1364 1365 + // Check if click target is a math-clickable element 1366 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1367 + { 1368 + use dioxus::web::WebEventExt; 1369 + use wasm_bindgen::JsCast; 1370 1371 + let web_evt = evt.as_web_event(); 1372 + if let Some(target) = web_evt.target() { 1373 + if let Some(element) = target.dyn_ref::<web_sys::Element>() { 1374 + // Check element or ancestors for math-clickable 1375 + if let Ok(Some(math_el)) = element.closest(".math-clickable") { 1376 + if let Some(char_target) = math_el.get_attribute("data-char-target") { 1377 + if let Ok(offset) = char_target.parse::<usize>() { 1378 + tracing::debug!("math-clickable clicked, moving cursor to {}", offset); 1379 + doc.cursor.write().offset = offset; 1380 + *doc.selection.write() = None; 1381 + // Update visibility FIRST so math-source is visible 1382 + let spans = syntax_spans(); 1383 + update_syntax_visibility(offset, None, &spans, &paras); 1384 + // Then set DOM selection 1385 + let map = offset_map(); 1386 + let _ = crate::components::editor::cursor::restore_cursor_position( 1387 + offset, 1388 + &map, 1389 + editor_id, 1390 + None, 1391 + ); 1392 + return; 1393 + } 1394 } 1395 } 1396 } 1397 } 1398 } 1399 + 1400 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 1401 + let spans = syntax_spans(); 1402 + let cursor_offset = doc.cursor.read().offset; 1403 + let selection = *doc.selection.read(); 1404 + update_syntax_visibility( 1405 + cursor_offset, 1406 + selection.as_ref(), 1407 + &spans, 1408 + &paras, 1409 + ); 1410 } 1411 + }, 1412 1413 + // Android workaround: Handle Enter in keypress instead of keydown. 1414 + // Chrome Android fires confused composition events on Enter in keydown, 1415 + // but keypress fires after composition state settles. 1416 + onkeypress: { 1417 + let mut doc = document.clone(); 1418 + move |evt| { 1419 + use dioxus::prelude::keyboard_types::Key; 1420 1421 + let plat = platform::platform(); 1422 + if plat.android && evt.key() == Key::Enter { 1423 + tracing::debug!("Android: handling Enter in keypress"); 1424 + evt.prevent_default(); 1425 1426 + // Get current range 1427 + let range = if let Some(sel) = *doc.selection.read() { 1428 + Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 1429 + } else { 1430 + Range::caret(doc.cursor.read().offset) 1431 + }; 1432 1433 + let action = EditorAction::InsertParagraph { range }; 1434 + execute_action(&mut doc, &action); 1435 + } 1436 + } 1437 + }, 1438 1439 + onpaste: { 1440 + let mut doc = document.clone(); 1441 + move |evt| { 1442 + handle_paste(evt, &mut doc); 1443 } 1444 + }, 1445 1446 + oncut: { 1447 + let mut doc = document.clone(); 1448 + move |evt| { 1449 + handle_cut(evt, &mut doc); 1450 + } 1451 + }, 1452 1453 + oncopy: { 1454 + let doc = document.clone(); 1455 + move |evt| { 1456 + handle_copy(evt, &doc); 1457 + } 1458 + }, 1459 1460 + onblur: { 1461 + let mut doc = document.clone(); 1462 + move |_| { 1463 + // Cancel any in-progress IME composition on focus loss 1464 + let had_composition = doc.composition.read().is_some(); 1465 + if had_composition { 1466 + tracing::debug!("onblur: clearing active composition"); 1467 + } 1468 + doc.composition.set(None); 1469 } 1470 + }, 1471 1472 + oncompositionstart: { 1473 + let mut doc = document.clone(); 1474 + move |evt: CompositionEvent| { 1475 + let data = evt.data().data(); 1476 tracing::trace!( 1477 + data = %data, 1478 + "compositionstart" 1479 ); 1480 + // Delete selection if present (composition replaces it) 1481 + let sel = doc.selection.write().take(); 1482 + if let Some(sel) = sel { 1483 + let (start, end) = 1484 + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1485 + tracing::trace!( 1486 + start, 1487 + end, 1488 + "compositionstart: deleting selection" 1489 + ); 1490 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 1491 + doc.cursor.write().offset = start; 1492 + } 1493 1494 + let cursor_offset = doc.cursor.read().offset; 1495 + tracing::trace!( 1496 + cursor = cursor_offset, 1497 + "compositionstart: setting composition state" 1498 + ); 1499 + doc.composition.set(Some(CompositionState { 1500 + start_offset: cursor_offset, 1501 + text: data, 1502 + })); 1503 + } 1504 + }, 1505 1506 + oncompositionupdate: { 1507 + let mut doc = document.clone(); 1508 + move |evt: CompositionEvent| { 1509 + let data = evt.data().data(); 1510 + tracing::trace!( 1511 + data = %data, 1512 + "compositionupdate" 1513 + ); 1514 + let mut comp_guard = doc.composition.write(); 1515 + if let Some(ref mut comp) = *comp_guard { 1516 + comp.text = data; 1517 + } else { 1518 + tracing::debug!("compositionupdate without active composition state"); 1519 + } 1520 } 1521 + }, 1522 1523 + oncompositionend: { 1524 + let mut doc = document.clone(); 1525 + move |evt: CompositionEvent| { 1526 + let final_text = evt.data().data(); 1527 + tracing::trace!( 1528 + data = %final_text, 1529 + "compositionend" 1530 + ); 1531 + // Record when composition ended for Safari timing workaround 1532 + doc.composition_ended_at.set(Some(web_time::Instant::now())); 1533 1534 + let comp = doc.composition.write().take(); 1535 + if let Some(comp) = comp { 1536 + tracing::debug!( 1537 + start_offset = comp.start_offset, 1538 + final_text = %final_text, 1539 + chars = final_text.chars().count(), 1540 + "compositionend: inserting text" 1541 + ); 1542 1543 + if !final_text.is_empty() { 1544 + let mut delete_start = comp.start_offset; 1545 + while delete_start > 0 { 1546 + match get_char_at(doc.loro_text(), delete_start - 1) { 1547 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 1548 + _ => break, 1549 + } 1550 } 1551 1552 + let cursor_offset = doc.cursor.read().offset; 1553 + let zw_count = cursor_offset - delete_start; 1554 + if zw_count > 0 { 1555 + // Splice: delete zero-width chars and insert new char in one op 1556 + let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 1557 + doc.cursor.write().offset = delete_start + final_text.chars().count(); 1558 + } else if cursor_offset == doc.len_chars() { 1559 + // Fast path: append at end 1560 + let _ = doc.push_tracked(&final_text); 1561 + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 1562 + } else { 1563 + let _ = doc.insert_tracked(cursor_offset, &final_text); 1564 + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 1565 + } 1566 } 1567 + } else { 1568 + tracing::debug!("compositionend without active composition state"); 1569 } 1570 } 1571 + }, 1572 } 1573 + div { class: "editor-debug", 1574 + div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 1575 + // Collab debug info 1576 + { 1577 + if let Some(debug_state) = crate::collab_context::try_use_collab_debug() { 1578 + let ds = debug_state.read(); 1579 + rsx! { 1580 + div { class: "collab-debug", 1581 + if let Some(ref node_id) = ds.node_id { 1582 + span { title: "{node_id}", "Node: {&node_id[..8.min(node_id.len())]}…" } 1583 + } 1584 + if ds.is_joined { 1585 + span { class: "joined", "✓ Joined" } 1586 + } 1587 + span { "Peers: {ds.discovered_peers}" } 1588 + if let Some(ref err) = ds.last_error { 1589 + span { class: "error", title: "{err}", "⚠" } 1590 + } 1591 } 1592 } 1593 + } else { 1594 + rsx! {} 1595 } 1596 + }, 1597 + ReportButton { 1598 + email: "editor-bugs@weaver.sh".to_string(), 1599 + editor_id: "markdown-editor".to_string(), 1600 } 1601 } 1602 } 1603 1604 + EditorToolbar { 1605 + on_format: { 1606 + let mut doc = document.clone(); 1607 + move |action| { 1608 + formatting::apply_formatting(&mut doc, action); 1609 + } 1610 + }, 1611 + on_image: { 1612 + let mut doc = document.clone(); 1613 + move |uploaded: super::image_upload::UploadedImage| { 1614 + // Build data URL for immediate preview 1615 + use base64::{Engine, engine::general_purpose::STANDARD}; 1616 + let data_url = format!( 1617 + "data:{};base64,{}", 1618 + uploaded.mime_type, 1619 + STANDARD.encode(&uploaded.data) 1620 + ); 1621 1622 + // Add to resolver for immediate display 1623 + let name = uploaded.name.clone(); 1624 + image_resolver.with_mut(|resolver| { 1625 + resolver.add_pending(name.clone(), data_url); 1626 + }); 1627 1628 + // Insert markdown image syntax at cursor 1629 + let alt_text = if uploaded.alt.is_empty() { 1630 + name.clone() 1631 + } else { 1632 + uploaded.alt.clone() 1633 + }; 1634 1635 + // Check if authenticated and get DID for draft path 1636 + let auth = auth_state.read(); 1637 + let did_for_path = auth.did.clone(); 1638 + let is_authenticated = auth.is_authenticated(); 1639 + drop(auth); 1640 1641 + // Pre-generate TID for the blob rkey (used in draft path and upload) 1642 + let blob_tid = jacquard::types::tid::Ticker::new().next(None); 1643 1644 + // Build markdown with proper draft path if authenticated 1645 + let markdown = if let Some(ref did) = did_for_path { 1646 + format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) 1647 + } else { 1648 + // Fallback for unauthenticated - simple path (won't be publishable anyway) 1649 + format!("![{}](/image/{})", alt_text, name) 1650 + }; 1651 1652 + let pos = doc.cursor.read().offset; 1653 + let _ = doc.insert_tracked(pos, &markdown); 1654 + doc.cursor.write().offset = pos + markdown.chars().count(); 1655 1656 + // Upload to PDS in background if authenticated 1657 + if is_authenticated { 1658 + let fetcher = fetcher.clone(); 1659 + let name_for_upload = name.clone(); 1660 + let alt_for_upload = alt_text.clone(); 1661 + let data = uploaded.data.clone(); 1662 + let mut doc_for_spawn = doc.clone(); 1663 1664 + spawn(async move { 1665 + let client = fetcher.get_client(); 1666 1667 + // Clone data for cache pre-warming 1668 + let data_for_cache = data.clone(); 1669 1670 + // Use pre-generated TID as rkey for the blob record 1671 + let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) 1672 + .expect("TID is valid record key"); 1673 1674 + // Upload blob and create temporary PublishedBlob record 1675 + match client.publish_blob(data, &name_for_upload, Some(rkey)).await { 1676 + Ok((strong_ref, published_blob)) => { 1677 + // Get DID from fetcher 1678 + let did = match fetcher.current_did().await { 1679 + Some(d) => d, 1680 + None => { 1681 + tracing::warn!("No DID available"); 1682 + return; 1683 + } 1684 + }; 1685 1686 + // Extract rkey from the AT-URI 1687 + let blob_rkey = match strong_ref.uri.rkey() { 1688 + Some(rkey) => rkey.0.clone().into_static(), 1689 + None => { 1690 + tracing::warn!("No rkey in PublishedBlob URI"); 1691 + return; 1692 + } 1693 + }; 1694 1695 + let cid = published_blob.upload.blob().cid().clone().into_static(); 1696 1697 + let name_for_resolver = name_for_upload.clone(); 1698 + let image = Image::new() 1699 + .alt(alt_for_upload.to_cowstr()) 1700 + .image(published_blob.upload) 1701 + .name(name_for_upload.to_cowstr()) 1702 + .build(); 1703 + doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); 1704 1705 + // Promote from pending to uploaded in resolver 1706 + let ident = AtIdentifier::Did(did); 1707 + image_resolver.with_mut(|resolver| { 1708 + resolver.promote_to_uploaded( 1709 + &name_for_resolver, 1710 + blob_rkey, 1711 + ident, 1712 + ); 1713 + }); 1714 1715 + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 1716 1717 + // Pre-warm server cache with blob bytes 1718 + #[cfg(feature = "fullstack-server")] 1719 + { 1720 + use jacquard::smol_str::ToSmolStr; 1721 + if let Err(e) = crate::data::cache_blob_bytes( 1722 + cid.to_smolstr(), 1723 + Some(name_for_resolver.into()), 1724 + None, 1725 + data_for_cache.into(), 1726 + ).await { 1727 + tracing::warn!(error = %e, "Failed to pre-warm blob cache"); 1728 + } 1729 } 1730 } 1731 + Err(e) => { 1732 + tracing::error!(error = %e, "Failed to upload image"); 1733 + // Image stays as data URL - will work for preview but not publish 1734 + } 1735 } 1736 + }); 1737 + } else { 1738 + tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); 1739 + } 1740 } 1741 + }, 1742 + } 1743 1744 + } 1745 } 1746 } 1747 } ··· 1752 /// Uses the same offset mapping as local cursor restoration. 1753 #[component] 1754 fn RemoteCursors( 1755 + presence: Signal<weaver_common::transport::PresenceSnapshot>, 1756 document: EditorDocument, 1757 render_cache: Signal<render::RenderCache>, 1758 ) -> Element { 1759 let presence_read = presence.read(); 1760 + let cursor_count = presence_read.collaborators.len(); 1761 let cursors: Vec<_> = presence_read 1762 + .collaborators 1763 + .iter() 1764 + .filter_map(|c| { 1765 + c.cursor_position 1766 + .map(|pos| (c.display_name.clone(), c.color, pos, c.selection)) 1767 + }) 1768 .collect(); 1769 1770 if cursor_count > 0 {
+10 -4
crates/weaver-app/src/components/editor/mod.rs
··· 6 7 mod actions; 8 mod beforeinput; 9 mod component; 10 mod cursor; 11 mod document; ··· 36 37 // Document types 38 #[allow(unused_imports)] 39 - pub use document::{Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection}; 40 41 // Formatting 42 #[allow(unused_imports)] ··· 62 // Sync 63 #[allow(unused_imports)] 64 pub use sync::{ 65 load_and_merge_document, load_edit_state_from_pds, sync_to_pds, 66 - list_drafts_from_pds, RemoteDraft, 67 - PdsEditState, SyncState, SyncStatus, 68 }; 69 70 // UI components ··· 85 // Worker 86 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 87 pub use worker::{ 88 - EditorWorker, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput, 89 };
··· 6 7 mod actions; 8 mod beforeinput; 9 + mod collab; 10 mod component; 11 mod cursor; 12 mod document; ··· 37 38 // Document types 39 #[allow(unused_imports)] 40 + pub use document::{ 41 + Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection, 42 + }; 43 44 // Formatting 45 #[allow(unused_imports)] ··· 65 // Sync 66 #[allow(unused_imports)] 67 pub use sync::{ 68 + PdsEditState, RemoteDraft, SyncState, SyncStatus, list_drafts_from_pds, 69 load_and_merge_document, load_edit_state_from_pds, sync_to_pds, 70 }; 71 72 // UI components ··· 87 // Worker 88 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 89 pub use worker::{ 90 + EditorReactor, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput, 91 }; 92 + 93 + // Collab coordinator 94 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 95 + pub use collab::CollabCoordinator;
-567
crates/weaver-app/src/components/editor/sync.rs
··· 1344 } 1345 1346 // ============================================================================ 1347 - // Real-Time P2P Sync (iroh-gossip) 1348 - // ============================================================================ 1349 - 1350 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1351 - use crate::collab_context::use_collab_node; 1352 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1353 - use std::sync::Arc; 1354 - 1355 - use weaver_common::transport::PresenceTracker; 1356 - 1357 - /// Props for real-time P2P sync component. 1358 - #[derive(Props, Clone, PartialEq)] 1359 - pub struct RealTimeSyncProps { 1360 - /// The editor document to sync 1361 - pub document: super::document::EditorDocument, 1362 - /// StrongRef to the resource being edited (for topic derivation and session records) 1363 - pub resource_ref: Option<StrongRef<'static>>, 1364 - /// Presence tracker for remote collaborators (shared with editor for rendering) 1365 - pub presence: Signal<PresenceTracker>, 1366 - } 1367 - 1368 - /// Real-time P2P sync component using iroh-gossip. 1369 - /// 1370 - /// When editing a collaborative document, this component: 1371 - /// - Joins a gossip topic for the resource 1372 - /// - Broadcasts local edits to peers via Loro's subscribe_local_update 1373 - /// - Imports incoming edits from peers 1374 - /// 1375 - /// This runs alongside the existing async PDS sync for redundancy. 1376 - /// Session TTL in minutes - sessions are refreshed while active 1377 - const SESSION_TTL_MINUTES: u32 = 15; 1378 - 1379 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1380 - #[component] 1381 - pub fn RealTimeSync(props: RealTimeSyncProps) -> Element { 1382 - use crate::collab_context::try_use_collab_debug; 1383 - use tokio::sync::mpsc; 1384 - use weaver_common::WeaverExt; 1385 - use weaver_common::transport::{CollabMessage, CollabSession, SessionEvent}; 1386 - 1387 - let collab_node = use_collab_node(); 1388 - let fetcher = use_context::<crate::fetch::Fetcher>(); 1389 - let mut session: Signal<Option<Arc<CollabSession>>> = use_signal(|| None); 1390 - // URI of our published session record (for cleanup) 1391 - let mut session_record_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 1392 - // Debug state for display in editor debug panel (optional, may not be provided) 1393 - let debug_state = try_use_collab_debug(); 1394 - // Channel for sending local updates from Loro callback to async broadcast task 1395 - let mut update_tx: Signal<Option<mpsc::UnboundedSender<Vec<u8>>>> = use_signal(|| None); 1396 - // Channel for sending cursor updates 1397 - let mut cursor_tx: Signal<Option<mpsc::UnboundedSender<(usize, Option<(usize, usize)>)>>> = 1398 - use_signal(|| None); 1399 - // Keep subscription alive 1400 - let mut _subscription: Signal<Option<loro::Subscription>> = use_signal(|| None); 1401 - // Our assigned colour (set when we join) 1402 - let mut our_color: Signal<u32> = use_signal(|| 0x4ECDC4FF); 1403 - 1404 - let resource_ref = props.resource_ref.clone(); 1405 - let doc = props.document.clone(); 1406 - let mut presence = props.presence; 1407 - 1408 - // Broadcast cursor position when it changes 1409 - { 1410 - let doc = doc.clone(); 1411 - use_effect(move || { 1412 - // Read cursor to create reactive dependency 1413 - let cursor_state = doc.cursor.read(); 1414 - let selection = *doc.selection.read(); 1415 - 1416 - // Send cursor update if we have a channel 1417 - if let Some(ref tx) = *cursor_tx.read() { 1418 - let sel = selection.map(|s| (s.anchor, s.head)); 1419 - let _ = tx.send((cursor_state.offset, sel)); 1420 - } 1421 - }); 1422 - } 1423 - 1424 - // Join the gossip session when we have a node and resource ref 1425 - { 1426 - let resource_ref = resource_ref.clone(); 1427 - let doc_for_join = doc.clone(); 1428 - let fetcher = fetcher.clone(); 1429 - 1430 - use_effect(move || { 1431 - let Some(node) = collab_node.clone() else { 1432 - tracing::debug!("RealTimeSync: no CollabNode yet"); 1433 - return; 1434 - }; 1435 - 1436 - let Some(ref strong_ref) = resource_ref else { 1437 - tracing::debug!("RealTimeSync: no resource ref"); 1438 - return; 1439 - }; 1440 - 1441 - // Only join if we're not already in a session 1442 - if session.peek().is_some() { 1443 - return; 1444 - } 1445 - 1446 - let uri = strong_ref.uri.clone().into_static(); 1447 - tracing::info!("RealTimeSync: joining session for {}", uri); 1448 - 1449 - // Create channel for local update broadcasts 1450 - let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>(); 1451 - update_tx.set(Some(tx.clone())); 1452 - 1453 - // Create channel for cursor updates 1454 - let (ctx, mut crx) = mpsc::unbounded_channel::<(usize, Option<(usize, usize)>)>(); 1455 - cursor_tx.set(Some(ctx)); 1456 - 1457 - // Subscribe to local updates from Loro - fires when local changes are committed 1458 - let sub = doc_for_join 1459 - .loro_doc() 1460 - .subscribe_local_update(Box::new(move |update| { 1461 - tracing::debug!("RealTimeSync: local update ({} bytes)", update.len()); 1462 - if let Err(e) = tx.send(update.to_vec()) { 1463 - tracing::warn!("RealTimeSync: failed to queue update: {}", e); 1464 - } 1465 - true // Keep subscription active 1466 - })); 1467 - _subscription.set(Some(sub)); 1468 - 1469 - let doc_for_recv = doc_for_join.clone(); 1470 - let resource_ref_for_spawn = resource_ref.clone().unwrap(); 1471 - let fetcher = fetcher.clone(); 1472 - 1473 - spawn(async move { 1474 - // Derive topic from resource URI 1475 - let topic = CollabSession::topic_from_uri(uri.as_str()); 1476 - 1477 - // Wait for relay connection before discovering peers or publishing session 1478 - // Browser clients REQUIRE relay for peer connectivity 1479 - let relay_url = node.wait_for_relay().await; 1480 - tracing::info!( 1481 - relay_url = %relay_url, 1482 - "RealTimeSync: relay connection ready" 1483 - ); 1484 - 1485 - // Update debug state with node info 1486 - if let Some(mut ds) = debug_state { 1487 - ds.with_mut(|s| { 1488 - s.node_id = Some(node.node_id_string()); 1489 - s.relay_url = Some(relay_url.clone()); 1490 - }); 1491 - } 1492 - 1493 - // Discover existing session peers for bootstrap 1494 - let bootstrap_peers = match fetcher.find_session_peers(&uri).await { 1495 - Ok(peers) => { 1496 - tracing::info!("RealTimeSync: found {} existing peers", peers.len()); 1497 - if let Some(mut ds) = debug_state { 1498 - ds.with_mut(|s| s.discovered_peers = peers.len()); 1499 - } 1500 - for p in &peers { 1501 - tracing::info!( 1502 - did = %p.did, 1503 - node_id = %p.node_id, 1504 - relay_url = ?p.relay_url, 1505 - expires_at = ?p.expires_at, 1506 - "RealTimeSync: discovered peer" 1507 - ); 1508 - } 1509 - peers 1510 - .into_iter() 1511 - .filter_map(|p| { 1512 - weaver_common::transport::parse_node_id(&p.node_id).ok() 1513 - }) 1514 - .collect() 1515 - } 1516 - Err(e) => { 1517 - tracing::warn!("RealTimeSync: failed to find peers: {}", e); 1518 - if let Some(mut ds) = debug_state { 1519 - ds.with_mut(|s| s.last_error = Some(format!("peer discovery: {}", e))); 1520 - } 1521 - vec![] 1522 - } 1523 - }; 1524 - 1525 - // Publish our session record for peer discovery 1526 - let node_id_str = node.node_id_string(); 1527 - match fetcher 1528 - .create_collab_session( 1529 - &resource_ref_for_spawn, 1530 - &node_id_str, 1531 - Some(&relay_url), 1532 - Some(SESSION_TTL_MINUTES), 1533 - ) 1534 - .await 1535 - { 1536 - Ok(uri) => { 1537 - tracing::info!("RealTimeSync: published session record: {}", uri); 1538 - if let Some(mut ds) = debug_state { 1539 - ds.with_mut(|s| s.session_record_uri = Some(uri.to_string())); 1540 - } 1541 - session_record_uri.set(Some(uri)); 1542 - } 1543 - Err(e) => { 1544 - tracing::warn!("RealTimeSync: failed to publish session record: {}", e); 1545 - if let Some(mut ds) = debug_state { 1546 - ds.with_mut(|s| s.last_error = Some(format!("publish session: {}", e))); 1547 - } 1548 - } 1549 - } 1550 - 1551 - // Clone before join() consumes them 1552 - let node_for_discovery = node.clone(); 1553 - let bootstrap_peers_set = bootstrap_peers.clone(); 1554 - 1555 - match CollabSession::join(node, topic, bootstrap_peers).await { 1556 - Ok((collab_session, mut event_stream)) => { 1557 - let collab_session = Arc::new(collab_session); 1558 - session.set(Some(collab_session.clone())); 1559 - if let Some(mut ds) = debug_state { 1560 - ds.with_mut(|s| s.is_joined = true); 1561 - } 1562 - 1563 - tracing::info!("RealTimeSync: joined session for {}", uri); 1564 - 1565 - // Broadcast Join message to announce ourselves 1566 - let our_did = fetcher.current_did().await; 1567 - let display_name = if let Some(ref did) = our_did { 1568 - use jacquard::types::ident::AtIdentifier; 1569 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 1570 - 1571 - let ident = AtIdentifier::Did(did.clone()); 1572 - fetcher 1573 - .fetch_profile(&ident) 1574 - .await 1575 - .ok() 1576 - .and_then(|p| match &p.inner { 1577 - ProfileDataViewInner::ProfileView(pv) => { 1578 - pv.display_name.as_ref().map(|s| s.to_string()) 1579 - } 1580 - ProfileDataViewInner::ProfileViewDetailed(pv) => { 1581 - pv.display_name.as_ref().map(|s| s.to_string()) 1582 - } 1583 - _ => None, 1584 - }) 1585 - .unwrap_or_else(|| "Collaborator".to_string()) 1586 - } else { 1587 - "Collaborator".to_string() 1588 - }; 1589 - let join_msg = CollabMessage::Join { 1590 - did: our_did.map(|d| d.to_string()).unwrap_or_default(), 1591 - display_name, 1592 - }; 1593 - if let Err(e) = collab_session.broadcast(&join_msg).await { 1594 - tracing::warn!("RealTimeSync: failed to broadcast Join: {}", e); 1595 - } 1596 - 1597 - // Request sync from existing peers 1598 - // Convert our version vector to wire format 1599 - let our_vv = doc_for_recv.version_vector(); 1600 - let have_version: Vec<(u64, u64)> = our_vv 1601 - .iter() 1602 - .map(|(peer, counter)| (*peer, *counter as u64)) 1603 - .collect(); 1604 - let sync_request = CollabMessage::SyncRequest { have_version }; 1605 - if let Err(e) = collab_session.broadcast(&sync_request).await { 1606 - tracing::warn!("RealTimeSync: failed to broadcast SyncRequest: {}", e); 1607 - } else { 1608 - tracing::debug!("RealTimeSync: sent sync request ({} vv entries)", our_vv.len()); 1609 - } 1610 - 1611 - // Spawn TTL refresh task - keeps our session record alive 1612 - let session_uri_for_refresh = session_record_uri.clone(); 1613 - let fetcher_for_refresh = fetcher.clone(); 1614 - spawn(async move { 1615 - // Refresh every 5 minutes (TTL is 15 min, so plenty of buffer) 1616 - let mut interval = n0_future::time::interval( 1617 - n0_future::time::Duration::from_secs(5 * 60), 1618 - ); 1619 - loop { 1620 - interval.tick().await; 1621 - if let Some(uri) = session_uri_for_refresh.peek().clone() { 1622 - tracing::debug!("RealTimeSync: refreshing session TTL"); 1623 - if let Err(e) = fetcher_for_refresh 1624 - .refresh_collab_session(&uri, SESSION_TTL_MINUTES) 1625 - .await 1626 - { 1627 - tracing::warn!( 1628 - "RealTimeSync: failed to refresh session: {}", 1629 - e 1630 - ); 1631 - } 1632 - } 1633 - } 1634 - }); 1635 - 1636 - // Spawn broadcast task - sends local updates to gossip 1637 - let session_for_broadcast = collab_session.clone(); 1638 - spawn(async move { 1639 - while let Some(update_bytes) = rx.recv().await { 1640 - let msg = CollabMessage::LoroUpdate { 1641 - data: update_bytes, 1642 - version: vec![], // Version included in Loro update bytes 1643 - }; 1644 - if let Err(e) = session_for_broadcast.broadcast(&msg).await { 1645 - tracing::warn!("RealTimeSync: broadcast failed: {}", e); 1646 - } else { 1647 - tracing::debug!("RealTimeSync: broadcasted update"); 1648 - } 1649 - } 1650 - tracing::debug!("RealTimeSync: broadcast channel closed"); 1651 - }); 1652 - 1653 - // Spawn cursor broadcast task - sends cursor positions to gossip 1654 - let session_for_cursor = collab_session.clone(); 1655 - spawn(async move { 1656 - while let Some((position, selection)) = crx.recv().await { 1657 - let color = *our_color.peek(); 1658 - let msg = CollabMessage::Cursor { 1659 - position, 1660 - selection, 1661 - color, 1662 - }; 1663 - if let Err(e) = session_for_cursor.broadcast(&msg).await { 1664 - tracing::warn!("RealTimeSync: cursor broadcast failed: {}", e); 1665 - } 1666 - } 1667 - }); 1668 - 1669 - // Spawn periodic peer discovery task 1670 - // This handles the race condition where peers publish sessions 1671 - // at different times and might miss each other on initial discovery 1672 - let session_for_discovery = collab_session.clone(); 1673 - let fetcher_for_discovery = fetcher.clone(); 1674 - let uri_for_discovery = uri.clone(); 1675 - let our_node_id = node_for_discovery.node_id(); 1676 - let mut known_peers: std::collections::HashSet<weaver_common::transport::EndpointId> = 1677 - bootstrap_peers_set.iter().cloned().collect(); 1678 - spawn(async move { 1679 - // Check for new peers every 30 seconds 1680 - let mut interval = 1681 - n0_future::time::interval(n0_future::time::Duration::from_secs(30)); 1682 - loop { 1683 - interval.tick().await; 1684 - tracing::debug!("RealTimeSync: periodic discovery tick"); 1685 - match fetcher_for_discovery 1686 - .find_session_peers(&uri_for_discovery) 1687 - .await 1688 - { 1689 - Ok(peers) => { 1690 - tracing::info!( 1691 - "RealTimeSync: periodic discovery found {} session records", 1692 - peers.len() 1693 - ); 1694 - for p in &peers { 1695 - tracing::debug!( 1696 - " - peer: {} (relay: {:?}, expires: {:?})", 1697 - p.node_id, 1698 - p.relay_url, 1699 - p.expires_at 1700 - ); 1701 - } 1702 - // Filter: parse node ID, exclude ourselves, exclude already known 1703 - let new_peers: Vec<_> = peers 1704 - .into_iter() 1705 - .filter_map(|p| { 1706 - weaver_common::transport::parse_node_id(&p.node_id) 1707 - .ok() 1708 - }) 1709 - .filter(|id| *id != our_node_id) 1710 - .filter(|id| !known_peers.contains(id)) 1711 - .collect(); 1712 - 1713 - if !new_peers.is_empty() { 1714 - tracing::info!( 1715 - "RealTimeSync: periodic discovery found {} NEW peers", 1716 - new_peers.len() 1717 - ); 1718 - for p in &new_peers { 1719 - known_peers.insert(*p); 1720 - } 1721 - if let Err(e) = 1722 - session_for_discovery.join_peers(new_peers).await 1723 - { 1724 - tracing::warn!( 1725 - "RealTimeSync: failed to join discovered peers: {}", 1726 - e 1727 - ); 1728 - } 1729 - } 1730 - } 1731 - Err(e) => { 1732 - tracing::warn!( 1733 - "RealTimeSync: periodic peer discovery failed: {}", 1734 - e 1735 - ); 1736 - } 1737 - } 1738 - } 1739 - }); 1740 - 1741 - // Spawn event receiver task - receives updates from peers 1742 - let mut doc_for_recv = doc_for_recv.clone(); 1743 - let session_for_sync = collab_session.clone(); 1744 - spawn(async move { 1745 - use n0_future::StreamExt; 1746 - 1747 - while let Some(result) = event_stream.next().await { 1748 - let event = match result { 1749 - Ok(e) => e, 1750 - Err(e) => { 1751 - tracing::error!("RealTimeSync: event stream error: {}", e); 1752 - break; 1753 - } 1754 - }; 1755 - match event { 1756 - SessionEvent::Message { from, message } => { 1757 - match message { 1758 - CollabMessage::LoroUpdate { data, .. } => { 1759 - tracing::debug!( 1760 - "RealTimeSync: received update from {} ({} bytes)", 1761 - from, 1762 - data.len() 1763 - ); 1764 - if let Err(e) = doc_for_recv.import_updates(&data) { 1765 - tracing::warn!( 1766 - "RealTimeSync: failed to import update: {:?}", 1767 - e 1768 - ); 1769 - } 1770 - } 1771 - CollabMessage::Cursor { 1772 - position, 1773 - selection, 1774 - .. 1775 - } => { 1776 - // Add peer if not known (cursor might arrive before Join) 1777 - let mut p = presence.write(); 1778 - if !p.contains(&from) { 1779 - p.add_collaborator( 1780 - from, 1781 - "unknown".into(), 1782 - "Peer".into(), 1783 - ); 1784 - } 1785 - p.update_cursor(&from, position, selection); 1786 - } 1787 - CollabMessage::Join { did, display_name } => { 1788 - tracing::info!( 1789 - "RealTimeSync: peer joined: {} ({})", 1790 - display_name, 1791 - did 1792 - ); 1793 - presence.write().add_collaborator( 1794 - from, 1795 - did, 1796 - display_name, 1797 - ); 1798 - } 1799 - CollabMessage::Leave { did } => { 1800 - tracing::info!("RealTimeSync: peer left: {}", did); 1801 - presence.write().remove_collaborator(&from); 1802 - } 1803 - CollabMessage::SyncRequest { have_version } => { 1804 - tracing::debug!( 1805 - "RealTimeSync: sync request (have {} entries)", 1806 - have_version.len() 1807 - ); 1808 - // Convert their version vector from wire format 1809 - let their_vv: loro::VersionVector = have_version 1810 - .into_iter() 1811 - .map(|(peer, counter)| (peer, counter as i32)) 1812 - .collect(); 1813 - 1814 - // Export updates they don't have 1815 - if let Some(data) = 1816 - doc_for_recv.export_updates_from(&their_vv) 1817 - { 1818 - tracing::info!( 1819 - "RealTimeSync: sending {} bytes to sync peer", 1820 - data.len() 1821 - ); 1822 - let response = CollabMessage::SyncResponse { 1823 - data, 1824 - is_snapshot: false, 1825 - }; 1826 - if let Err(e) = 1827 - session_for_sync.broadcast(&response).await 1828 - { 1829 - tracing::warn!( 1830 - "RealTimeSync: failed to send sync response: {}", 1831 - e 1832 - ); 1833 - } 1834 - } else { 1835 - tracing::debug!( 1836 - "RealTimeSync: no updates to send (peer is up to date)" 1837 - ); 1838 - } 1839 - } 1840 - CollabMessage::SyncResponse { data, is_snapshot } => { 1841 - tracing::info!( 1842 - "RealTimeSync: received sync response ({} bytes, snapshot: {})", 1843 - data.len(), 1844 - is_snapshot 1845 - ); 1846 - if let Err(e) = doc_for_recv.import_updates(&data) { 1847 - tracing::warn!( 1848 - "RealTimeSync: failed to import sync response: {:?}", 1849 - e 1850 - ); 1851 - } 1852 - } 1853 - } 1854 - } 1855 - SessionEvent::PeerJoined(peer) => { 1856 - tracing::info!("RealTimeSync: peer connected: {}", peer); 1857 - // Add peer with placeholder name until they send Join 1858 - if !presence.read().contains(&peer) { 1859 - presence.write().add_collaborator( 1860 - peer, 1861 - "unknown".into(), 1862 - "Collaborator".into(), 1863 - ); 1864 - } 1865 - } 1866 - SessionEvent::PeerLeft(peer) => { 1867 - tracing::info!("RealTimeSync: peer disconnected: {}", peer); 1868 - presence.write().remove_collaborator(&peer); 1869 - } 1870 - SessionEvent::Joined => { 1871 - tracing::info!("RealTimeSync: joined gossip swarm"); 1872 - } 1873 - } 1874 - } 1875 - tracing::debug!("RealTimeSync: event stream ended"); 1876 - }); 1877 - } 1878 - Err(e) => { 1879 - tracing::error!("RealTimeSync: failed to join session: {}", e); 1880 - } 1881 - } 1882 - }); 1883 - }); 1884 - } 1885 - 1886 - // Cleanup: delete session record when component unmounts 1887 - { 1888 - let fetcher = fetcher.clone(); 1889 - use_drop(move || { 1890 - if let Some(uri) = session_record_uri.peek().clone() { 1891 - let fetcher = fetcher.clone(); 1892 - spawn(async move { 1893 - tracing::info!("RealTimeSync: cleaning up session record: {}", uri); 1894 - if let Err(e) = fetcher.delete_collab_session(&uri).await { 1895 - tracing::warn!("RealTimeSync: failed to delete session record: {}", e); 1896 - } 1897 - }); 1898 - } 1899 - }); 1900 - } 1901 - 1902 - // No UI - this is a background sync component 1903 - rsx! {} 1904 - } 1905 - 1906 - /// No-op for non-WASM builds. 1907 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 1908 - #[component] 1909 - pub fn RealTimeSync(props: RealTimeSyncProps) -> Element { 1910 - rsx! {} 1911 - } 1912 - 1913 - // ============================================================================ 1914 // Sync UI Components 1915 // ============================================================================ 1916
··· 1344 } 1345 1346 // ============================================================================ 1347 // Sync UI Components 1348 // ============================================================================ 1349
+470 -57
crates/weaver-app/src/components/editor/worker.rs
··· 4 //! CPU-intensive operations like snapshot export and base64 encoding 5 //! off the main thread. 6 //! 7 //! Also handles embed fetching with a persistent cache to avoid re-fetching. 8 9 #[cfg(all(target_family = "wasm", target_os = "unknown"))] ··· 53 /// Node ID strings 54 peers: Vec<String>, 55 }, 56 /// Local cursor position changed 57 BroadcastCursor { 58 /// Cursor position ··· 110 PresenceUpdate(PresenceSnapshot), 111 /// Collab session ended 112 CollabStopped, 113 } 114 115 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 116 mod worker_impl { 117 use super::*; 118 - use gloo_worker::{HandlerId, Worker, WorkerScope}; 119 120 - /// Editor worker that maintains a shadow Loro document. 121 - pub struct EditorWorker { 122 - /// Shadow Loro document 123 - doc: Option<loro::LoroDoc>, 124 - /// Draft key for storage identification 125 - draft_key: String, 126 } 127 128 - impl Worker for EditorWorker { 129 - type Message = (); 130 - type Input = WorkerInput; 131 - type Output = WorkerOutput; 132 133 - fn create(_scope: &WorkerScope<Self>) -> Self { 134 - Self { 135 - doc: None, 136 - draft_key: String::new(), 137 - } 138 } 139 140 - fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {} 141 142 - fn received(&mut self, scope: &WorkerScope<Self>, msg: Self::Input, id: HandlerId) { 143 - match msg { 144 WorkerInput::Init { 145 snapshot, 146 - draft_key, 147 } => { 148 - let doc = loro::LoroDoc::new(); 149 if !snapshot.is_empty() { 150 - if let Err(e) = doc.import(&snapshot) { 151 - scope.respond( 152 - id, 153 - WorkerOutput::Error { 154 message: format!("Failed to import snapshot: {e}"), 155 - }, 156 - ); 157 - return; 158 } 159 } 160 - self.doc = Some(doc); 161 - self.draft_key = draft_key; 162 - scope.respond(id, WorkerOutput::Ready); 163 } 164 165 WorkerInput::ApplyUpdates { updates } => { 166 - if let Some(ref doc) = self.doc { 167 if let Err(e) = doc.import(&updates) { 168 - // Log but don't fail - updates can be stale 169 tracing::warn!("Worker failed to import updates: {e}"); 170 } 171 } 172 - // No response for updates - fire and forget 173 } 174 175 WorkerInput::ExportSnapshot { ··· 177 editing_uri, 178 editing_cid, 179 } => { 180 - let Some(ref doc) = self.doc else { 181 - scope.respond( 182 - id, 183 - WorkerOutput::Error { 184 message: "No document initialized".into(), 185 - }, 186 - ); 187 - return; 188 }; 189 190 - // Export snapshot 191 let export_start = crate::perf::now(); 192 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 193 Ok(bytes) => bytes, 194 Err(e) => { 195 - scope.respond( 196 - id, 197 - WorkerOutput::Error { 198 message: format!("Export failed: {e}"), 199 - }, 200 - ); 201 - return; 202 } 203 }; 204 let export_ms = crate::perf::now() - export_start; 205 206 - // Base64 encode 207 let encode_start = crate::perf::now(); 208 let b64_snapshot = BASE64.encode(&snapshot_bytes); 209 let encode_ms = crate::perf::now() - encode_start; 210 211 - // Extract content and title 212 let content = doc.get_text("content").to_string(); 213 let title = doc.get_text("title").to_string(); 214 215 - scope.respond( 216 - id, 217 - WorkerOutput::Snapshot { 218 - draft_key: self.draft_key.clone(), 219 b64_snapshot, 220 content, 221 title, ··· 224 editing_cid, 225 export_ms, 226 encode_ms, 227 - }, 228 - ); 229 } 230 } 231 } 232 } 233 } 234 235 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 236 - pub use worker_impl::EditorWorker; 237 238 // ============================================================================ 239 // Embed Worker - fetches and caches AT Protocol embeds
··· 4 //! CPU-intensive operations like snapshot export and base64 encoding 5 //! off the main thread. 6 //! 7 + //! When the `collab-worker` feature is enabled, also handles iroh P2P 8 + //! networking for real-time collaboration. 9 + //! 10 //! Also handles embed fetching with a persistent cache to avoid re-fetching. 11 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] ··· 56 /// Node ID strings 57 peers: Vec<String>, 58 }, 59 + /// Announce ourselves to peers (sent after AddPeers) 60 + BroadcastJoin { 61 + /// Our DID 62 + did: String, 63 + /// Our display name 64 + display_name: String, 65 + }, 66 /// Local cursor position changed 67 BroadcastCursor { 68 /// Cursor position ··· 120 PresenceUpdate(PresenceSnapshot), 121 /// Collab session ended 122 CollabStopped, 123 + /// A new peer connected (coordinator should send BroadcastJoin) 124 + PeerConnected, 125 } 126 127 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 128 mod worker_impl { 129 use super::*; 130 + use futures_util::sink::SinkExt; 131 + use futures_util::stream::StreamExt; 132 + use gloo_worker::reactor::{reactor, ReactorScope}; 133 + use weaver_common::transport::CollaboratorInfo; 134 + 135 + #[cfg(feature = "collab-worker")] 136 + use std::sync::Arc; 137 + #[cfg(feature = "collab-worker")] 138 + use weaver_common::transport::{ 139 + CollabMessage, CollabNode, CollabSession, PresenceTracker, SessionEvent, TopicId, 140 + parse_node_id, 141 + }; 142 143 + /// Internal event from gossip handler task to main reactor loop. 144 + #[cfg(feature = "collab-worker")] 145 + enum CollabEvent { 146 + RemoteUpdates { data: Vec<u8> }, 147 + PresenceChanged(PresenceSnapshot), 148 + PeerConnected, 149 } 150 151 + /// Editor reactor that maintains a shadow Loro document and handles collab. 152 + #[reactor] 153 + pub async fn EditorReactor(mut scope: ReactorScope<WorkerInput, WorkerOutput>) { 154 + let mut doc: Option<loro::LoroDoc> = None; 155 + let mut draft_key = String::new(); 156 + 157 + // Collab state (only used when collab-worker feature enabled) 158 + #[cfg(feature = "collab-worker")] 159 + let mut collab_node: Option<Arc<CollabNode>> = None; 160 + #[cfg(feature = "collab-worker")] 161 + let mut collab_session: Option<Arc<CollabSession>> = None; 162 + #[cfg(feature = "collab-worker")] 163 + let mut collab_event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<CollabEvent>> = None; 164 + #[cfg(feature = "collab-worker")] 165 + const OUR_COLOR: u32 = 0x4ECDC4FF; 166 167 + // Helper enum for racing coordinator messages vs collab events 168 + #[cfg(feature = "collab-worker")] 169 + enum RaceResult { 170 + CoordinatorMsg(Option<WorkerInput>), 171 + CollabEvent(Option<CollabEvent>), 172 } 173 174 + loop { 175 + // Race between coordinator messages and collab events 176 + #[cfg(feature = "collab-worker")] 177 + let race_result = if let Some(ref mut event_rx) = collab_event_rx { 178 + use n0_future::FutureExt; 179 + let coord_fut = async { RaceResult::CoordinatorMsg(scope.next().await) }; 180 + let collab_fut = async { RaceResult::CollabEvent(event_rx.recv().await) }; 181 + coord_fut.race(collab_fut).await 182 + } else { 183 + RaceResult::CoordinatorMsg(scope.next().await) 184 + }; 185 186 + #[cfg(feature = "collab-worker")] 187 + match race_result { 188 + RaceResult::CollabEvent(Some(event)) => { 189 + match event { 190 + CollabEvent::RemoteUpdates { data } => { 191 + if let Err(e) = scope.send(WorkerOutput::RemoteUpdates { data }).await { 192 + tracing::error!("Failed to send RemoteUpdates to coordinator: {e}"); 193 + } 194 + } 195 + CollabEvent::PresenceChanged(snapshot) => { 196 + if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await { 197 + tracing::error!("Failed to send PresenceUpdate to coordinator: {e}"); 198 + } 199 + } 200 + CollabEvent::PeerConnected => { 201 + if let Err(e) = scope.send(WorkerOutput::PeerConnected).await { 202 + tracing::error!("Failed to send PeerConnected to coordinator: {e}"); 203 + } 204 + } 205 + } 206 + continue; // Go back to racing 207 + } 208 + RaceResult::CollabEvent(None) => { 209 + // Collab channel closed, continue with just coordinator messages 210 + collab_event_rx = None; 211 + continue; 212 + } 213 + RaceResult::CoordinatorMsg(None) => break, // Coordinator closed 214 + RaceResult::CoordinatorMsg(Some(msg)) => { 215 + // Fall through to message handling below 216 + tracing::debug!(?msg, "Worker: received message"); 217 + match msg { 218 WorkerInput::Init { 219 snapshot, 220 + draft_key: key, 221 } => { 222 + let new_doc = loro::LoroDoc::new(); 223 if !snapshot.is_empty() { 224 + if let Err(e) = new_doc.import(&snapshot) { 225 + if let Err(send_err) = scope 226 + .send(WorkerOutput::Error { 227 message: format!("Failed to import snapshot: {e}"), 228 + }) 229 + .await 230 + { 231 + tracing::error!("Failed to send Error to coordinator: {send_err}"); 232 + } 233 + continue; 234 } 235 } 236 + doc = Some(new_doc); 237 + draft_key = key; 238 + if let Err(e) = scope.send(WorkerOutput::Ready).await { 239 + tracing::error!("Failed to send Ready to coordinator: {e}"); 240 + } 241 } 242 243 WorkerInput::ApplyUpdates { updates } => { 244 + if let Some(ref doc) = doc { 245 if let Err(e) = doc.import(&updates) { 246 tracing::warn!("Worker failed to import updates: {e}"); 247 } 248 } 249 } 250 251 WorkerInput::ExportSnapshot { ··· 253 editing_uri, 254 editing_cid, 255 } => { 256 + let Some(ref doc) = doc else { 257 + if let Err(e) = scope 258 + .send(WorkerOutput::Error { 259 message: "No document initialized".into(), 260 + }) 261 + .await 262 + { 263 + tracing::error!("Failed to send Error to coordinator: {e}"); 264 + } 265 + continue; 266 }; 267 268 let export_start = crate::perf::now(); 269 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 270 Ok(bytes) => bytes, 271 Err(e) => { 272 + if let Err(send_err) = scope 273 + .send(WorkerOutput::Error { 274 message: format!("Export failed: {e}"), 275 + }) 276 + .await 277 + { 278 + tracing::error!("Failed to send Error to coordinator: {send_err}"); 279 + } 280 + continue; 281 } 282 }; 283 let export_ms = crate::perf::now() - export_start; 284 285 let encode_start = crate::perf::now(); 286 let b64_snapshot = BASE64.encode(&snapshot_bytes); 287 let encode_ms = crate::perf::now() - encode_start; 288 289 let content = doc.get_text("content").to_string(); 290 let title = doc.get_text("title").to_string(); 291 292 + if let Err(e) = scope 293 + .send(WorkerOutput::Snapshot { 294 + draft_key: draft_key.clone(), 295 b64_snapshot, 296 content, 297 title, ··· 300 editing_cid, 301 export_ms, 302 encode_ms, 303 + }) 304 + .await 305 + { 306 + tracing::error!("Failed to send Snapshot to coordinator: {e}"); 307 + } 308 + } 309 + 310 + // ============================================================ 311 + // Collab handlers - full impl when collab-worker feature enabled 312 + // ============================================================ 313 + #[cfg(feature = "collab-worker")] 314 + WorkerInput::StartCollab { 315 + topic, 316 + bootstrap_peers, 317 + } => { 318 + // Spawn CollabNode 319 + let node = match CollabNode::spawn(None).await { 320 + Ok(n) => n, 321 + Err(e) => { 322 + if let Err(send_err) = scope 323 + .send(WorkerOutput::Error { 324 + message: format!("Failed to spawn CollabNode: {e}"), 325 + }) 326 + .await 327 + { 328 + tracing::error!("Failed to send Error to coordinator: {send_err}"); 329 + } 330 + continue; 331 + } 332 + }; 333 + 334 + // Wait for relay connection 335 + let relay_url = node.wait_for_relay().await; 336 + let node_id = node.node_id_string(); 337 + 338 + // Send ready so main thread can create session record 339 + if let Err(e) = scope 340 + .send(WorkerOutput::CollabReady { 341 + node_id, 342 + relay_url: Some(relay_url), 343 + }) 344 + .await 345 + { 346 + tracing::error!("Failed to send CollabReady to coordinator: {e}"); 347 + } 348 + 349 + collab_node = Some(node.clone()); 350 + 351 + // Parse bootstrap peers 352 + let peers: Vec<_> = bootstrap_peers 353 + .iter() 354 + .filter_map(|s| parse_node_id(s).ok()) 355 + .collect(); 356 + 357 + // Join gossip session 358 + let topic_id = TopicId::from_bytes(topic); 359 + match CollabSession::join(node, topic_id, peers).await { 360 + Ok((session, mut events)) => { 361 + let session = Arc::new(session); 362 + collab_session = Some(session.clone()); 363 + if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { 364 + tracing::error!("Failed to send CollabJoined to coordinator: {e}"); 365 + } 366 + 367 + // NOTE: Don't broadcast Join here - wait for BroadcastJoin message 368 + // after peers have been added via AddPeers 369 + 370 + // Create channel for events from spawned task 371 + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); 372 + collab_event_rx = Some(event_rx); 373 + 374 + // Spawn event handler task that sends via channel 375 + wasm_bindgen_futures::spawn_local(async move { 376 + let mut presence = PresenceTracker::new(); 377 + 378 + while let Some(Ok(event)) = events.next().await { 379 + match event { 380 + SessionEvent::Message { from, message } => { 381 + match message { 382 + CollabMessage::LoroUpdate { data, .. } => { 383 + if event_tx.send(CollabEvent::RemoteUpdates { data }).is_err() { 384 + tracing::warn!("Collab event channel closed"); 385 + return; 386 + } 387 + } 388 + CollabMessage::Join { did, display_name } => { 389 + tracing::info!(%from, %did, %display_name, "Received Join message"); 390 + presence.add_collaborator(from, did, display_name); 391 + if event_tx.send(CollabEvent::PresenceChanged( 392 + presence_to_snapshot(&presence), 393 + )).is_err() { 394 + tracing::warn!("Collab event channel closed"); 395 + return; 396 + } 397 + } 398 + CollabMessage::Leave { .. } => { 399 + presence.remove_collaborator(&from); 400 + if event_tx.send(CollabEvent::PresenceChanged( 401 + presence_to_snapshot(&presence), 402 + )).is_err() { 403 + tracing::warn!("Collab event channel closed"); 404 + return; 405 + } 406 + } 407 + CollabMessage::Cursor { 408 + position, 409 + selection, 410 + .. 411 + } => { 412 + // Note: cursor updates require the collaborator to exist 413 + // (added via Join message) 414 + let exists = presence.contains(&from); 415 + tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); 416 + presence.update_cursor(&from, position, selection); 417 + if event_tx.send(CollabEvent::PresenceChanged( 418 + presence_to_snapshot(&presence), 419 + )).is_err() { 420 + tracing::warn!("Collab event channel closed"); 421 + return; 422 + } 423 + } 424 + _ => {} 425 + } 426 + } 427 + SessionEvent::PeerJoined(peer) => { 428 + tracing::info!(%peer, "PeerJoined - notifying coordinator"); 429 + // Notify coordinator so it can send BroadcastJoin 430 + // Don't add to presence yet - wait for their Join message 431 + if event_tx.send(CollabEvent::PeerConnected).is_err() { 432 + tracing::warn!("Collab event channel closed"); 433 + return; 434 + } 435 + } 436 + SessionEvent::PeerLeft(peer) => { 437 + presence.remove_collaborator(&peer); 438 + if event_tx.send(CollabEvent::PresenceChanged( 439 + presence_to_snapshot(&presence), 440 + )).is_err() { 441 + tracing::warn!("Collab event channel closed"); 442 + return; 443 + } 444 + } 445 + SessionEvent::Joined => {} 446 + } 447 + } 448 + }); 449 + } 450 + Err(e) => { 451 + if let Err(send_err) = scope 452 + .send(WorkerOutput::Error { 453 + message: format!("Failed to join session: {e}"), 454 + }) 455 + .await 456 + { 457 + tracing::error!("Failed to send Error to coordinator: {send_err}"); 458 + } 459 + } 460 + } 461 + } 462 + 463 + #[cfg(feature = "collab-worker")] 464 + WorkerInput::BroadcastUpdate { data } => { 465 + if let Some(ref session) = collab_session { 466 + let msg = CollabMessage::LoroUpdate { 467 + data, 468 + version: vec![], 469 + }; 470 + if let Err(e) = session.broadcast(&msg).await { 471 + tracing::warn!("Broadcast failed: {e}"); 472 + } 473 + } 474 + } 475 + 476 + #[cfg(feature = "collab-worker")] 477 + WorkerInput::BroadcastCursor { position, selection } => { 478 + if let Some(ref session) = collab_session { 479 + tracing::debug!(position, ?selection, "Worker: broadcasting cursor"); 480 + let msg = CollabMessage::Cursor { 481 + position, 482 + selection, 483 + color: OUR_COLOR, 484 + }; 485 + if let Err(e) = session.broadcast(&msg).await { 486 + tracing::warn!("Cursor broadcast failed: {e}"); 487 + } 488 + } else { 489 + tracing::debug!(position, ?selection, "Worker: BroadcastCursor but no session"); 490 + } 491 + } 492 + 493 + #[cfg(feature = "collab-worker")] 494 + WorkerInput::AddPeers { peers } => { 495 + tracing::info!(count = peers.len(), "Worker: received AddPeers"); 496 + if let Some(ref session) = collab_session { 497 + let peer_ids: Vec<_> = peers 498 + .iter() 499 + .filter_map(|s| { 500 + match parse_node_id(s) { 501 + Ok(id) => Some(id), 502 + Err(e) => { 503 + tracing::warn!(node_id = %s, error = %e, "Failed to parse node_id"); 504 + None 505 + } 506 + } 507 + }) 508 + .collect(); 509 + tracing::info!(parsed_count = peer_ids.len(), "Worker: joining peers"); 510 + if let Err(e) = session.join_peers(peer_ids).await { 511 + tracing::warn!("Failed to add peers: {e}"); 512 + } 513 + } else { 514 + tracing::warn!("Worker: AddPeers but no collab_session"); 515 + } 516 + } 517 + 518 + #[cfg(feature = "collab-worker")] 519 + WorkerInput::BroadcastJoin { did, display_name } => { 520 + if let Some(ref session) = collab_session { 521 + let join_msg = CollabMessage::Join { did, display_name }; 522 + if let Err(e) = session.broadcast(&join_msg).await { 523 + tracing::warn!("Failed to broadcast Join: {e}"); 524 + } 525 + } 526 + } 527 + 528 + #[cfg(feature = "collab-worker")] 529 + WorkerInput::StopCollab => { 530 + collab_session = None; 531 + collab_node = None; 532 + collab_event_rx = None; 533 + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 534 + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 535 + } 536 + } 537 + 538 + } // end match msg 539 + } // end RaceResult::CoordinatorMsg(Some(msg)) 540 + } // end match race_result 541 + 542 + // Non-collab-worker: simple message loop 543 + #[cfg(not(feature = "collab-worker"))] 544 + { 545 + let Some(msg) = scope.next().await else { break }; 546 + tracing::debug!(?msg, "Worker: received message"); 547 + match msg { 548 + WorkerInput::Init { snapshot, draft_key: key } => { 549 + let new_doc = loro::LoroDoc::new(); 550 + if !snapshot.is_empty() { 551 + if let Err(e) = new_doc.import(&snapshot) { 552 + if let Err(send_err) = scope 553 + .send(WorkerOutput::Error { 554 + message: format!("Failed to import snapshot: {e}"), 555 + }) 556 + .await 557 + { 558 + tracing::error!("Failed to send Error to coordinator: {send_err}"); 559 + } 560 + continue; 561 + } 562 + } 563 + doc = Some(new_doc); 564 + draft_key = key; 565 + if let Err(e) = scope.send(WorkerOutput::Ready).await { 566 + tracing::error!("Failed to send Ready to coordinator: {e}"); 567 + } 568 + } 569 + WorkerInput::ApplyUpdates { updates } => { 570 + if let Some(ref doc) = doc { 571 + if let Err(e) = doc.import(&updates) { 572 + tracing::warn!("Worker failed to import updates: {e}"); 573 + } 574 + } 575 + } 576 + WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid } => { 577 + let Some(ref doc) = doc else { 578 + if let Err(e) = scope.send(WorkerOutput::Error { message: "No document initialized".into() }).await { 579 + tracing::error!("Failed to send Error to coordinator: {e}"); 580 + } 581 + continue; 582 + }; 583 + let export_start = crate::perf::now(); 584 + let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 585 + Ok(bytes) => bytes, 586 + Err(e) => { 587 + if let Err(send_err) = scope.send(WorkerOutput::Error { message: format!("Export failed: {e}") }).await { 588 + tracing::error!("Failed to send Error to coordinator: {send_err}"); 589 + } 590 + continue; 591 + } 592 + }; 593 + let export_ms = crate::perf::now() - export_start; 594 + let encode_start = crate::perf::now(); 595 + let b64_snapshot = BASE64.encode(&snapshot_bytes); 596 + let encode_ms = crate::perf::now() - encode_start; 597 + let content = doc.get_text("content").to_string(); 598 + let title = doc.get_text("title").to_string(); 599 + if let Err(e) = scope.send(WorkerOutput::Snapshot { 600 + draft_key: draft_key.clone(), b64_snapshot, content, title, 601 + cursor_offset, editing_uri, editing_cid, export_ms, encode_ms, 602 + }).await { 603 + tracing::error!("Failed to send Snapshot to coordinator: {e}"); 604 + } 605 + } 606 + // Collab stubs for non-collab-worker build 607 + WorkerInput::StartCollab { .. } => { 608 + if let Err(e) = scope.send(WorkerOutput::Error { message: "Collab not enabled".into() }).await { 609 + tracing::error!("Failed to send Error to coordinator: {e}"); 610 + } 611 + } 612 + WorkerInput::BroadcastUpdate { .. } => {} 613 + WorkerInput::AddPeers { .. } => {} 614 + WorkerInput::BroadcastJoin { .. } => {} 615 + WorkerInput::BroadcastCursor { .. } => {} 616 + WorkerInput::StopCollab => { 617 + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 618 + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 619 + } 620 + } 621 } 622 } 623 } 624 } 625 + 626 + /// Convert PresenceTracker to serializable PresenceSnapshot. 627 + #[cfg(feature = "collab-worker")] 628 + fn presence_to_snapshot(tracker: &PresenceTracker) -> PresenceSnapshot { 629 + let collaborators = tracker 630 + .collaborators() 631 + .map(|c| CollaboratorInfo { 632 + node_id: c.node_id.to_string(), 633 + did: c.did.clone(), 634 + display_name: c.display_name.clone(), 635 + color: c.color, 636 + cursor_position: c.cursor.as_ref().map(|cur| cur.position), 637 + selection: c.cursor.as_ref().and_then(|cur| cur.selection), 638 + }) 639 + .collect(); 640 + 641 + PresenceSnapshot { 642 + collaborators, 643 + peer_count: tracker.len(), 644 + } 645 + } 646 } 647 648 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 649 + pub use worker_impl::EditorReactor; 650 651 // ============================================================================ 652 // Embed Worker - fetches and caches AT Protocol embeds
+4 -4
crates/weaver-app/src/env.rs
··· 1 // This file is automatically generated by build.rs 2 3 #[allow(unused)] 4 - pub const WEAVER_APP_ENV: &'static str = "prod"; 5 #[allow(unused)] 6 - pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh"; 7 #[allow(unused)] 8 - pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh"; 9 #[allow(unused)] 10 pub const WEAVER_PORT: &'static str = "8080"; 11 #[allow(unused)] ··· 13 #[allow(unused)] 14 pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; 15 #[allow(unused)] 16 - pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; 17 #[allow(unused)] 18 pub const WEAVER_TOS_URI: &'static str = ""; 19 #[allow(unused)]
··· 1 // This file is automatically generated by build.rs 2 3 #[allow(unused)] 4 + pub const WEAVER_APP_ENV: &'static str = "dev"; 5 #[allow(unused)] 6 + pub const WEAVER_APP_HOST: &'static str = "http://localhost"; 7 #[allow(unused)] 8 + pub const WEAVER_APP_DOMAIN: &'static str = ""; 9 #[allow(unused)] 10 pub const WEAVER_PORT: &'static str = "8080"; 11 #[allow(unused)] ··· 13 #[allow(unused)] 14 pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; 15 #[allow(unused)] 16 + pub const WEAVER_LOGO_URI: &'static str = ""; 17 #[allow(unused)] 18 pub const WEAVER_TOS_URI: &'static str = ""; 19 #[allow(unused)]
+2 -5
crates/weaver-app/src/lib.rs
··· 146 document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=IBM+Plex+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" } 147 // App shell styles (depends on theme variables) 148 document::Link { rel: "stylesheet", href: MAIN_CSS } 149 - // P2P collaboration node (provides CollabNode context for real-time sync) 150 - collab_context::CollabProvider { 151 - components::toast::ToastProvider { 152 - Router::<Route> {} 153 - } 154 } 155 } 156 }
··· 146 document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=IBM+Plex+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" } 147 // App shell styles (depends on theme variables) 148 document::Link { rel: "stylesheet", href: MAIN_CSS } 149 + components::toast::ToastProvider { 150 + Router::<Route> {} 151 } 152 } 153 }
+3
crates/weaver-app/src/views/entry.rs
··· 34 .map(|t| t.as_ref()) 35 .unwrap_or("Untitled"); 36 37 let author_handle = entry_view 38 .authors 39 .first() ··· 176 .as_ref() 177 .map(|p| p.as_ref().to_string()) 178 .unwrap_or_else(|| title.to_string()); 179 180 let author_handle = entry_view 181 .authors
··· 34 .map(|t| t.as_ref()) 35 .unwrap_or("Untitled"); 36 37 + tracing::info!("Entry: {title}"); 38 let author_handle = entry_view 39 .authors 40 .first() ··· 177 .as_ref() 178 .map(|p| p.as_ref().to_string()) 179 .unwrap_or_else(|| title.to_string()); 180 + 181 + tracing::info!("Entry: {entry_path} - {title}"); 182 183 let author_handle = entry_view 184 .authors
+3
crates/weaver-common/src/lib.rs
··· 10 // Re-export jacquard for convenience 11 pub use agent::{SessionPeer, WeaverExt}; 12 pub use error::WeaverError; 13 pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry}; 14 15 pub use jacquard;
··· 10 // Re-export jacquard for convenience 11 pub use agent::{SessionPeer, WeaverExt}; 12 pub use error::WeaverError; 13 + 14 + // Re-export blake3 for topic hashing 15 + pub use blake3; 16 pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry}; 17 18 pub use jacquard;
+1 -1
crates/weaver-common/src/transport/presence.rs
··· 38 } 39 40 /// Tracks all collaborators in a session. 41 - #[derive(Debug, Default)] 42 pub struct PresenceTracker { 43 /// Collaborators by EndpointId. 44 collaborators: HashMap<EndpointId, Collaborator>,
··· 38 } 39 40 /// Tracks all collaborators in a session. 41 + #[derive(Debug, Default, Clone)] 42 pub struct PresenceTracker { 43 /// Collaborators by EndpointId. 44 collaborators: HashMap<EndpointId, Collaborator>,
+14 -6
crates/weaver-common/src/transport/session.rs
··· 92 } 93 94 // Subscribe to the gossip topic 95 - let (sender, receiver) = node 96 - .gossip() 97 - .subscribe_and_join(topic, bootstrap_peers) 98 - .await 99 - .map_err(|e| SessionError::Subscribe(Box::new(e)))? 100 - .split(); 101 102 tracing::info!("CollabSession: subscribed to gossip topic"); 103
··· 92 } 93 94 // Subscribe to the gossip topic 95 + // Use subscribe (non-blocking) if no bootstrap peers, otherwise subscribe_and_join 96 + let (sender, receiver) = if bootstrap_peers.is_empty() { 97 + node.gossip() 98 + .subscribe(topic, vec![]) 99 + .await 100 + .map_err(|e| SessionError::Subscribe(Box::new(e)))? 101 + .split() 102 + } else { 103 + node.gossip() 104 + .subscribe_and_join(topic, bootstrap_peers) 105 + .await 106 + .map_err(|e| SessionError::Subscribe(Box::new(e)))? 107 + .split() 108 + }; 109 110 tracing::info!("CollabSession: subscribed to gossip topic"); 111