···201 result
202 }
20300000000000000000204 /// Remove text range and record edit info for incremental rendering.
205 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
206 let content = self.text.to_string();
···201 result
202 }
203204+ /// Push text to end of document. Faster than insert for appending.
205+ pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
206+ let pos = self.text.len_unicode();
207+ let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
208+ let result = self.text.push_str(text);
209+ let len_after = self.text.len_unicode();
210+ self.last_edit = Some(EditInfo {
211+ edit_char_pos: pos,
212+ inserted_len: text.chars().count(),
213+ deleted_len: 0,
214+ contains_newline: text.contains('\n'),
215+ in_block_syntax_zone,
216+ doc_len_after: len_after,
217+ });
218+ result
219+ }
220+221 /// Remove text range and record edit info for incremental rendering.
222 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
223 let content = self.text.to_string();
+321-40
crates/weaver-app/src/components/editor/mod.rs
···42/// - LocalStorage auto-save with debouncing
43/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
44///
45-/// # Phase 1 Limitations
46/// - Cursor jumps to end after each keystroke (acceptable for MVP)
47-/// - All formatting characters visible (no hiding based on cursor position)
48/// - No proper grapheme cluster handling
49-/// - No IME composition support
50-/// - No undo/redo
51/// - No selection with Shift+Arrow
52-/// - No mouse selection
53#[component]
54pub fn MarkdownEditor(initial_content: Option<String>) -> Element {
55 // Try to restore from localStorage (includes CRDT state for undo history)
···101 // Update DOM when paragraphs change (incremental rendering)
102 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
103 use_effect(move || {
00104 // Read document once to avoid multiple borrows
105 let doc = document();
0000000000000106 let cursor_offset = doc.cursor.offset;
107 let selection = doc.selection;
108 drop(doc); // Release borrow before other operations
···124 // Use requestAnimationFrame to wait for browser paint
125 if let Some(window) = web_sys::window() {
126 let closure = Closure::once(move || {
127- if let Err(e) =
128- cursor::restore_cursor_position(cursor_offset, &map, editor_id)
129 {
130 tracing::warn!("Cursor restoration failed: {:?}", e);
131 }
···140 cached_paragraphs.set(new_paras.clone());
141142 // Update syntax visibility after DOM changes
143- update_syntax_visibility(
144- cursor_offset,
145- selection.as_ref(),
146- &spans,
147- &new_paras,
148- );
149 });
150151 // Track last saved frontiers to detect changes (peek-only, no subscriptions)
···170171 if needs_save {
172 // Sync cursor and extract data for save
173- let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| {
174- doc.sync_loro_cursor();
175- (
176- doc.to_string(),
177- doc.cursor.offset,
178- doc.loro_cursor().cloned(),
179- doc.export_snapshot(),
180- )
181- });
0182183 use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set
184 let snapshot_b64 = if snapshot_bytes.is_empty() {
···220 // DOM populated via web-sys in use_effect for incremental updates
221222 onkeydown: move |evt| {
000000000000000000223 // Only prevent default for operations that modify content
224 // Let browser handle arrow keys, Home/End naturally
225 if should_intercept_key(&evt) {
···279 oncopy: move |evt| {
280 handle_copy(evt, &document);
281 },
00000000000000000000000000000000000000000000000000000000000000000000000000000000000282 }
283284···306 // Handle Ctrl/Cmd shortcuts
307 if mods.ctrl() || mods.meta() {
308 if let Key::Character(ch) = &key {
309- // Intercept our shortcuts: formatting (b/i), undo/redo (z/y)
310- return matches!(ch.as_str(), "b" | "i" | "z" | "y");
0000311 }
312 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
313 return false;
···536537/// Handle paste events and insert text at cursor
538fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
00539 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
540 {
541 use dioxus::web::WebEventExt;
···544 let base_evt = evt.as_web_event();
545 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
546 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
547- if let Ok(text) = data_transfer.get_data("text/plain") {
0000000548 document.with_mut(|doc| {
549 // Delete selection if present
550 if let Some(sel) = doc.selection {
···568569/// Handle cut events - extract text, write to clipboard, then delete
570fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
00571 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
572 {
573 use dioxus::web::WebEventExt;
···575576 let base_evt = evt.as_web_event();
577 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
578- document.with_mut(|doc| {
579 if let Some(sel) = doc.selection {
580 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
581 if start != end {
582- // Extract text
583 let selected_text = doc.slice(start, end).unwrap_or_default();
000584585- // Write to clipboard BEFORE deleting
586 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
587- if let Err(e) = data_transfer.set_data("text/plain", &selected_text) {
588 tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e);
589 }
590 }
···593 let _ = doc.remove_tracked(start, end.saturating_sub(start));
594 doc.cursor.offset = start;
595 doc.selection = None;
00596 }
597 }
0598 });
000000000599 }
600 }
601···626 .replace('\u{200C}', "")
627 .replace('\u{200B}', "");
628629- // Write to clipboard
630 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
631 if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
632 tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e);
633 }
634 }
63500000000636 // Prevent browser's default copy (which would copy rendered HTML)
637 evt.prevent_default();
638 }
···646 }
647}
6480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000649/// Extract a slice of text from a string by char indices
650fn extract_text_slice(text: &str, start: usize, end: usize) -> String {
651- text.chars().skip(start).take(end.saturating_sub(start)).collect()
000652}
653654/// Handle keyboard events and update document state
···697 doc.selection = None;
698 return;
699 }
000000000000000000000700 _ => {}
701 }
702 }
···707 let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch);
708 doc.cursor.offset = start + ch.chars().count();
709 } else {
710- let _ = doc.insert_tracked(doc.cursor.offset, &ch);
711- doc.cursor.offset += ch.chars().count();
00000000000000000000712 }
713 }
714···758 }
759760 // Delete from where we stopped to end (including any trailing zero-width)
761- let _ = doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start));
0762 doc.cursor.offset = delete_start;
763 } else {
764 // Normal backspace - delete one char
···809 let delete_end = (line_end + 1).min(doc.len_chars());
810811 // Use replace_tracked to atomically delete line and insert paragraph break
812- let _ = doc.replace_tracked(line_start, delete_end.saturating_sub(line_start), "\n\n\u{200C}\n");
0000813 doc.cursor.offset = line_start + 2;
814 } else {
815 // Non-empty item - continue list
···910/// Check if the current list item is empty (just the marker, no content after cursor).
911///
912/// Used to determine whether Enter should continue the list or exit it.
913-fn is_list_item_empty(
914- text: &loro::LoroText,
915- cursor_offset: usize,
916- ctx: &ListContext,
917-) -> bool {
918 let line_start = find_line_start(text, cursor_offset);
919 let line_end = find_line_end(text, cursor_offset);
920
···42/// - LocalStorage auto-save with debouncing
43/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
44///
45+/// # Phase 1 Limitations (mostly resolved)
46/// - Cursor jumps to end after each keystroke (acceptable for MVP)
47+/// - All formatting characters visible (no hiding based on cursor position) - RESOLVED
48/// - No proper grapheme cluster handling
49+/// - No undo/redo - RESOLVED (Loro UndoManager)
050/// - No selection with Shift+Arrow
51+/// - No mouse selection - RESOLVED
52#[component]
53pub fn MarkdownEditor(initial_content: Option<String>) -> Element {
54 // Try to restore from localStorage (includes CRDT state for undo history)
···100 // Update DOM when paragraphs change (incremental rendering)
101 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
102 use_effect(move || {
103+ tracing::info!("DOM update effect triggered");
104+105 // Read document once to avoid multiple borrows
106 let doc = document();
107+108+ tracing::info!(
109+ composition_active = doc.composition.is_some(),
110+ cursor = doc.cursor.offset,
111+ "DOM update: checking state"
112+ );
113+114+ // Skip DOM updates during IME composition - browser controls the preview
115+ if doc.composition.is_some() {
116+ tracing::info!("skipping DOM update during composition");
117+ return;
118+ }
119+120 let cursor_offset = doc.cursor.offset;
121 let selection = doc.selection;
122 drop(doc); // Release borrow before other operations
···138 // Use requestAnimationFrame to wait for browser paint
139 if let Some(window) = web_sys::window() {
140 let closure = Closure::once(move || {
141+ if let Err(e) = cursor::restore_cursor_position(cursor_offset, &map, editor_id)
0142 {
143 tracing::warn!("Cursor restoration failed: {:?}", e);
144 }
···153 cached_paragraphs.set(new_paras.clone());
154155 // Update syntax visibility after DOM changes
156+ update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras);
00000157 });
158159 // Track last saved frontiers to detect changes (peek-only, no subscriptions)
···178179 if needs_save {
180 // Sync cursor and extract data for save
181+ let (content, cursor_offset, loro_cursor, snapshot_bytes) =
182+ document.with_mut(|doc| {
183+ doc.sync_loro_cursor();
184+ (
185+ doc.to_string(),
186+ doc.cursor.offset,
187+ doc.loro_cursor().cloned(),
188+ doc.export_snapshot(),
189+ )
190+ });
191192 use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set
193 let snapshot_b64 = if snapshot_bytes.is_empty() {
···229 // DOM populated via web-sys in use_effect for incremental updates
230231 onkeydown: move |evt| {
232+ use dioxus::prelude::keyboard_types::Key;
233+234+ // During IME composition, let browser handle everything
235+ // Exception: Escape cancels composition
236+ if document.peek().composition.is_some() {
237+ tracing::info!(
238+ key = ?evt.key(),
239+ "keydown during composition - delegating to browser"
240+ );
241+ if evt.key() == Key::Escape {
242+ tracing::info!("Escape pressed - cancelling composition");
243+ document.with_mut(|doc| {
244+ doc.composition = None;
245+ });
246+ }
247+ return;
248+ }
249+250 // Only prevent default for operations that modify content
251 // Let browser handle arrow keys, Home/End naturally
252 if should_intercept_key(&evt) {
···306 oncopy: move |evt| {
307 handle_copy(evt, &document);
308 },
309+310+ onblur: move |_| {
311+ // Cancel any in-progress IME composition on focus loss
312+ let had_composition = document.peek().composition.is_some();
313+ if had_composition {
314+ tracing::info!("onblur: clearing active composition");
315+ }
316+ document.with_mut(|doc| {
317+ doc.composition = None;
318+ });
319+ },
320+321+ oncompositionstart: move |evt: CompositionEvent| {
322+ let data = evt.data().data();
323+ tracing::info!(
324+ data = %data,
325+ "compositionstart"
326+ );
327+ document.with_mut(|doc| {
328+ // Delete selection if present (composition replaces it)
329+ if let Some(sel) = doc.selection.take() {
330+ let (start, end) =
331+ (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
332+ tracing::info!(
333+ start,
334+ end,
335+ "compositionstart: deleting selection"
336+ );
337+ let _ = doc.remove_tracked(start, end.saturating_sub(start));
338+ doc.cursor.offset = start;
339+ }
340+341+ tracing::info!(
342+ cursor = doc.cursor.offset,
343+ "compositionstart: setting composition state"
344+ );
345+ doc.composition = Some(CompositionState {
346+ start_offset: doc.cursor.offset,
347+ text: data,
348+ });
349+ });
350+ },
351+352+ oncompositionupdate: move |evt: CompositionEvent| {
353+ let data = evt.data().data();
354+ tracing::info!(
355+ data = %data,
356+ "compositionupdate"
357+ );
358+ document.with_mut(|doc| {
359+ if let Some(ref mut comp) = doc.composition {
360+ comp.text = data;
361+ } else {
362+ tracing::info!("compositionupdate without active composition state");
363+ }
364+ });
365+ },
366+367+ oncompositionend: move |evt: CompositionEvent| {
368+ let final_text = evt.data().data();
369+ tracing::info!(
370+ data = %final_text,
371+ "compositionend"
372+ );
373+ document.with_mut(|doc| {
374+ if let Some(comp) = doc.composition.take() {
375+ tracing::info!(
376+ start_offset = comp.start_offset,
377+ final_text = %final_text,
378+ chars = final_text.chars().count(),
379+ "compositionend: inserting text"
380+ );
381+382+ if !final_text.is_empty() {
383+ let _ = doc.insert_tracked(comp.start_offset, &final_text);
384+ doc.cursor.offset =
385+ comp.start_offset + final_text.chars().count();
386+ }
387+ } else {
388+ tracing::info!("compositionend without active composition state");
389+ }
390+ });
391+ },
392 }
393394···416 // Handle Ctrl/Cmd shortcuts
417 if mods.ctrl() || mods.meta() {
418 if let Key::Character(ch) = &key {
419+ // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e)
420+ match ch.as_str() {
421+ "b" | "i" | "z" | "y" => return true,
422+ "e" => return true, // Ctrl+E for HTML export/copy
423+ _ => {}
424+ }
425 }
426 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
427 return false;
···650651/// Handle paste events and insert text at cursor
652fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
653+ evt.prevent_default();
654+655 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
656 {
657 use dioxus::web::WebEventExt;
···660 let base_evt = evt.as_web_event();
661 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
662 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
663+ // Try our custom type first (internal paste), fall back to text/plain
664+ let text = data_transfer
665+ .get_data("text/x-weaver-md")
666+ .ok()
667+ .filter(|s| !s.is_empty())
668+ .or_else(|| data_transfer.get_data("text/plain").ok());
669+670+ if let Some(text) = text {
671 document.with_mut(|doc| {
672 // Delete selection if present
673 if let Some(sel) = doc.selection {
···691692/// Handle cut events - extract text, write to clipboard, then delete
693fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
694+ evt.prevent_default();
695+696 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
697 {
698 use dioxus::web::WebEventExt;
···700701 let base_evt = evt.as_web_event();
702 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
703+ let cut_text = document.with_mut(|doc| {
704 if let Some(sel) = doc.selection {
705 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
706 if start != end {
707+ // Extract text and strip zero-width chars
708 let selected_text = doc.slice(start, end).unwrap_or_default();
709+ let clean_text = selected_text
710+ .replace('\u{200C}', "")
711+ .replace('\u{200B}', "");
712713+ // Write to clipboard BEFORE deleting (sync fallback)
714 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
715+ if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
716 tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e);
717 }
718 }
···721 let _ = doc.remove_tracked(start, end.saturating_sub(start));
722 doc.cursor.offset = start;
723 doc.selection = None;
724+725+ return Some(clean_text);
726 }
727 }
728+ None
729 });
730+731+ // Async: also write custom MIME type for internal paste detection
732+ if let Some(text) = cut_text {
733+ wasm_bindgen_futures::spawn_local(async move {
734+ if let Err(e) = write_clipboard_with_custom_type(&text).await {
735+ tracing::debug!("[CUT] Async clipboard write failed: {:?}", e);
736+ }
737+ });
738+ }
739 }
740 }
741···766 .replace('\u{200C}', "")
767 .replace('\u{200B}', "");
768769+ // Sync fallback: write text/plain via DataTransfer
770 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
771 if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
772 tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e);
773 }
774 }
775776+ // Async: also write custom MIME type for internal paste detection
777+ let text_for_async = clean_text.clone();
778+ wasm_bindgen_futures::spawn_local(async move {
779+ if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await {
780+ tracing::debug!("[COPY] Async clipboard write failed: {:?}", e);
781+ }
782+ });
783+784 // Prevent browser's default copy (which would copy rendered HTML)
785 evt.prevent_default();
786 }
···794 }
795}
796797+/// Copy markdown as rendered HTML to clipboard
798+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
799+async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> {
800+ use js_sys::Array;
801+ use wasm_bindgen::JsValue;
802+ use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
803+804+ // Render markdown to HTML using ClientWriter
805+ let parser = markdown_weaver::Parser::new(markdown).into_offset_iter();
806+ let mut html = String::new();
807+ weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(
808+ parser.map(|(evt, _range)| evt),
809+ &mut html,
810+ )
811+ .run()
812+ .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?;
813+814+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
815+ let clipboard = window.navigator().clipboard();
816+817+ // Create blobs for both HTML and plain text (raw HTML for inspection)
818+ let parts = Array::new();
819+ parts.push(&JsValue::from_str(&html));
820+821+ let mut html_opts = BlobPropertyBag::new();
822+ html_opts.type_("text/html");
823+ let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?;
824+825+ let mut text_opts = BlobPropertyBag::new();
826+ text_opts.type_("text/plain");
827+ let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?;
828+829+ // Create ClipboardItem with both types
830+ let item_data = js_sys::Object::new();
831+ js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
832+ js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
833+834+ let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
835+ let items = Array::new();
836+ items.push(&clipboard_item);
837+838+ wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
839+ tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len());
840+ Ok(())
841+}
842+843+/// Write text to clipboard with both text/plain and custom MIME type
844+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
845+async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> {
846+ use js_sys::{Array, Object, Reflect};
847+ use wasm_bindgen::JsValue;
848+ use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
849+850+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
851+ let navigator = window.navigator();
852+ let clipboard = navigator.clipboard();
853+854+ // Create blobs for each MIME type
855+ let text_parts = Array::new();
856+ text_parts.push(&JsValue::from_str(text));
857+858+ let mut text_opts = BlobPropertyBag::new();
859+ text_opts.type_("text/plain");
860+ let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?;
861+862+ let mut custom_opts = BlobPropertyBag::new();
863+ custom_opts.type_("text/x-weaver-md");
864+ let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?;
865+866+ // Create ClipboardItem with both types
867+ let item_data = Object::new();
868+ Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
869+ Reflect::set(
870+ &item_data,
871+ &JsValue::from_str("text/x-weaver-md"),
872+ &custom_blob,
873+ )?;
874+875+ let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
876+ let items = Array::new();
877+ items.push(&clipboard_item);
878+879+ let promise = clipboard.write(&items);
880+ wasm_bindgen_futures::JsFuture::from(promise).await?;
881+882+ Ok(())
883+}
884+885/// Extract a slice of text from a string by char indices
886fn extract_text_slice(text: &str, start: usize, end: usize) -> String {
887+ text.chars()
888+ .skip(start)
889+ .take(end.saturating_sub(start))
890+ .collect()
891}
892893/// Handle keyboard events and update document state
···936 doc.selection = None;
937 return;
938 }
939+ "e" => {
940+ // Ctrl+E = copy as HTML (export)
941+ if let Some(sel) = doc.selection {
942+ let (start, end) =
943+ (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
944+ if start != end {
945+ if let Some(markdown) = doc.slice(start, end) {
946+ let clean_md = markdown
947+ .replace('\u{200C}', "")
948+ .replace('\u{200B}', "");
949+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
950+ wasm_bindgen_futures::spawn_local(async move {
951+ if let Err(e) = copy_as_html(&clean_md).await {
952+ tracing::warn!("[COPY HTML] Failed: {:?}", e);
953+ }
954+ });
955+ }
956+ }
957+ }
958+ return;
959+ }
960 _ => {}
961 }
962 }
···967 let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch);
968 doc.cursor.offset = start + ch.chars().count();
969 } else {
970+ // Clean up any preceding zero-width chars (gap scaffolding)
971+ let mut delete_start = doc.cursor.offset;
972+ while delete_start > 0 {
973+ match get_char_at(doc.loro_text(), delete_start - 1) {
974+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
975+ _ => break,
976+ }
977+ }
978+979+ let zw_count = doc.cursor.offset - delete_start;
980+ if zw_count > 0 {
981+ // Splice: delete zero-width chars and insert new char in one op
982+ let _ = doc.replace_tracked(delete_start, zw_count, &ch);
983+ doc.cursor.offset = delete_start + ch.chars().count();
984+ } else if doc.cursor.offset == doc.len_chars() {
985+ // Fast path: append at end
986+ let _ = doc.push_tracked(&ch);
987+ doc.cursor.offset += ch.chars().count();
988+ } else {
989+ let _ = doc.insert_tracked(doc.cursor.offset, &ch);
990+ doc.cursor.offset += ch.chars().count();
991+ }
992 }
993 }
994···1038 }
10391040 // Delete from where we stopped to end (including any trailing zero-width)
1041+ let _ = doc
1042+ .remove_tracked(delete_start, delete_end.saturating_sub(delete_start));
1043 doc.cursor.offset = delete_start;
1044 } else {
1045 // Normal backspace - delete one char
···1090 let delete_end = (line_end + 1).min(doc.len_chars());
10911092 // Use replace_tracked to atomically delete line and insert paragraph break
1093+ let _ = doc.replace_tracked(
1094+ line_start,
1095+ delete_end.saturating_sub(line_start),
1096+ "\n\n\u{200C}\n",
1097+ );
1098 doc.cursor.offset = line_start + 2;
1099 } else {
1100 // Non-empty item - continue list
···1195/// Check if the current list item is empty (just the marker, no content after cursor).
1196///
1197/// Used to determine whether Enter should continue the list or exit it.
1198+fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool {
00001199 let line_start = find_line_start(text, cursor_offset);
1200 let line_end = find_line_end(text, cursor_offset);
1201