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