diff --git a/Cargo.lock b/Cargo.lock index 9123762..1f03c63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9970,6 +9970,7 @@ dependencies = [ "weaver-common", "weaver-renderer", "web-sys", + "web-time", "webbrowser", ] diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 11094b1..2da1be7 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -51,6 +51,7 @@ tracing.workspace = true serde_ipld_dagcbor = { version = "0.6" } loro = "1.9.1" markdown-weaver-escape = { workspace = true } +web-time = "1.1" [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] webbrowser = "1.0.6" @@ -65,7 +66,7 @@ mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d0 chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag"] } +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"] } js-sys = "0.3" gloo-storage = "0.3" gloo-timers = "0.3" diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index d89ed33..301ba42 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -2,7 +2,10 @@ //! //! Uses Loro CRDT for text storage with built-in undo/redo support. -use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager}; +use loro::{ + ExportMode, LoroDoc, LoroResult, LoroText, UndoManager, + cursor::{Cursor, Side}, +}; /// Single source of truth for editor state. /// @@ -35,6 +38,10 @@ pub struct EditorDocument { /// IME composition state (for Phase 3) pub composition: Option, + /// Timestamp when the last composition ended. + /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. + pub composition_ended_at: Option, + /// Most recent edit info for incremental rendering optimization. /// Used to determine if we can skip full re-parsing. pub last_edit: Option, @@ -128,7 +135,8 @@ impl EditorDocument { // Insert initial content if any if !content.is_empty() { - text.insert(0, &content).expect("failed to insert initial content"); + text.insert(0, &content) + .expect("failed to insert initial content"); } // Set up undo manager with merge interval for batching keystrokes @@ -150,6 +158,7 @@ impl EditorDocument { loro_cursor, selection: None, composition: None, + composition_ended_at: None, last_edit: None, } } @@ -221,11 +230,7 @@ impl EditorDocument { /// Remove text range and record edit info for incremental rendering. pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { let content = self.text.to_string(); - let contains_newline = content - .chars() - .skip(start) - .take(len) - .any(|c| c == '\n'); + let contains_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); let in_block_syntax_zone = self.is_in_block_syntax_zone(start); let result = self.text.delete(start, len); @@ -243,11 +248,7 @@ impl EditorDocument { /// Replace text (delete then insert) and record combined edit info. pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { let content = self.text.to_string(); - let delete_has_newline = content - .chars() - .skip(start) - .take(len) - .any(|c| c == '\n'); + let delete_has_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); let in_block_syntax_zone = self.is_in_block_syntax_zone(start); let len_before = self.text.len_unicode(); @@ -415,6 +416,7 @@ impl EditorDocument { loro_cursor, selection: None, composition: None, + composition_ended_at: None, last_edit: None, } } @@ -433,6 +435,7 @@ impl Clone for EditorDocument { new_doc.sync_loro_cursor(); new_doc.selection = self.selection; new_doc.composition = self.composition.clone(); + new_doc.composition_ended_at = self.composition_ended_at; new_doc.last_edit = self.last_edit.clone(); new_doc } diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index c39895a..6c069c0 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -9,6 +9,7 @@ mod document; mod formatting; mod offset_map; mod paragraph; +mod platform; mod render; mod storage; mod toolbar; @@ -30,6 +31,8 @@ pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; use dioxus::prelude::*; +use crate::components::record_view::CodeView; + /// Main markdown editor component. /// /// # Props @@ -219,6 +222,100 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { interval.forget(); }); + // Set up beforeinput listener for iOS/Android virtual keyboard quirks + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::*; + + let plat = platform::platform(); + + // Only needed on mobile + if !plat.mobile { + return; + } + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let dom_document = match window.document() { + Some(d) => d, + None => return, + }; + let editor = match dom_document.get_element_by_id(editor_id) { + Some(e) => e, + None => return, + }; + + let mut document_signal = document; + let cached_paras = cached_paragraphs; + + let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { + let input_type = evt.input_type(); + tracing::debug!(input_type = %input_type, "beforeinput"); + + let plat = platform::platform(); + + // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak + // without proper keydown events. Handle them here. + if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { + tracing::debug!("iOS: intercepting {} via beforeinput", input_type); + evt.prevent_default(); + + // Handle as Enter key + document_signal.with_mut(|doc| { + if let Some(sel) = doc.selection.take() { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + } + + if input_type == "insertLineBreak" { + // Soft break (like Shift+Enter) + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); + doc.cursor.offset += 3; + } else { + // Paragraph break + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); + doc.cursor.offset += 2; + } + }); + } + + // Android workaround: When swipe keyboard picks a suggestion, + // DOM mutations fire before selection moves. We detect this pattern + // and defer cursor sync. + if plat.android && input_type == "insertText" { + // Check if this might be a suggestion pick (has data that looks like a word) + if let Some(data) = evt.data() { + if data.contains(' ') || data.len() > 3 { + tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); + // Defer cursor sync by 20ms to let selection settle + let paras = cached_paras; + let doc_sig = document_signal; + let window = web_sys::window(); + if let Some(window) = window { + let closure = Closure::once(move || { + let paras = paras(); + sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, ¶s); + }); + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( + closure.as_ref().unchecked_ref(), + 20, + ); + closure.forget(); + } + } + } + } + }) as Box); + + let _ = editor + .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); + closure.forget(); + }); + rsx! { Stylesheet { href: asset!("/assets/styling/editor.css") } div { class: "markdown-editor-container", @@ -236,20 +333,54 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { onkeydown: move |evt| { use dioxus::prelude::keyboard_types::Key; + use std::time::Duration; + + let plat = platform::platform(); + let mods = evt.modifiers(); + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); - // During IME composition, let browser handle everything - // Exception: Escape cancels composition + // During IME composition: + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) + // - Allow Escape to cancel composition + // - Block text input (let browser handle composition preview) if document.peek().composition.is_some() { - tracing::debug!( - key = ?evt.key(), - "keydown during composition - delegating to browser" - ); if evt.key() == Key::Escape { tracing::debug!("Escape pressed - cancelling composition"); document.with_mut(|doc| { doc.composition = None; }); + return; + } + + // Allow modifier shortcuts through during composition + if !has_modifier { + tracing::debug!( + key = ?evt.key(), + "keydown during composition - delegating to browser" + ); + return; + } + // Fall through to handle the shortcut + } + + // Safari workaround: After Japanese IME composition ends, both + // compositionend and keydown fire for Enter. Ignore keydown + // within 500ms of composition end to prevent double-newline. + if plat.safari && evt.key() == Key::Enter { + if let Some(ended_at) = document.peek().composition_ended_at { + if ended_at.elapsed() < Duration::from_millis(500) { + tracing::debug!( + "Safari: ignoring Enter within 500ms of compositionend" + ); + return; + } } + } + + // Android workaround: Chrome Android gets confused by Enter during/after + // composition. Defer Enter handling to onkeypress instead. + if plat.android && evt.key() == Key::Enter { + tracing::debug!("Android: deferring Enter to keypress"); return; } @@ -345,6 +476,20 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { ); }, + // Android workaround: Handle Enter in keypress instead of keydown. + // Chrome Android fires confused composition events on Enter in keydown, + // but keypress fires after composition state settles. + onkeypress: move |evt| { + use dioxus::prelude::keyboard_types::Key; + + let plat = platform::platform(); + if plat.android && evt.key() == Key::Enter { + tracing::debug!("Android: handling Enter in keypress"); + evt.prevent_default(); + handle_keydown(evt, &mut document); + } + }, + onpaste: move |evt| { handle_paste(evt, &mut document); }, @@ -421,6 +566,9 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { "compositionend" ); document.with_mut(|doc| { + // Record when composition ended for Safari timing workaround + doc.composition_ended_at = Some(web_time::Instant::now()); + if let Some(comp) = doc.composition.take() { tracing::debug!( start_offset = comp.start_offset, @@ -468,6 +616,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } } } + } } diff --git a/crates/weaver-app/src/components/editor/platform.rs b/crates/weaver-app/src/components/editor/platform.rs new file mode 100644 index 0000000..23b5edf --- /dev/null +++ b/crates/weaver-app/src/components/editor/platform.rs @@ -0,0 +1,120 @@ +//! Platform detection for browser-specific workarounds. +//! +//! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm. + +use std::sync::OnceLock; + +/// Cached platform detection results. +#[derive(Debug, Clone)] +pub struct Platform { + pub ios: bool, + pub mac: bool, + pub android: bool, + pub chrome: bool, + pub safari: bool, + pub gecko: bool, + pub webkit_version: Option, + pub chrome_version: Option, + pub mobile: bool, +} + +impl Default for Platform { + fn default() -> Self { + Self { + ios: false, + mac: false, + android: false, + chrome: false, + safari: false, + gecko: false, + webkit_version: None, + chrome_version: None, + mobile: false, + } + } +} + +static PLATFORM: OnceLock = OnceLock::new(); + +/// Get cached platform info. Detection runs once on first call. +pub fn platform() -> &'static Platform { + PLATFORM.get_or_init(detect_platform) +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn detect_platform() -> Platform { + let window = match web_sys::window() { + Some(w) => w, + None => return Platform::default(), + }; + + let navigator = window.navigator(); + let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase(); + let platform_str = navigator.platform().unwrap_or_default().to_lowercase(); + + // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch + let ios = user_agent.contains("iphone") + || user_agent.contains("ipad") + || user_agent.contains("ipod") + || (platform_str.contains("mac") && has_touch_support(&navigator)); + + // macOS (but not iOS) + let mac = platform_str.contains("mac") && !ios; + + // Android + let android = user_agent.contains("android"); + + // Chrome (but not Edge, which also contains Chrome) + let chrome = user_agent.contains("chrome") && !user_agent.contains("edg"); + + // Safari (WebKit but not Chrome) + let safari = user_agent.contains("safari") && !user_agent.contains("chrome"); + + // Firefox/Gecko + let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko"); + + // WebKit version extraction + let webkit_version = extract_version(&user_agent, "applewebkit/"); + + // Chrome version extraction + let chrome_version = extract_version(&user_agent, "chrome/"); + + // Mobile detection + let mobile = ios + || android + || user_agent.contains("mobile") + || user_agent.contains("iemobile"); + + Platform { + ios, + mac, + android, + chrome, + safari, + gecko, + webkit_version, + chrome_version, + mobile, + } +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn has_touch_support(navigator: &web_sys::Navigator) -> bool { + // Check maxTouchPoints > 0 (indicates touch capability) + navigator.max_touch_points() > 0 +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn extract_version(ua: &str, prefix: &str) -> Option { + ua.find(prefix).and_then(|idx| { + let after = &ua[idx + prefix.len()..]; + // Take digits until non-digit + let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + version_str.parse().ok() + }) +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +fn detect_platform() -> Platform { + Platform::default() +} diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 027be85..3d8de67 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -1936,26 +1936,26 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let para_char_start = self.last_char_offset; // Create a minimal paragraph for the empty blockquote - // let node_id = self.gen_node_id(); - // write!(&mut self.writer, "

as block syntax let syntax = &raw_text[gt_pos..gt_pos + 1]; self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; - // self.write("

\n")?; - // self.end_node(); + self.write("\n")?; + self.end_node(); // Record paragraph boundary for incremental rendering let byte_range = para_byte_start..bq_range.end; diff --git a/crates/weaver-app/src/views/editor.rs b/crates/weaver-app/src/views/editor.rs index 0d78480..78a13e1 100644 --- a/crates/weaver-app/src/views/editor.rs +++ b/crates/weaver-app/src/views/editor.rs @@ -1,6 +1,6 @@ //! Editor view - wraps the MarkdownEditor component for the /editor route. -use crate::components::editor::MarkdownEditor; +use crate::components::{editor::MarkdownEditor, record_view::CodeView}; use dioxus::prelude::*; /// Editor page view. @@ -12,7 +12,9 @@ pub fn Editor() -> Element { rsx! { EditorCss {} div { class: "editor-page", + h1 { style: "margin-left: 6rem;", "Markdown Editor Test" } MarkdownEditor { initial_content: None } + } } }