···5566use base64::{Engine, engine::general_purpose::STANDARD};
77use dioxus::prelude::*;
88-use jacquard::bytes::Bytes;
88+use jacquard::IntoStatic;
99+use jacquard::cowstr::ToCowStr;
1010+use jacquard::types::ident::AtIdentifier;
1111+use jacquard::{bytes::Bytes, types::tid::Tid};
912use mime_sniffer::MimeTypeSniffer;
1313+1414+use super::document::SignalEditorDocument;
1515+use crate::auth::AuthState;
1616+use crate::fetch::Fetcher;
1717+use weaver_api::sh_weaver::embed::images::Image;
1818+use weaver_editor_core::{EditorDocument, EditorImageResolver};
10191120use crate::components::{
1221 button::{Button, ButtonVariant},
···175184 }
176185 }
177186}
187187+188188+/// Handle an uploaded image: add to resolver, insert markdown, and upload to PDS.
189189+///
190190+/// This is the main handler for when an image is confirmed via the upload dialog.
191191+/// It:
192192+/// 1. Creates a data URL for immediate preview
193193+/// 2. Adds to the image resolver for display
194194+/// 3. Inserts markdown image syntax at cursor
195195+/// 4. If authenticated, uploads to PDS in background
196196+#[allow(clippy::too_many_arguments)]
197197+pub fn handle_image_upload(
198198+ uploaded: UploadedImage,
199199+ doc: &mut SignalEditorDocument,
200200+ image_resolver: &mut Signal<EditorImageResolver>,
201201+ auth_state: &Signal<AuthState>,
202202+ fetcher: &Fetcher,
203203+) {
204204+ // Build data URL for immediate preview.
205205+ let data_url = format!(
206206+ "data:{};base64,{}",
207207+ uploaded.mime_type,
208208+ STANDARD.encode(&uploaded.data)
209209+ );
210210+211211+ // Add to resolver for immediate display.
212212+ let name = uploaded.name.clone();
213213+ image_resolver.with_mut(|resolver| {
214214+ resolver.add_pending(name.clone(), data_url);
215215+ });
216216+217217+ // Insert markdown image syntax at cursor.
218218+ let alt_text = if uploaded.alt.is_empty() {
219219+ name.clone()
220220+ } else {
221221+ uploaded.alt.clone()
222222+ };
223223+224224+ // Check if authenticated and get DID for draft path.
225225+ let auth = auth_state.read();
226226+ let did_for_path = auth.did.clone();
227227+ let is_authenticated = auth.is_authenticated();
228228+ drop(auth);
229229+230230+ // Pre-generate TID for the blob rkey (used in draft path and upload).
231231+ let blob_tid = jacquard::types::tid::Ticker::new().next(None);
232232+233233+ // Build markdown with proper draft path if authenticated.
234234+ let markdown = if let Some(ref did) = did_for_path {
235235+ format!(
236236+ "",
237237+ alt_text,
238238+ did,
239239+ blob_tid.as_str(),
240240+ name
241241+ )
242242+ } else {
243243+ // Fallback for unauthenticated - simple path (won't be publishable anyway).
244244+ format!("", alt_text, name)
245245+ };
246246+247247+ let pos = doc.cursor_offset();
248248+ doc.insert(pos, &markdown);
249249+250250+ // Upload to PDS in background if authenticated.
251251+ if is_authenticated {
252252+ let fetcher = fetcher.clone();
253253+ let name_for_upload = name.clone();
254254+ let alt_for_upload = alt_text.clone();
255255+ let data = uploaded.data.clone();
256256+ let mut doc_for_spawn = doc.clone();
257257+ let mut resolver_for_spawn = *image_resolver;
258258+259259+ spawn(async move {
260260+ upload_image_to_pds(
261261+ &fetcher,
262262+ &mut doc_for_spawn,
263263+ &mut resolver_for_spawn,
264264+ data,
265265+ name_for_upload,
266266+ alt_for_upload,
267267+ blob_tid,
268268+ )
269269+ .await;
270270+ });
271271+ } else {
272272+ tracing::debug!(name = %name, "Image added with data URL (not authenticated)");
273273+ }
274274+}
275275+276276+/// Upload image to PDS and update resolver.
277277+async fn upload_image_to_pds(
278278+ fetcher: &Fetcher,
279279+ doc: &mut SignalEditorDocument,
280280+ image_resolver: &mut Signal<EditorImageResolver>,
281281+ data: Bytes,
282282+ name: String,
283283+ alt: String,
284284+ blob_tid: Tid,
285285+) {
286286+ let client = fetcher.get_client();
287287+ use weaver_common::WeaverExt;
288288+289289+ // Clone data for cache pre-warming.
290290+ #[cfg(feature = "fullstack-server")]
291291+ let data_for_cache = data.clone();
292292+293293+ // Use pre-generated TID as rkey for the blob record.
294294+ let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str())
295295+ .expect("TID is valid record key");
296296+297297+ // Upload blob and create temporary PublishedBlob record.
298298+ match client.publish_blob(data, &name, Some(rkey)).await {
299299+ Ok((strong_ref, published_blob)) => {
300300+ // Get DID from fetcher.
301301+ let did = match fetcher.current_did().await {
302302+ Some(d) => d,
303303+ None => {
304304+ tracing::warn!("No DID available");
305305+ return;
306306+ }
307307+ };
308308+309309+ // Extract rkey from the AT-URI.
310310+ let blob_rkey = match strong_ref.uri.rkey() {
311311+ Some(rkey) => rkey.0.clone().into_static(),
312312+ None => {
313313+ tracing::warn!("No rkey in PublishedBlob URI");
314314+ return;
315315+ }
316316+ };
317317+318318+ let cid = published_blob.upload.blob().cid().clone().into_static();
319319+320320+ let name_for_resolver = name.clone();
321321+ let image = Image::new()
322322+ .alt(alt.to_cowstr())
323323+ .image(published_blob.upload)
324324+ .name(name.to_cowstr())
325325+ .build();
326326+ doc.add_image(&image, Some(&strong_ref.uri));
327327+328328+ // Promote from pending to uploaded in resolver.
329329+ let ident = AtIdentifier::Did(did);
330330+ image_resolver.with_mut(|resolver| {
331331+ resolver.promote_to_uploaded(&name_for_resolver, blob_rkey, ident);
332332+ });
333333+334334+ tracing::info!(name = %name_for_resolver, "Image uploaded to PDS");
335335+336336+ // Pre-warm server cache with blob bytes.
337337+ #[cfg(feature = "fullstack-server")]
338338+ {
339339+ use jacquard::smol_str::ToSmolStr;
340340+ if let Err(e) = crate::data::cache_blob_bytes(
341341+ cid.to_smolstr(),
342342+ Some(name_for_resolver.into()),
343343+ None,
344344+ data_for_cache.into(),
345345+ )
346346+ .await
347347+ {
348348+ tracing::warn!(error = %e, "Failed to pre-warm blob cache");
349349+ }
350350+ }
351351+352352+ // Suppress unused variable warning when fullstack-server is disabled.
353353+ #[cfg(not(feature = "fullstack-server"))]
354354+ let _ = cid;
355355+ }
356356+ Err(e) => {
357357+ tracing::error!(error = %e, "Failed to upload image");
358358+ // Image stays as data URL - will work for preview but not publish.
359359+ }
360360+ }
361361+}
+289
crates/weaver-app/src/components/editor/sync.rs
···1010use std::collections::HashMap;
11111212use super::document::{LoadedDocState, SignalEditorDocument};
1313+use super::publish::load_entry_for_editing;
1314use crate::fetch::Fetcher;
1415use jacquard::IntoStatic;
1616+use jacquard::identity::resolver::IdentityResolver;
1517use jacquard::prelude::*;
1818+use jacquard::smol_str::{SmolStr, ToSmolStr};
1619use jacquard::types::ident::AtIdentifier;
1720use jacquard::types::string::{AtUri, Cid};
1821use loro::LoroDoc;
···905908 }
906909 }
907910}
911911+912912+// === Editor state loading ===
913913+914914+/// Result of loading editor state.
915915+#[derive(Clone)]
916916+pub enum LoadEditorResult {
917917+ /// Document state loaded successfully.
918918+ Loaded(LoadedDocState),
919919+ /// Loading failed with error message.
920920+ Failed(String),
921921+}
922922+923923+/// Load editor state from various sources.
924924+///
925925+/// This function handles the complete loading flow:
926926+/// 1. Resolves notebook title to URI if provided
927927+/// 2. Tries to load and merge from localStorage + PDS edit state
928928+/// 3. Falls back to loading entry content if no edit state exists
929929+/// 4. Creates new document with initial content if nothing exists
930930+///
931931+/// # Arguments
932932+/// - `fetcher`: The fetcher for API calls
933933+/// - `draft_key`: Unique key for localStorage (entry URI or "new:{tid}")
934934+/// - `entry_uri`: Optional AT-URI of existing entry to edit
935935+/// - `initial_content`: Optional initial markdown for new entries
936936+/// - `target_notebook`: Optional notebook title to resolve to URI
937937+pub async fn load_editor_state(
938938+ fetcher: &Fetcher,
939939+ draft_key: &str,
940940+ entry_uri: Option<&AtUri<'static>>,
941941+ initial_content: Option<&str>,
942942+ target_notebook: Option<&str>,
943943+) -> LoadEditorResult {
944944+ // Resolve target_notebook to a URI if provided.
945945+ let notebook_uri: Option<SmolStr> = if let Some(title) = target_notebook {
946946+ if let Some(did) = fetcher.current_did().await {
947947+ let ident = AtIdentifier::Did(did);
948948+ match fetcher.get_notebook(ident, title.into()).await {
949949+ Ok(Some(notebook_data)) => Some(notebook_data.0.uri.to_smolstr()),
950950+ Ok(None) | Err(_) => {
951951+ tracing::debug!("Could not resolve notebook '{}' to URI", title);
952952+ None
953953+ }
954954+ }
955955+ } else {
956956+ None
957957+ }
958958+ } else {
959959+ None
960960+ };
961961+962962+ match load_and_merge_document(fetcher, draft_key, entry_uri).await {
963963+ Ok(Some(mut state)) => {
964964+ tracing::debug!("Loaded merged document state");
965965+ if state.notebook_uri.is_none() {
966966+ state.notebook_uri = notebook_uri;
967967+ }
968968+ return LoadEditorResult::Loaded(state);
969969+ }
970970+ Ok(None) => {
971971+ // No existing state - check if we need to load entry content.
972972+ if let Some(uri) = entry_uri {
973973+ // Check that this entry belongs to the current user.
974974+ if let Some(current_did) = fetcher.current_did().await {
975975+ let entry_authority = uri.authority();
976976+ let is_own_entry = match entry_authority {
977977+ AtIdentifier::Did(did) => did == ¤t_did,
978978+ AtIdentifier::Handle(handle) => {
979979+ match fetcher.client.resolve_handle(handle).await {
980980+ Ok(resolved_did) => resolved_did == current_did,
981981+ Err(_) => false,
982982+ }
983983+ }
984984+ };
985985+ if !is_own_entry {
986986+ tracing::warn!(
987987+ "Cannot edit entry belonging to another user: {}",
988988+ entry_authority
989989+ );
990990+ return LoadEditorResult::Failed(
991991+ "You can only edit your own entries".to_string(),
992992+ );
993993+ }
994994+ }
995995+996996+ match load_entry_for_editing(fetcher, uri).await {
997997+ Ok(loaded) => {
998998+ return LoadEditorResult::Loaded(
999999+ create_state_from_entry(fetcher, &loaded, uri, notebook_uri).await,
10001000+ );
10011001+ }
10021002+ Err(e) => {
10031003+ tracing::error!("Failed to load entry: {}", e);
10041004+ return LoadEditorResult::Failed(e.to_string());
10051005+ }
10061006+ }
10071007+ }
10081008+10091009+ // New document with initial content.
10101010+ let doc = LoroDoc::new();
10111011+ if let Some(content) = initial_content {
10121012+ let text = doc.get_text("content");
10131013+ text.insert(0, content).ok();
10141014+ doc.commit();
10151015+ }
10161016+10171017+ LoadEditorResult::Loaded(LoadedDocState {
10181018+ doc,
10191019+ entry_ref: None,
10201020+ edit_root: None,
10211021+ last_diff: None,
10221022+ synced_version: None,
10231023+ last_seen_diffs: HashMap::new(),
10241024+ resolved_content: weaver_common::ResolvedContent::default(),
10251025+ notebook_uri,
10261026+ })
10271027+ }
10281028+ Err(e) => {
10291029+ tracing::error!("Failed to load document state: {}", e);
10301030+ LoadEditorResult::Failed(e.to_string())
10311031+ }
10321032+ }
10331033+}
10341034+10351035+/// Create LoadedDocState from a loaded entry.
10361036+///
10371037+/// Handles:
10381038+/// - Creating LoroDoc and populating with entry data
10391039+/// - Restoring embeds (images and records)
10401040+/// - Pre-warming blob cache (server feature)
10411041+/// - Pre-fetching embed content for initial render
10421042+async fn create_state_from_entry(
10431043+ fetcher: &Fetcher,
10441044+ loaded: &super::publish::LoadedEntry,
10451045+ uri: &AtUri<'_>,
10461046+ notebook_uri: Option<SmolStr>,
10471047+) -> LoadedDocState {
10481048+ let doc = LoroDoc::new();
10491049+ let content = doc.get_text("content");
10501050+ let title = doc.get_text("title");
10511051+ let path = doc.get_text("path");
10521052+ let tags = doc.get_list("tags");
10531053+10541054+ content.insert(0, loaded.entry.content.as_ref()).ok();
10551055+ title.insert(0, loaded.entry.title.as_ref()).ok();
10561056+ path.insert(0, loaded.entry.path.as_ref()).ok();
10571057+ if let Some(ref entry_tags) = loaded.entry.tags {
10581058+ for tag in entry_tags {
10591059+ let tag_str: &str = tag.as_ref();
10601060+ tags.push(tag_str).ok();
10611061+ }
10621062+ }
10631063+10641064+ // Restore existing embeds from the entry.
10651065+ if let Some(ref embeds) = loaded.entry.embeds {
10661066+ let embeds_map = doc.get_map("embeds");
10671067+10681068+ if let Some(ref images) = embeds.images {
10691069+ let images_list = embeds_map
10701070+ .get_or_create_container("images", loro::LoroList::new())
10711071+ .expect("images list");
10721072+ for image in &images.images {
10731073+ let json = serde_json::to_value(image).expect("Image serializes");
10741074+ images_list.push(json).ok();
10751075+ }
10761076+ }
10771077+10781078+ if let Some(ref records) = embeds.records {
10791079+ let records_list = embeds_map
10801080+ .get_or_create_container("records", loro::LoroList::new())
10811081+ .expect("records list");
10821082+ for record in &records.records {
10831083+ let json = serde_json::to_value(record).expect("RecordEmbed serializes");
10841084+ records_list.push(json).ok();
10851085+ }
10861086+ }
10871087+ }
10881088+10891089+ doc.commit();
10901090+10911091+ // Pre-warm blob cache for images.
10921092+ #[cfg(feature = "fullstack-server")]
10931093+ if let Some(ref embeds) = loaded.entry.embeds {
10941094+ if let Some(ref images) = embeds.images {
10951095+ let ident: &str = match uri.authority() {
10961096+ AtIdentifier::Did(d) => d.as_ref(),
10971097+ AtIdentifier::Handle(h) => h.as_ref(),
10981098+ };
10991099+ for image in &images.images {
11001100+ let cid = image.image.blob().cid();
11011101+ let name = image.name.as_ref().map(|n| n.as_ref());
11021102+ if let Err(e) = crate::data::cache_blob(
11031103+ ident.into(),
11041104+ cid.as_ref().into(),
11051105+ name.map(|n| n.into()),
11061106+ )
11071107+ .await
11081108+ {
11091109+ tracing::warn!("Failed to pre-warm blob cache for {}: {}", cid, e);
11101110+ }
11111111+ }
11121112+ }
11131113+ }
11141114+11151115+ // Pre-fetch embeds for initial render.
11161116+ let resolved_content = prefetch_embeds_for_entry(fetcher, &doc, &loaded.entry.embeds).await;
11171117+11181118+ LoadedDocState {
11191119+ doc,
11201120+ entry_ref: Some(loaded.entry_ref.clone()),
11211121+ edit_root: None,
11221122+ last_diff: None,
11231123+ synced_version: None,
11241124+ last_seen_diffs: HashMap::new(),
11251125+ resolved_content,
11261126+ notebook_uri,
11271127+ }
11281128+}
11291129+11301130+/// Pre-fetch embed content for an entry being loaded.
11311131+async fn prefetch_embeds_for_entry(
11321132+ fetcher: &Fetcher,
11331133+ doc: &LoroDoc,
11341134+ embeds: &Option<weaver_api::sh_weaver::notebook::entry::EntryEmbeds<'_>>,
11351135+) -> weaver_common::ResolvedContent {
11361136+ use weaver_common::{ExtractedRef, collect_refs_from_markdown};
11371137+11381138+ let mut resolved_content = weaver_common::ResolvedContent::default();
11391139+11401140+ if let Some(embeds) = embeds {
11411141+ if let Some(ref records) = embeds.records {
11421142+ for record in &records.records {
11431143+ let key_uri = if let Some(ref name) = record.name {
11441144+ match AtUri::new(name.as_ref()) {
11451145+ Ok(uri) => uri.into_static(),
11461146+ Err(_) => continue,
11471147+ }
11481148+ } else {
11491149+ record.record.uri.clone().into_static()
11501150+ };
11511151+11521152+ match weaver_renderer::atproto::fetch_and_render(&record.record.uri, fetcher).await
11531153+ {
11541154+ Ok(html) => {
11551155+ resolved_content.add_embed(key_uri, html, None);
11561156+ }
11571157+ Err(e) => {
11581158+ tracing::warn!("Failed to pre-fetch embed {}: {}", record.record.uri, e);
11591159+ }
11601160+ }
11611161+ }
11621162+ }
11631163+ }
11641164+11651165+ // Fall back to parsing markdown if no embeds in record.
11661166+ if resolved_content.embed_content.is_empty() {
11671167+ let text = doc.get_text("content");
11681168+ let markdown = text.to_string();
11691169+11701170+ if !markdown.is_empty() {
11711171+ tracing::debug!("Falling back to markdown parsing for embeds");
11721172+ let refs = collect_refs_from_markdown(&markdown);
11731173+11741174+ for extracted in refs {
11751175+ if let ExtractedRef::AtEmbed { uri, .. } = extracted {
11761176+ let key_uri = match AtUri::new(&uri) {
11771177+ Ok(u) => u.into_static(),
11781178+ Err(_) => continue,
11791179+ };
11801180+11811181+ match weaver_renderer::atproto::fetch_and_render(&key_uri, fetcher).await {
11821182+ Ok(html) => {
11831183+ tracing::debug!("Pre-fetched embed from markdown: {}", uri);
11841184+ resolved_content.add_embed(key_uri, html, None);
11851185+ }
11861186+ Err(e) => {
11871187+ tracing::warn!("Failed to pre-fetch embed {}: {}", uri, e);
11881188+ }
11891189+ }
11901190+ }
11911191+ }
11921192+ }
11931193+ }
11941194+11951195+ resolved_content
11961196+}
+52
crates/weaver-editor-browser/src/dom_sync.rs
···99 is_valid_cursor_position,
1010};
11111212+use weaver_editor_core::{EditorDocument, Selection, SyntaxSpanInfo};
1313+1214use crate::cursor::restore_cursor_position;
1515+use crate::update_syntax_visibility;
13161417/// Result of syncing cursor from DOM.
1518#[derive(Debug, Clone)]
···370373 }
371374372375 None
376376+}
377377+378378+/// Sync cursor state from DOM to an EditorDocument.
379379+///
380380+/// This is a generic version that works with any `EditorDocument` implementation.
381381+/// It reads the browser's selection state and updates the document's cursor and selection.
382382+pub fn sync_cursor_from_dom<D: EditorDocument>(
383383+ doc: &mut D,
384384+ editor_id: &str,
385385+ paragraphs: &[ParagraphRender],
386386+ direction_hint: Option<SnapDirection>,
387387+) {
388388+ if let Some(result) = sync_cursor_from_dom_impl(editor_id, paragraphs, direction_hint) {
389389+ match result {
390390+ CursorSyncResult::Cursor(offset) => {
391391+ doc.set_cursor_offset(offset);
392392+ doc.set_selection(None);
393393+ }
394394+ CursorSyncResult::Selection { anchor, head } => {
395395+ doc.set_cursor_offset(head);
396396+ if anchor != head {
397397+ doc.set_selection(Some(Selection { anchor, head }));
398398+ } else {
399399+ doc.set_selection(None);
400400+ }
401401+ }
402402+ CursorSyncResult::None => {}
403403+ }
404404+ }
405405+}
406406+407407+/// Sync cursor from DOM and update syntax visibility in one call.
408408+///
409409+/// This is the common pattern used by most event handlers: sync the cursor
410410+/// position from the browser's selection, then update which syntax elements
411411+/// are visible based on the new cursor position.
412412+///
413413+/// Use this for: onclick, onselect, onselectstart, onselectionchange, onkeyup.
414414+pub fn sync_cursor_and_visibility<D: EditorDocument>(
415415+ doc: &mut D,
416416+ editor_id: &str,
417417+ paragraphs: &[ParagraphRender],
418418+ syntax_spans: &[SyntaxSpanInfo],
419419+ direction_hint: Option<SnapDirection>,
420420+) {
421421+ sync_cursor_from_dom(doc, editor_id, paragraphs, direction_hint);
422422+ let cursor_offset = doc.cursor_offset();
423423+ let selection = doc.selection();
424424+ update_syntax_visibility(cursor_offset, selection.as_ref(), syntax_spans, paragraphs);
373425}
374426375427/// Update paragraph DOM elements incrementally.
+108
crates/weaver-editor-browser/src/events.rs
···561561 false
562562 }
563563}
564564+565565+// === Composition (IME) event handlers ===
566566+567567+/// Handle composition start event.
568568+///
569569+/// Clears any existing selection (composition replaces it) and sets up
570570+/// composition state tracking.
571571+#[cfg(feature = "dioxus")]
572572+pub fn handle_compositionstart<D: EditorDocument>(
573573+ evt: dioxus_core::Event<dioxus_html::CompositionData>,
574574+ doc: &mut D,
575575+) {
576576+ let data = evt.data().data();
577577+ tracing::trace!(data = %data, "compositionstart");
578578+579579+ // Delete selection if present (composition replaces it).
580580+ if let Some(sel) = doc.selection() {
581581+ let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
582582+ tracing::trace!(start, end, "compositionstart: deleting selection");
583583+ doc.delete(start..end);
584584+ doc.set_cursor_offset(start);
585585+ doc.set_selection(None);
586586+ }
587587+588588+ let cursor_offset = doc.cursor_offset();
589589+ tracing::trace!(cursor = cursor_offset, "compositionstart: setting composition state");
590590+ doc.set_composition(Some(weaver_editor_core::CompositionState {
591591+ start_offset: cursor_offset,
592592+ text: data,
593593+ }));
594594+}
595595+596596+/// Handle composition update event.
597597+///
598598+/// Updates the composition text as the user types or selects IME suggestions.
599599+#[cfg(feature = "dioxus")]
600600+pub fn handle_compositionupdate<D: EditorDocument>(
601601+ evt: dioxus_core::Event<dioxus_html::CompositionData>,
602602+ doc: &mut D,
603603+) {
604604+ let data = evt.data().data();
605605+ tracing::trace!(data = %data, "compositionupdate");
606606+607607+ if let Some(mut comp) = doc.composition() {
608608+ comp.text = data;
609609+ doc.set_composition(Some(comp));
610610+ } else {
611611+ tracing::debug!("compositionupdate without active composition state");
612612+ }
613613+}
614614+615615+/// Handle composition end event.
616616+///
617617+/// Finalizes the composition by inserting the final text into the document.
618618+/// Also handles zero-width character cleanup that some IMEs leave behind.
619619+#[cfg(feature = "dioxus")]
620620+pub fn handle_compositionend<D: EditorDocument>(
621621+ evt: dioxus_core::Event<dioxus_html::CompositionData>,
622622+ doc: &mut D,
623623+) {
624624+ let final_text = evt.data().data();
625625+ tracing::trace!(data = %final_text, "compositionend");
626626+627627+ // Record when composition ended for Safari timing workaround.
628628+ doc.set_composition_ended_now();
629629+630630+ let comp = doc.composition();
631631+ doc.set_composition(None);
632632+633633+ if let Some(comp) = comp {
634634+ tracing::debug!(
635635+ start_offset = comp.start_offset,
636636+ final_text = %final_text,
637637+ chars = final_text.chars().count(),
638638+ "compositionend: inserting text"
639639+ );
640640+641641+ if !final_text.is_empty() {
642642+ // Clean up zero-width characters that IMEs sometimes leave behind.
643643+ let mut delete_start = comp.start_offset;
644644+ while delete_start > 0 {
645645+ match doc.char_at(delete_start - 1) {
646646+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
647647+ _ => break,
648648+ }
649649+ }
650650+651651+ let cursor_offset = doc.cursor_offset();
652652+ let zw_count = cursor_offset - delete_start;
653653+654654+ if zw_count > 0 {
655655+ // Splice: delete zero-width chars and insert new char in one op.
656656+ doc.replace(delete_start..delete_start + zw_count, &final_text);
657657+ doc.set_cursor_offset(delete_start + final_text.chars().count());
658658+ } else if cursor_offset == doc.len_chars() {
659659+ // Fast path: append at end.
660660+ doc.push(&final_text);
661661+ doc.set_cursor_offset(comp.start_offset + final_text.chars().count());
662662+ } else {
663663+ // Insert at cursor position.
664664+ doc.insert(cursor_offset, &final_text);
665665+ doc.set_cursor_offset(comp.start_offset + final_text.chars().count());
666666+ }
667667+ }
668668+ } else {
669669+ tracing::debug!("compositionend without active composition state");
670670+ }
671671+}