platform-specific bugfixes ported from prosemirror and other analogous tools

Orual 2f2ed57a 0d6ad974

+308 -32
+1
Cargo.lock
··· 9970 9970 "weaver-common", 9971 9971 "weaver-renderer", 9972 9972 "web-sys", 9973 + "web-time", 9973 9974 "webbrowser", 9974 9975 ] 9975 9976
+2 -1
crates/weaver-app/Cargo.toml
··· 51 51 serde_ipld_dagcbor = { version = "0.6" } 52 52 loro = "1.9.1" 53 53 markdown-weaver-escape = { workspace = true } 54 + web-time = "1.1" 54 55 55 56 [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] 56 57 webbrowser = "1.0.6" ··· 65 66 chrono = { version = "0.4", features = ["wasmbind"] } 66 67 wasm-bindgen = "0.2" 67 68 wasm-bindgen-futures = "0.4" 68 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag"] } 69 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions"] } 69 70 js-sys = "0.3" 70 71 gloo-storage = "0.3" 71 72 gloo-timers = "0.3"
+15 -12
crates/weaver-app/src/components/editor/document.rs
··· 2 2 //! 3 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 4 5 - use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager}; 5 + use loro::{ 6 + ExportMode, LoroDoc, LoroResult, LoroText, UndoManager, 7 + cursor::{Cursor, Side}, 8 + }; 6 9 7 10 /// Single source of truth for editor state. 8 11 /// ··· 35 38 /// IME composition state (for Phase 3) 36 39 pub composition: Option<CompositionState>, 37 40 41 + /// Timestamp when the last composition ended. 42 + /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 43 + pub composition_ended_at: Option<web_time::Instant>, 44 + 38 45 /// Most recent edit info for incremental rendering optimization. 39 46 /// Used to determine if we can skip full re-parsing. 40 47 pub last_edit: Option<EditInfo>, ··· 128 135 129 136 // Insert initial content if any 130 137 if !content.is_empty() { 131 - text.insert(0, &content).expect("failed to insert initial content"); 138 + text.insert(0, &content) 139 + .expect("failed to insert initial content"); 132 140 } 133 141 134 142 // Set up undo manager with merge interval for batching keystrokes ··· 150 158 loro_cursor, 151 159 selection: None, 152 160 composition: None, 161 + composition_ended_at: None, 153 162 last_edit: None, 154 163 } 155 164 } ··· 221 230 /// Remove text range and record edit info for incremental rendering. 222 231 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 223 232 let content = self.text.to_string(); 224 - let contains_newline = content 225 - .chars() 226 - .skip(start) 227 - .take(len) 228 - .any(|c| c == '\n'); 233 + let contains_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); 229 234 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 230 235 231 236 let result = self.text.delete(start, len); ··· 243 248 /// Replace text (delete then insert) and record combined edit info. 244 249 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { 245 250 let content = self.text.to_string(); 246 - let delete_has_newline = content 247 - .chars() 248 - .skip(start) 249 - .take(len) 250 - .any(|c| c == '\n'); 251 + let delete_has_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); 251 252 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 252 253 253 254 let len_before = self.text.len_unicode(); ··· 415 416 loro_cursor, 416 417 selection: None, 417 418 composition: None, 419 + composition_ended_at: None, 418 420 last_edit: None, 419 421 } 420 422 } ··· 433 435 new_doc.sync_loro_cursor(); 434 436 new_doc.selection = self.selection; 435 437 new_doc.composition = self.composition.clone(); 438 + new_doc.composition_ended_at = self.composition_ended_at; 436 439 new_doc.last_edit = self.last_edit.clone(); 437 440 new_doc 438 441 }
+155 -6
crates/weaver-app/src/components/editor/mod.rs
··· 9 9 mod formatting; 10 10 mod offset_map; 11 11 mod paragraph; 12 + mod platform; 12 13 mod render; 13 14 mod storage; 14 15 mod toolbar; ··· 29 30 pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; 30 31 31 32 use dioxus::prelude::*; 33 + 34 + use crate::components::record_view::CodeView; 32 35 33 36 /// Main markdown editor component. 34 37 /// ··· 219 222 interval.forget(); 220 223 }); 221 224 225 + // Set up beforeinput listener for iOS/Android virtual keyboard quirks 226 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 227 + use_effect(move || { 228 + use wasm_bindgen::JsCast; 229 + use wasm_bindgen::prelude::*; 230 + 231 + let plat = platform::platform(); 232 + 233 + // Only needed on mobile 234 + if !plat.mobile { 235 + return; 236 + } 237 + 238 + let window = match web_sys::window() { 239 + Some(w) => w, 240 + None => return, 241 + }; 242 + let dom_document = match window.document() { 243 + Some(d) => d, 244 + None => return, 245 + }; 246 + let editor = match dom_document.get_element_by_id(editor_id) { 247 + Some(e) => e, 248 + None => return, 249 + }; 250 + 251 + let mut document_signal = document; 252 + let cached_paras = cached_paragraphs; 253 + 254 + let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 255 + let input_type = evt.input_type(); 256 + tracing::debug!(input_type = %input_type, "beforeinput"); 257 + 258 + let plat = platform::platform(); 259 + 260 + // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak 261 + // without proper keydown events. Handle them here. 262 + if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { 263 + tracing::debug!("iOS: intercepting {} via beforeinput", input_type); 264 + evt.prevent_default(); 265 + 266 + // Handle as Enter key 267 + document_signal.with_mut(|doc| { 268 + if let Some(sel) = doc.selection.take() { 269 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 270 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 271 + doc.cursor.offset = start; 272 + } 273 + 274 + if input_type == "insertLineBreak" { 275 + // Soft break (like Shift+Enter) 276 + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 277 + doc.cursor.offset += 3; 278 + } else { 279 + // Paragraph break 280 + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 281 + doc.cursor.offset += 2; 282 + } 283 + }); 284 + } 285 + 286 + // Android workaround: When swipe keyboard picks a suggestion, 287 + // DOM mutations fire before selection moves. We detect this pattern 288 + // and defer cursor sync. 289 + if plat.android && input_type == "insertText" { 290 + // Check if this might be a suggestion pick (has data that looks like a word) 291 + if let Some(data) = evt.data() { 292 + if data.contains(' ') || data.len() > 3 { 293 + tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 294 + // Defer cursor sync by 20ms to let selection settle 295 + let paras = cached_paras; 296 + let doc_sig = document_signal; 297 + let window = web_sys::window(); 298 + if let Some(window) = window { 299 + let closure = Closure::once(move || { 300 + let paras = paras(); 301 + sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, &paras); 302 + }); 303 + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 304 + closure.as_ref().unchecked_ref(), 305 + 20, 306 + ); 307 + closure.forget(); 308 + } 309 + } 310 + } 311 + } 312 + }) as Box<dyn FnMut(web_sys::InputEvent)>); 313 + 314 + let _ = editor 315 + .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); 316 + closure.forget(); 317 + }); 318 + 222 319 rsx! { 223 320 Stylesheet { href: asset!("/assets/styling/editor.css") } 224 321 div { class: "markdown-editor-container", ··· 236 333 237 334 onkeydown: move |evt| { 238 335 use dioxus::prelude::keyboard_types::Key; 336 + use std::time::Duration; 239 337 240 - // During IME composition, let browser handle everything 241 - // Exception: Escape cancels composition 338 + let plat = platform::platform(); 339 + let mods = evt.modifiers(); 340 + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 341 + 342 + // During IME composition: 343 + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 344 + // - Allow Escape to cancel composition 345 + // - Block text input (let browser handle composition preview) 242 346 if document.peek().composition.is_some() { 243 - tracing::debug!( 244 - key = ?evt.key(), 245 - "keydown during composition - delegating to browser" 246 - ); 247 347 if evt.key() == Key::Escape { 248 348 tracing::debug!("Escape pressed - cancelling composition"); 249 349 document.with_mut(|doc| { 250 350 doc.composition = None; 251 351 }); 352 + return; 252 353 } 354 + 355 + // Allow modifier shortcuts through during composition 356 + if !has_modifier { 357 + tracing::debug!( 358 + key = ?evt.key(), 359 + "keydown during composition - delegating to browser" 360 + ); 361 + return; 362 + } 363 + // Fall through to handle the shortcut 364 + } 365 + 366 + // Safari workaround: After Japanese IME composition ends, both 367 + // compositionend and keydown fire for Enter. Ignore keydown 368 + // within 500ms of composition end to prevent double-newline. 369 + if plat.safari && evt.key() == Key::Enter { 370 + if let Some(ended_at) = document.peek().composition_ended_at { 371 + if ended_at.elapsed() < Duration::from_millis(500) { 372 + tracing::debug!( 373 + "Safari: ignoring Enter within 500ms of compositionend" 374 + ); 375 + return; 376 + } 377 + } 378 + } 379 + 380 + // Android workaround: Chrome Android gets confused by Enter during/after 381 + // composition. Defer Enter handling to onkeypress instead. 382 + if plat.android && evt.key() == Key::Enter { 383 + tracing::debug!("Android: deferring Enter to keypress"); 253 384 return; 254 385 } 255 386 ··· 345 476 ); 346 477 }, 347 478 479 + // Android workaround: Handle Enter in keypress instead of keydown. 480 + // Chrome Android fires confused composition events on Enter in keydown, 481 + // but keypress fires after composition state settles. 482 + onkeypress: move |evt| { 483 + use dioxus::prelude::keyboard_types::Key; 484 + 485 + let plat = platform::platform(); 486 + if plat.android && evt.key() == Key::Enter { 487 + tracing::debug!("Android: handling Enter in keypress"); 488 + evt.prevent_default(); 489 + handle_keydown(evt, &mut document); 490 + } 491 + }, 492 + 348 493 onpaste: move |evt| { 349 494 handle_paste(evt, &mut document); 350 495 }, ··· 421 566 "compositionend" 422 567 ); 423 568 document.with_mut(|doc| { 569 + // Record when composition ended for Safari timing workaround 570 + doc.composition_ended_at = Some(web_time::Instant::now()); 571 + 424 572 if let Some(comp) = doc.composition.take() { 425 573 tracing::debug!( 426 574 start_offset = comp.start_offset, ··· 468 616 } 469 617 } 470 618 } 619 + 471 620 } 472 621 } 473 622
+120
crates/weaver-app/src/components/editor/platform.rs
··· 1 + //! Platform detection for browser-specific workarounds. 2 + //! 3 + //! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm. 4 + 5 + use std::sync::OnceLock; 6 + 7 + /// Cached platform detection results. 8 + #[derive(Debug, Clone)] 9 + pub struct Platform { 10 + pub ios: bool, 11 + pub mac: bool, 12 + pub android: bool, 13 + pub chrome: bool, 14 + pub safari: bool, 15 + pub gecko: bool, 16 + pub webkit_version: Option<u32>, 17 + pub chrome_version: Option<u32>, 18 + pub mobile: bool, 19 + } 20 + 21 + impl Default for Platform { 22 + fn default() -> Self { 23 + Self { 24 + ios: false, 25 + mac: false, 26 + android: false, 27 + chrome: false, 28 + safari: false, 29 + gecko: false, 30 + webkit_version: None, 31 + chrome_version: None, 32 + mobile: false, 33 + } 34 + } 35 + } 36 + 37 + static PLATFORM: OnceLock<Platform> = OnceLock::new(); 38 + 39 + /// Get cached platform info. Detection runs once on first call. 40 + pub fn platform() -> &'static Platform { 41 + PLATFORM.get_or_init(detect_platform) 42 + } 43 + 44 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 45 + fn detect_platform() -> Platform { 46 + let window = match web_sys::window() { 47 + Some(w) => w, 48 + None => return Platform::default(), 49 + }; 50 + 51 + let navigator = window.navigator(); 52 + let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase(); 53 + let platform_str = navigator.platform().unwrap_or_default().to_lowercase(); 54 + 55 + // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch 56 + let ios = user_agent.contains("iphone") 57 + || user_agent.contains("ipad") 58 + || user_agent.contains("ipod") 59 + || (platform_str.contains("mac") && has_touch_support(&navigator)); 60 + 61 + // macOS (but not iOS) 62 + let mac = platform_str.contains("mac") && !ios; 63 + 64 + // Android 65 + let android = user_agent.contains("android"); 66 + 67 + // Chrome (but not Edge, which also contains Chrome) 68 + let chrome = user_agent.contains("chrome") && !user_agent.contains("edg"); 69 + 70 + // Safari (WebKit but not Chrome) 71 + let safari = user_agent.contains("safari") && !user_agent.contains("chrome"); 72 + 73 + // Firefox/Gecko 74 + let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko"); 75 + 76 + // WebKit version extraction 77 + let webkit_version = extract_version(&user_agent, "applewebkit/"); 78 + 79 + // Chrome version extraction 80 + let chrome_version = extract_version(&user_agent, "chrome/"); 81 + 82 + // Mobile detection 83 + let mobile = ios 84 + || android 85 + || user_agent.contains("mobile") 86 + || user_agent.contains("iemobile"); 87 + 88 + Platform { 89 + ios, 90 + mac, 91 + android, 92 + chrome, 93 + safari, 94 + gecko, 95 + webkit_version, 96 + chrome_version, 97 + mobile, 98 + } 99 + } 100 + 101 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 102 + fn has_touch_support(navigator: &web_sys::Navigator) -> bool { 103 + // Check maxTouchPoints > 0 (indicates touch capability) 104 + navigator.max_touch_points() > 0 105 + } 106 + 107 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 108 + fn extract_version(ua: &str, prefix: &str) -> Option<u32> { 109 + ua.find(prefix).and_then(|idx| { 110 + let after = &ua[idx + prefix.len()..]; 111 + // Take digits until non-digit 112 + let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); 113 + version_str.parse().ok() 114 + }) 115 + } 116 + 117 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 118 + fn detect_platform() -> Platform { 119 + Platform::default() 120 + }
+12 -12
crates/weaver-app/src/components/editor/writer.rs
··· 1936 1936 let para_char_start = self.last_char_offset; 1937 1937 1938 1938 // Create a minimal paragraph for the empty blockquote 1939 - // let node_id = self.gen_node_id(); 1940 - // write!(&mut self.writer, "<p id=\"{}\"", node_id)?; 1939 + let node_id = self.gen_node_id(); 1940 + write!(&mut self.writer, "<div id=\"{}\"", node_id)?; 1941 1941 // self.begin_node(node_id.clone()); 1942 1942 1943 1943 // // Record start-of-node mapping for cursor positioning 1944 - // self.offset_maps.push(OffsetMapping { 1945 - // byte_range: para_byte_start..para_byte_start, 1946 - // char_range: para_char_start..para_char_start, 1947 - // node_id: node_id.clone(), 1948 - // char_offset_in_node: 0, 1949 - // child_index: Some(0), 1950 - // utf16_len: 0, 1951 - // }); 1944 + self.offset_maps.push(OffsetMapping { 1945 + byte_range: para_byte_start..para_byte_start, 1946 + char_range: para_char_start..para_char_start, 1947 + node_id: node_id.clone(), 1948 + char_offset_in_node: gt_pos, 1949 + child_index: Some(0), 1950 + utf16_len: 0, 1951 + }); 1952 1952 1953 1953 // Emit the > as block syntax 1954 1954 let syntax = &raw_text[gt_pos..gt_pos + 1]; 1955 1955 self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; 1956 1956 1957 - // self.write("</p>\n")?; 1958 - // self.end_node(); 1957 + self.write("</div>\n")?; 1958 + self.end_node(); 1959 1959 1960 1960 // Record paragraph boundary for incremental rendering 1961 1961 let byte_range = para_byte_start..bq_range.end;
+3 -1
crates/weaver-app/src/views/editor.rs
··· 1 1 //! Editor view - wraps the MarkdownEditor component for the /editor route. 2 2 3 - use crate::components::editor::MarkdownEditor; 3 + use crate::components::{editor::MarkdownEditor, record_view::CodeView}; 4 4 use dioxus::prelude::*; 5 5 6 6 /// Editor page view. ··· 12 12 rsx! { 13 13 EditorCss {} 14 14 div { class: "editor-page", 15 + h1 { style: "margin-left: 6rem;", "Markdown Editor Test" } 15 16 MarkdownEditor { initial_content: None } 17 + 16 18 } 17 19 } 18 20 }