tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
further pulls out of the app crate
Orual
1 month ago
735b7880
9b12c555
+1409
-7
9 changed files
expand all
collapse all
unified
split
Cargo.lock
crates
weaver-editor-browser
Cargo.toml
src
events.rs
lib.rs
weaver-editor-core
src
actions.rs
execute.rs
lib.rs
text_helpers.rs
docs
graph-data.json
+1
Cargo.lock
···
12197
12197
"smol_str",
12198
12198
"tracing",
12199
12199
"wasm-bindgen",
12200
12200
+
"wasm-bindgen-futures",
12200
12201
"wasm-bindgen-test",
12201
12202
"weaver-editor-core",
12202
12203
"web-sys",
+7
crates/weaver-editor-browser/Cargo.toml
···
18
18
# Logging
19
19
tracing = { workspace = true }
20
20
21
21
+
# Async (for JS promise bridging)
22
22
+
wasm-bindgen-futures = "0.4"
23
23
+
21
24
# Utilities
22
25
smol_str = "0.3"
23
26
···
48
51
"DataTransferItemList",
49
52
"FocusEvent",
50
53
"MouseEvent",
54
54
+
"Blob",
55
55
+
"BlobPropertyBag",
56
56
+
"Clipboard",
57
57
+
"ClipboardItem",
51
58
]
52
59
53
60
[features]
+254
-2
crates/weaver-editor-browser/src/events.rs
···
1
1
//! Browser event handling for the editor.
2
2
//!
3
3
-
//! Handles beforeinput, keydown, paste, and other DOM events.
3
3
+
//! Provides browser-specific event extraction and input type parsing for
4
4
+
//! the `beforeinput` event and other DOM events.
4
5
5
5
-
// TODO: Migrate from weaver-app (beforeinput.rs, input.rs)
6
6
+
use wasm_bindgen::prelude::*;
7
7
+
use weaver_editor_core::{InputType, OffsetMapping, Range};
8
8
+
9
9
+
use crate::dom_sync::dom_position_to_text_offset;
10
10
+
11
11
+
// === StaticRange binding ===
12
12
+
//
13
13
+
// Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it.
14
14
+
// StaticRange is returned by InputEvent.getTargetRanges() and represents
15
15
+
// a fixed range that doesn't update when the DOM changes.
16
16
+
17
17
+
#[wasm_bindgen]
18
18
+
extern "C" {
19
19
+
/// The StaticRange interface represents a static range of text in the DOM.
20
20
+
pub type StaticRange;
21
21
+
22
22
+
#[wasm_bindgen(method, getter, structural)]
23
23
+
pub fn startContainer(this: &StaticRange) -> web_sys::Node;
24
24
+
25
25
+
#[wasm_bindgen(method, getter, structural)]
26
26
+
pub fn startOffset(this: &StaticRange) -> u32;
27
27
+
28
28
+
#[wasm_bindgen(method, getter, structural)]
29
29
+
pub fn endContainer(this: &StaticRange) -> web_sys::Node;
30
30
+
31
31
+
#[wasm_bindgen(method, getter, structural)]
32
32
+
pub fn endOffset(this: &StaticRange) -> u32;
33
33
+
34
34
+
#[wasm_bindgen(method, getter, structural)]
35
35
+
pub fn collapsed(this: &StaticRange) -> bool;
36
36
+
}
37
37
+
38
38
+
// === InputType browser parsing ===
39
39
+
40
40
+
/// Parse a browser inputType string to an InputType enum.
41
41
+
///
42
42
+
/// This handles the W3C Input Events inputType values as returned by
43
43
+
/// `InputEvent.inputType` in browsers.
44
44
+
pub fn parse_browser_input_type(s: &str) -> InputType {
45
45
+
match s {
46
46
+
// Insertion
47
47
+
"insertText" => InputType::InsertText,
48
48
+
"insertCompositionText" => InputType::InsertCompositionText,
49
49
+
"insertLineBreak" => InputType::InsertLineBreak,
50
50
+
"insertParagraph" => InputType::InsertParagraph,
51
51
+
"insertFromPaste" => InputType::InsertFromPaste,
52
52
+
"insertFromDrop" => InputType::InsertFromDrop,
53
53
+
"insertReplacementText" => InputType::InsertReplacementText,
54
54
+
"insertFromYank" => InputType::InsertFromYank,
55
55
+
"insertHorizontalRule" => InputType::InsertHorizontalRule,
56
56
+
"insertOrderedList" => InputType::InsertOrderedList,
57
57
+
"insertUnorderedList" => InputType::InsertUnorderedList,
58
58
+
"insertLink" => InputType::InsertLink,
59
59
+
60
60
+
// Deletion
61
61
+
"deleteContentBackward" => InputType::DeleteContentBackward,
62
62
+
"deleteContentForward" => InputType::DeleteContentForward,
63
63
+
"deleteWordBackward" => InputType::DeleteWordBackward,
64
64
+
"deleteWordForward" => InputType::DeleteWordForward,
65
65
+
"deleteSoftLineBackward" => InputType::DeleteSoftLineBackward,
66
66
+
"deleteSoftLineForward" => InputType::DeleteSoftLineForward,
67
67
+
"deleteHardLineBackward" => InputType::DeleteHardLineBackward,
68
68
+
"deleteHardLineForward" => InputType::DeleteHardLineForward,
69
69
+
"deleteByCut" => InputType::DeleteByCut,
70
70
+
"deleteByDrag" => InputType::DeleteByDrag,
71
71
+
"deleteContent" => InputType::DeleteContent,
72
72
+
"deleteEntireSoftLine" => InputType::DeleteSoftLineBackward,
73
73
+
"deleteEntireWordBackward" => InputType::DeleteEntireWordBackward,
74
74
+
"deleteEntireWordForward" => InputType::DeleteEntireWordForward,
75
75
+
76
76
+
// History
77
77
+
"historyUndo" => InputType::HistoryUndo,
78
78
+
"historyRedo" => InputType::HistoryRedo,
79
79
+
80
80
+
// Formatting
81
81
+
"formatBold" => InputType::FormatBold,
82
82
+
"formatItalic" => InputType::FormatItalic,
83
83
+
"formatUnderline" => InputType::FormatUnderline,
84
84
+
"formatStrikethrough" => InputType::FormatStrikethrough,
85
85
+
"formatSuperscript" => InputType::FormatSuperscript,
86
86
+
"formatSubscript" => InputType::FormatSubscript,
87
87
+
88
88
+
// Unknown
89
89
+
other => InputType::Unknown(other.to_string()),
90
90
+
}
91
91
+
}
92
92
+
93
93
+
// === BeforeInput event handling ===
94
94
+
95
95
+
/// Result of handling a beforeinput event.
96
96
+
#[derive(Debug, Clone)]
97
97
+
pub enum BeforeInputResult {
98
98
+
/// Event was handled, prevent default browser behavior.
99
99
+
Handled,
100
100
+
/// Event should be handled by browser (e.g., during composition).
101
101
+
PassThrough,
102
102
+
/// Event was handled but requires async follow-up (e.g., paste).
103
103
+
HandledAsync,
104
104
+
/// Android backspace workaround: defer and check if browser handled it.
105
105
+
DeferredCheck {
106
106
+
/// The action to execute if browser didn't handle it.
107
107
+
fallback_action: weaver_editor_core::EditorAction,
108
108
+
},
109
109
+
}
110
110
+
111
111
+
/// Context for beforeinput handling.
112
112
+
pub struct BeforeInputContext<'a> {
113
113
+
/// The input type.
114
114
+
pub input_type: InputType,
115
115
+
/// The data (text to insert, if any).
116
116
+
pub data: Option<String>,
117
117
+
/// Target range from getTargetRanges(), if available.
118
118
+
pub target_range: Option<Range>,
119
119
+
/// Whether the event is part of an IME composition.
120
120
+
pub is_composing: bool,
121
121
+
/// Whether we're on Android.
122
122
+
pub is_android: bool,
123
123
+
/// Whether we're on Chrome.
124
124
+
pub is_chrome: bool,
125
125
+
/// Offset mappings for the document.
126
126
+
pub offset_map: &'a [OffsetMapping],
127
127
+
}
128
128
+
129
129
+
/// Extract target range from a beforeinput event.
130
130
+
///
131
131
+
/// Uses getTargetRanges() to get the browser's intended range for this operation.
132
132
+
pub fn get_target_range_from_event(
133
133
+
event: &web_sys::InputEvent,
134
134
+
editor_id: &str,
135
135
+
offset_map: &[OffsetMapping],
136
136
+
) -> Option<Range> {
137
137
+
use wasm_bindgen::JsCast;
138
138
+
139
139
+
let ranges = event.get_target_ranges();
140
140
+
if ranges.length() == 0 {
141
141
+
return None;
142
142
+
}
143
143
+
144
144
+
let static_range: StaticRange = ranges.get(0).unchecked_into();
145
145
+
146
146
+
let window = web_sys::window()?;
147
147
+
let dom_document = window.document()?;
148
148
+
let editor_element = dom_document.get_element_by_id(editor_id)?;
149
149
+
150
150
+
let start_container = static_range.startContainer();
151
151
+
let start_offset = static_range.startOffset() as usize;
152
152
+
let end_container = static_range.endContainer();
153
153
+
let end_offset = static_range.endOffset() as usize;
154
154
+
155
155
+
let start = dom_position_to_text_offset(
156
156
+
&dom_document,
157
157
+
&editor_element,
158
158
+
&start_container,
159
159
+
start_offset,
160
160
+
offset_map,
161
161
+
None,
162
162
+
)?;
163
163
+
164
164
+
let end = dom_position_to_text_offset(
165
165
+
&dom_document,
166
166
+
&editor_element,
167
167
+
&end_container,
168
168
+
end_offset,
169
169
+
offset_map,
170
170
+
None,
171
171
+
)?;
172
172
+
173
173
+
Some(Range::new(start, end))
174
174
+
}
175
175
+
176
176
+
/// Get data from a beforeinput event, handling different sources.
177
177
+
pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option<String> {
178
178
+
// First try the data property.
179
179
+
if let Some(data) = event.data() {
180
180
+
if !data.is_empty() {
181
181
+
return Some(data);
182
182
+
}
183
183
+
}
184
184
+
185
185
+
// For paste/drop, try dataTransfer.
186
186
+
if let Some(data_transfer) = event.data_transfer() {
187
187
+
if let Ok(text) = data_transfer.get_data("text/plain") {
188
188
+
if !text.is_empty() {
189
189
+
return Some(text);
190
190
+
}
191
191
+
}
192
192
+
}
193
193
+
194
194
+
None
195
195
+
}
196
196
+
197
197
+
/// Get input type from a beforeinput event.
198
198
+
pub fn get_input_type_from_event(event: &web_sys::InputEvent) -> InputType {
199
199
+
parse_browser_input_type(&event.input_type())
200
200
+
}
201
201
+
202
202
+
/// Check if the beforeinput event is during IME composition.
203
203
+
pub fn is_composing(event: &web_sys::InputEvent) -> bool {
204
204
+
event.is_composing()
205
205
+
}
206
206
+
207
207
+
// === Clipboard helpers ===
208
208
+
209
209
+
/// Write text to clipboard with both text/plain and custom MIME type.
210
210
+
pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), JsValue> {
211
211
+
use js_sys::{Array, Object, Reflect};
212
212
+
use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
213
213
+
214
214
+
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
215
215
+
let navigator = window.navigator();
216
216
+
let clipboard = navigator.clipboard();
217
217
+
218
218
+
let text_parts = Array::new();
219
219
+
text_parts.push(&JsValue::from_str(text));
220
220
+
221
221
+
let text_opts = BlobPropertyBag::new();
222
222
+
text_opts.set_type("text/plain");
223
223
+
let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?;
224
224
+
225
225
+
let custom_opts = BlobPropertyBag::new();
226
226
+
custom_opts.set_type("text/markdown");
227
227
+
let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?;
228
228
+
229
229
+
let item_data = Object::new();
230
230
+
Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
231
231
+
Reflect::set(
232
232
+
&item_data,
233
233
+
&JsValue::from_str("text/markdown"),
234
234
+
&custom_blob,
235
235
+
)?;
236
236
+
237
237
+
let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
238
238
+
let items = Array::new();
239
239
+
items.push(&clipboard_item);
240
240
+
241
241
+
let promise = clipboard.write(&items);
242
242
+
wasm_bindgen_futures::JsFuture::from(promise).await?;
243
243
+
244
244
+
Ok(())
245
245
+
}
246
246
+
247
247
+
/// Read text from clipboard.
248
248
+
pub async fn read_clipboard_text() -> Result<Option<String>, JsValue> {
249
249
+
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
250
250
+
let navigator = window.navigator();
251
251
+
let clipboard = navigator.clipboard();
252
252
+
253
253
+
let promise = clipboard.read_text();
254
254
+
let result: JsValue = wasm_bindgen_futures::JsFuture::from(promise).await?;
255
255
+
256
256
+
Ok(result.as_string())
257
257
+
}
+11
-5
crates/weaver-editor-browser/src/lib.rs
···
8
8
//!
9
9
//! - `cursor`: Selection API handling and cursor restoration
10
10
//! - `dom_sync`: DOM ↔ document state synchronization
11
11
-
//! - `events`: beforeinput, keydown, paste event handlers
12
12
-
//! - `contenteditable`: Editor element setup and management
11
11
+
//! - `events`: beforeinput event handling and clipboard helpers
13
12
//! - `platform`: Browser/OS detection for platform-specific behavior
14
13
//!
15
14
//! # Re-exports
···
29
28
// Browser cursor implementation
30
29
pub use cursor::BrowserCursor;
31
30
31
31
+
// DOM sync types
32
32
+
pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData};
33
33
+
34
34
+
// Event handling
35
35
+
pub use events::{
36
36
+
BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event,
37
37
+
get_input_type_from_event, get_target_range_from_event, is_composing,
38
38
+
parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type,
39
39
+
};
40
40
+
32
41
// Platform detection
33
42
pub use platform::{Platform, platform};
34
34
-
35
35
-
// TODO: contenteditable module
36
36
-
// TODO: embed worker module
+183
crates/weaver-editor-core/src/actions.rs
···
457
457
pub alt: bool,
458
458
pub shift: bool,
459
459
pub meta: bool,
460
460
+
pub hyper: bool,
461
461
+
pub super_: bool,
460
462
}
461
463
462
464
impl Modifiers {
···
465
467
alt: false,
466
468
shift: false,
467
469
meta: false,
470
470
+
hyper: false,
471
471
+
super_: false,
468
472
};
469
473
470
474
pub const CTRL: Self = Self {
···
472
476
alt: false,
473
477
shift: false,
474
478
meta: false,
479
479
+
hyper: false,
480
480
+
super_: false,
475
481
};
476
482
477
483
pub const ALT: Self = Self {
···
479
485
alt: true,
480
486
shift: false,
481
487
meta: false,
488
488
+
hyper: false,
489
489
+
super_: false,
482
490
};
483
491
484
492
pub const SHIFT: Self = Self {
···
486
494
alt: false,
487
495
shift: true,
488
496
meta: false,
497
497
+
hyper: false,
498
498
+
super_: false,
489
499
};
490
500
491
501
pub const META: Self = Self {
···
493
503
alt: false,
494
504
shift: false,
495
505
meta: true,
506
506
+
hyper: false,
507
507
+
super_: false,
508
508
+
};
509
509
+
510
510
+
pub const HYPER: Self = Self {
511
511
+
ctrl: false,
512
512
+
alt: false,
513
513
+
shift: false,
514
514
+
meta: false,
515
515
+
hyper: true,
516
516
+
super_: false,
517
517
+
};
518
518
+
519
519
+
pub const SUPER: Self = Self {
520
520
+
ctrl: false,
521
521
+
alt: false,
522
522
+
shift: false,
523
523
+
meta: false,
524
524
+
hyper: false,
525
525
+
super_: true,
496
526
};
497
527
498
528
pub const CTRL_SHIFT: Self = Self {
···
500
530
alt: false,
501
531
shift: true,
502
532
meta: false,
533
533
+
hyper: false,
534
534
+
super_: false,
503
535
};
504
536
505
537
pub const META_SHIFT: Self = Self {
···
507
539
alt: false,
508
540
shift: true,
509
541
meta: true,
542
542
+
hyper: false,
543
543
+
super_: false,
510
544
};
511
545
512
546
/// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere).
···
593
627
/// Event should be passed through (navigation, etc.).
594
628
PassThrough,
595
629
}
630
630
+
631
631
+
// === Keybinding configuration ===
632
632
+
633
633
+
use std::collections::HashMap;
634
634
+
635
635
+
/// Keybinding configuration for the editor.
636
636
+
///
637
637
+
/// Maps key combinations to editor actions. Platform-specific defaults
638
638
+
/// can be created via `default_for_platform`.
639
639
+
#[derive(Debug, Clone)]
640
640
+
pub struct KeybindingConfig {
641
641
+
bindings: HashMap<KeyCombo, EditorAction>,
642
642
+
}
643
643
+
644
644
+
impl Default for KeybindingConfig {
645
645
+
fn default() -> Self {
646
646
+
Self::default_for_platform(false)
647
647
+
}
648
648
+
}
649
649
+
650
650
+
impl KeybindingConfig {
651
651
+
/// Create an empty keybinding configuration.
652
652
+
pub fn new() -> Self {
653
653
+
Self {
654
654
+
bindings: HashMap::new(),
655
655
+
}
656
656
+
}
657
657
+
658
658
+
/// Create default keybindings for the given platform.
659
659
+
///
660
660
+
/// `is_mac` determines whether to use Cmd (true) or Ctrl (false) for shortcuts.
661
661
+
pub fn default_for_platform(is_mac: bool) -> Self {
662
662
+
let mut bindings = HashMap::new();
663
663
+
664
664
+
// === Formatting ===
665
665
+
bindings.insert(
666
666
+
KeyCombo::primary(Key::character("b"), is_mac),
667
667
+
EditorAction::ToggleBold,
668
668
+
);
669
669
+
bindings.insert(
670
670
+
KeyCombo::primary(Key::character("i"), is_mac),
671
671
+
EditorAction::ToggleItalic,
672
672
+
);
673
673
+
bindings.insert(
674
674
+
KeyCombo::primary(Key::character("e"), is_mac),
675
675
+
EditorAction::CopyAsHtml,
676
676
+
);
677
677
+
678
678
+
// === History ===
679
679
+
bindings.insert(
680
680
+
KeyCombo::primary(Key::character("z"), is_mac),
681
681
+
EditorAction::Undo,
682
682
+
);
683
683
+
684
684
+
// Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere
685
685
+
if is_mac {
686
686
+
bindings.insert(
687
687
+
KeyCombo::primary_shift(Key::character("Z"), is_mac),
688
688
+
EditorAction::Redo,
689
689
+
);
690
690
+
} else {
691
691
+
bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo);
692
692
+
bindings.insert(
693
693
+
KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT),
694
694
+
EditorAction::Redo,
695
695
+
);
696
696
+
}
697
697
+
698
698
+
// === Selection ===
699
699
+
bindings.insert(
700
700
+
KeyCombo::primary(Key::character("a"), is_mac),
701
701
+
EditorAction::SelectAll,
702
702
+
);
703
703
+
704
704
+
// === Line deletion ===
705
705
+
if is_mac {
706
706
+
bindings.insert(
707
707
+
KeyCombo::meta(Key::Backspace),
708
708
+
EditorAction::DeleteToLineStart {
709
709
+
range: Range::caret(0),
710
710
+
},
711
711
+
);
712
712
+
bindings.insert(
713
713
+
KeyCombo::meta(Key::Delete),
714
714
+
EditorAction::DeleteToLineEnd {
715
715
+
range: Range::caret(0),
716
716
+
},
717
717
+
);
718
718
+
}
719
719
+
720
720
+
// === Enter behaviour ===
721
721
+
// Enter = soft break (single newline)
722
722
+
bindings.insert(
723
723
+
KeyCombo::new(Key::Enter),
724
724
+
EditorAction::InsertLineBreak {
725
725
+
range: Range::caret(0),
726
726
+
},
727
727
+
);
728
728
+
// Shift+Enter = paragraph break (double newline)
729
729
+
bindings.insert(
730
730
+
KeyCombo::shift(Key::Enter),
731
731
+
EditorAction::InsertParagraph {
732
732
+
range: Range::caret(0),
733
733
+
},
734
734
+
);
735
735
+
736
736
+
// === Dedicated keys ===
737
737
+
bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo);
738
738
+
bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo);
739
739
+
bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy);
740
740
+
bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut);
741
741
+
bindings.insert(
742
742
+
KeyCombo::new(Key::Paste),
743
743
+
EditorAction::Paste {
744
744
+
range: Range::caret(0),
745
745
+
},
746
746
+
);
747
747
+
bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
748
748
+
749
749
+
Self { bindings }
750
750
+
}
751
751
+
752
752
+
/// Look up an action for the given key combo.
753
753
+
///
754
754
+
/// The range in the returned action is updated to the provided range.
755
755
+
pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> {
756
756
+
self.bindings.get(combo).cloned().map(|a| a.with_range(range))
757
757
+
}
758
758
+
759
759
+
/// Add or replace a keybinding.
760
760
+
pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) {
761
761
+
self.bindings.insert(combo, action);
762
762
+
}
763
763
+
764
764
+
/// Remove a keybinding.
765
765
+
pub fn unbind(&mut self, combo: &KeyCombo) {
766
766
+
self.bindings.remove(combo);
767
767
+
}
768
768
+
769
769
+
/// Check if a key combo has a binding.
770
770
+
pub fn has_binding(&self, combo: &KeyCombo) -> bool {
771
771
+
self.bindings.contains_key(combo)
772
772
+
}
773
773
+
774
774
+
/// Iterate over all bindings.
775
775
+
pub fn iter(&self) -> impl Iterator<Item = (&KeyCombo, &EditorAction)> {
776
776
+
self.bindings.iter()
777
777
+
}
778
778
+
}
+522
crates/weaver-editor-core/src/execute.rs
···
1
1
+
//! Action execution for editor documents.
2
2
+
//!
3
3
+
//! This module provides the `execute_action` function that applies `EditorAction`
4
4
+
//! operations to any type implementing `EditorDocument`. The logic is generic
5
5
+
//! and platform-agnostic.
6
6
+
7
7
+
use crate::actions::{EditorAction, Range};
8
8
+
use crate::document::EditorDocument;
9
9
+
use crate::text_helpers::{
10
10
+
ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward,
11
11
+
find_word_boundary_forward, is_list_item_empty,
12
12
+
};
13
13
+
use crate::types::Selection;
14
14
+
15
15
+
/// Execute an editor action on a document.
16
16
+
///
17
17
+
/// This is the central dispatch point for all editor operations.
18
18
+
/// Returns true if the action was handled and the document was modified.
19
19
+
pub fn execute_action<D: EditorDocument>(doc: &mut D, action: &EditorAction) -> bool {
20
20
+
match action {
21
21
+
EditorAction::Insert { text, range } => execute_insert(doc, text, *range),
22
22
+
EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range),
23
23
+
EditorAction::InsertParagraph { range } => execute_insert_paragraph(doc, *range),
24
24
+
EditorAction::DeleteBackward { range } => execute_delete_backward(doc, *range),
25
25
+
EditorAction::DeleteForward { range } => execute_delete_forward(doc, *range),
26
26
+
EditorAction::DeleteWordBackward { range } => execute_delete_word_backward(doc, *range),
27
27
+
EditorAction::DeleteWordForward { range } => execute_delete_word_forward(doc, *range),
28
28
+
EditorAction::DeleteToLineStart { range } => execute_delete_to_line_start(doc, *range),
29
29
+
EditorAction::DeleteToLineEnd { range } => execute_delete_to_line_end(doc, *range),
30
30
+
EditorAction::DeleteSoftLineBackward { range } => {
31
31
+
execute_action(doc, &EditorAction::DeleteToLineStart { range: *range })
32
32
+
}
33
33
+
EditorAction::DeleteSoftLineForward { range } => {
34
34
+
execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range })
35
35
+
}
36
36
+
EditorAction::Undo => execute_undo(doc),
37
37
+
EditorAction::Redo => execute_redo(doc),
38
38
+
EditorAction::ToggleBold => execute_toggle_format(doc, "**"),
39
39
+
EditorAction::ToggleItalic => execute_toggle_format(doc, "*"),
40
40
+
EditorAction::ToggleCode => execute_toggle_format(doc, "`"),
41
41
+
EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"),
42
42
+
EditorAction::InsertLink => execute_insert_link(doc),
43
43
+
EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => {
44
44
+
// Clipboard operations are handled by platform layer.
45
45
+
false
46
46
+
}
47
47
+
EditorAction::Paste { range: _ } => {
48
48
+
// Paste is handled by platform layer with clipboard access.
49
49
+
false
50
50
+
}
51
51
+
EditorAction::SelectAll => execute_select_all(doc),
52
52
+
EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset),
53
53
+
EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset),
54
54
+
}
55
55
+
}
56
56
+
57
57
+
fn execute_insert<D: EditorDocument>(doc: &mut D, text: &str, range: Range) -> bool {
58
58
+
let range = range.normalize();
59
59
+
60
60
+
// Clean up any preceding zero-width chars.
61
61
+
let mut delete_start = range.start;
62
62
+
while delete_start > 0 {
63
63
+
match doc.char_at(delete_start - 1) {
64
64
+
Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
65
65
+
_ => break,
66
66
+
}
67
67
+
}
68
68
+
69
69
+
let zw_count = range.start - delete_start;
70
70
+
71
71
+
if range.is_caret() {
72
72
+
if zw_count > 0 {
73
73
+
doc.replace(delete_start..range.start, text);
74
74
+
} else if range.start == doc.len_chars() {
75
75
+
doc.insert(range.start, text);
76
76
+
} else {
77
77
+
doc.insert(range.start, text);
78
78
+
}
79
79
+
} else {
80
80
+
// Replace selection.
81
81
+
if zw_count > 0 {
82
82
+
// Delete zero-width chars before selection start too.
83
83
+
doc.replace(delete_start..range.end, text);
84
84
+
} else {
85
85
+
doc.replace(range.start..range.end, text);
86
86
+
}
87
87
+
}
88
88
+
89
89
+
doc.set_selection(None);
90
90
+
true
91
91
+
}
92
92
+
93
93
+
fn execute_insert_line_break<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
94
94
+
let range = range.normalize();
95
95
+
let offset = range.start;
96
96
+
97
97
+
// Delete selection if any.
98
98
+
if !range.is_caret() {
99
99
+
doc.delete(offset..range.end);
100
100
+
}
101
101
+
102
102
+
// Check if we're right after a soft break (newline + zero-width char).
103
103
+
let is_double_enter = if offset >= 2 {
104
104
+
let prev_char = doc.char_at(offset - 1);
105
105
+
let prev_prev_char = doc.char_at(offset - 2);
106
106
+
prev_char == Some('\u{200C}') && prev_prev_char == Some('\n')
107
107
+
} else {
108
108
+
false
109
109
+
};
110
110
+
111
111
+
if !is_double_enter {
112
112
+
// Check for list context.
113
113
+
if let Some(ctx) = detect_list_context(doc, offset) {
114
114
+
if is_list_item_empty(doc, offset, &ctx) {
115
115
+
// Empty item - exit list.
116
116
+
let line_start = find_line_start(doc, offset);
117
117
+
let line_end = find_line_end(doc, offset);
118
118
+
let delete_end = (line_end + 1).min(doc.len_chars());
119
119
+
doc.replace(line_start..delete_end, "\n\n\u{200C}\n");
120
120
+
doc.set_cursor_offset(line_start + 2);
121
121
+
} else {
122
122
+
// Continue list.
123
123
+
let continuation = list_continuation(&ctx);
124
124
+
let len = continuation.chars().count();
125
125
+
doc.insert(offset, &continuation);
126
126
+
doc.set_cursor_offset(offset + len);
127
127
+
}
128
128
+
} else {
129
129
+
// Normal soft break: insert newline + zero-width char.
130
130
+
doc.insert(offset, "\n\u{200C}");
131
131
+
doc.set_cursor_offset(offset + 2);
132
132
+
}
133
133
+
} else {
134
134
+
// Replace zero-width char with newline.
135
135
+
doc.replace(offset - 1..offset, "\n");
136
136
+
}
137
137
+
138
138
+
doc.set_selection(None);
139
139
+
true
140
140
+
}
141
141
+
142
142
+
fn execute_insert_paragraph<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
143
143
+
let range = range.normalize();
144
144
+
let cursor_offset = range.start;
145
145
+
146
146
+
// Delete selection if any.
147
147
+
if !range.is_caret() {
148
148
+
doc.delete(cursor_offset..range.end);
149
149
+
}
150
150
+
151
151
+
// Check for list context.
152
152
+
if let Some(ctx) = detect_list_context(doc, cursor_offset) {
153
153
+
if is_list_item_empty(doc, cursor_offset, &ctx) {
154
154
+
// Empty item - exit list.
155
155
+
let line_start = find_line_start(doc, cursor_offset);
156
156
+
let line_end = find_line_end(doc, cursor_offset);
157
157
+
let delete_end = (line_end + 1).min(doc.len_chars());
158
158
+
doc.replace(line_start..delete_end, "\n\n\u{200C}\n");
159
159
+
doc.set_cursor_offset(line_start + 2);
160
160
+
} else {
161
161
+
// Continue list.
162
162
+
let continuation = list_continuation(&ctx);
163
163
+
let len = continuation.chars().count();
164
164
+
doc.insert(cursor_offset, &continuation);
165
165
+
doc.set_cursor_offset(cursor_offset + len);
166
166
+
}
167
167
+
} else {
168
168
+
// Normal paragraph break.
169
169
+
doc.insert(cursor_offset, "\n\n");
170
170
+
doc.set_cursor_offset(cursor_offset + 2);
171
171
+
}
172
172
+
173
173
+
doc.set_selection(None);
174
174
+
true
175
175
+
}
176
176
+
177
177
+
fn execute_delete_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
178
178
+
let range = range.normalize();
179
179
+
180
180
+
if !range.is_caret() {
181
181
+
// Delete selection.
182
182
+
doc.delete(range.start..range.end);
183
183
+
return true;
184
184
+
}
185
185
+
186
186
+
if range.start == 0 {
187
187
+
return false;
188
188
+
}
189
189
+
190
190
+
let cursor_offset = range.start;
191
191
+
let prev_char = doc.char_at(cursor_offset - 1);
192
192
+
193
193
+
if prev_char == Some('\n') {
194
194
+
// Deleting a newline - handle paragraph merging.
195
195
+
let newline_pos = cursor_offset - 1;
196
196
+
let mut delete_start = newline_pos;
197
197
+
let mut delete_end = cursor_offset;
198
198
+
199
199
+
// Check for empty paragraph (double newline).
200
200
+
if newline_pos > 0 && doc.char_at(newline_pos - 1) == Some('\n') {
201
201
+
delete_start = newline_pos - 1;
202
202
+
}
203
203
+
204
204
+
// Check for trailing zero-width char.
205
205
+
if let Some(ch) = doc.char_at(delete_end) {
206
206
+
if ch == '\u{200C}' || ch == '\u{200B}' {
207
207
+
delete_end += 1;
208
208
+
}
209
209
+
}
210
210
+
211
211
+
// Scan backwards through zero-width chars.
212
212
+
while delete_start > 0 {
213
213
+
match doc.char_at(delete_start - 1) {
214
214
+
Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
215
215
+
Some('\n') | _ => break,
216
216
+
}
217
217
+
}
218
218
+
219
219
+
doc.delete(delete_start..delete_end);
220
220
+
} else {
221
221
+
// Normal single char delete.
222
222
+
doc.delete(cursor_offset - 1..cursor_offset);
223
223
+
}
224
224
+
225
225
+
doc.set_selection(None);
226
226
+
true
227
227
+
}
228
228
+
229
229
+
fn execute_delete_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
230
230
+
let range = range.normalize();
231
231
+
232
232
+
if !range.is_caret() {
233
233
+
doc.delete(range.start..range.end);
234
234
+
return true;
235
235
+
}
236
236
+
237
237
+
if range.start >= doc.len_chars() {
238
238
+
return false;
239
239
+
}
240
240
+
241
241
+
doc.delete(range.start..range.start + 1);
242
242
+
doc.set_selection(None);
243
243
+
true
244
244
+
}
245
245
+
246
246
+
fn execute_delete_word_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
247
247
+
let range = range.normalize();
248
248
+
249
249
+
if !range.is_caret() {
250
250
+
doc.delete(range.start..range.end);
251
251
+
return true;
252
252
+
}
253
253
+
254
254
+
let cursor = range.start;
255
255
+
let word_start = find_word_boundary_backward(doc, cursor);
256
256
+
if word_start < cursor {
257
257
+
doc.delete(word_start..cursor);
258
258
+
}
259
259
+
260
260
+
doc.set_selection(None);
261
261
+
true
262
262
+
}
263
263
+
264
264
+
fn execute_delete_word_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
265
265
+
let range = range.normalize();
266
266
+
267
267
+
if !range.is_caret() {
268
268
+
doc.delete(range.start..range.end);
269
269
+
return true;
270
270
+
}
271
271
+
272
272
+
let cursor = range.start;
273
273
+
let word_end = find_word_boundary_forward(doc, cursor);
274
274
+
if word_end > cursor {
275
275
+
doc.delete(cursor..word_end);
276
276
+
}
277
277
+
278
278
+
doc.set_selection(None);
279
279
+
true
280
280
+
}
281
281
+
282
282
+
fn execute_delete_to_line_start<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
283
283
+
let range = range.normalize();
284
284
+
let cursor = range.start;
285
285
+
let line_start = find_line_start(doc, cursor);
286
286
+
287
287
+
if line_start < cursor {
288
288
+
doc.delete(line_start..cursor);
289
289
+
}
290
290
+
291
291
+
doc.set_selection(None);
292
292
+
true
293
293
+
}
294
294
+
295
295
+
fn execute_delete_to_line_end<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
296
296
+
let range = range.normalize();
297
297
+
let cursor = if range.is_caret() {
298
298
+
range.start
299
299
+
} else {
300
300
+
range.end
301
301
+
};
302
302
+
let line_end = find_line_end(doc, cursor);
303
303
+
304
304
+
if cursor < line_end {
305
305
+
doc.delete(cursor..line_end);
306
306
+
}
307
307
+
308
308
+
doc.set_selection(None);
309
309
+
true
310
310
+
}
311
311
+
312
312
+
fn execute_undo<D: EditorDocument>(doc: &mut D) -> bool {
313
313
+
if doc.undo() {
314
314
+
let max = doc.len_chars();
315
315
+
let cursor = doc.cursor();
316
316
+
if cursor.offset > max {
317
317
+
doc.set_cursor_offset(max);
318
318
+
}
319
319
+
doc.set_selection(None);
320
320
+
true
321
321
+
} else {
322
322
+
false
323
323
+
}
324
324
+
}
325
325
+
326
326
+
fn execute_redo<D: EditorDocument>(doc: &mut D) -> bool {
327
327
+
if doc.redo() {
328
328
+
let max = doc.len_chars();
329
329
+
let cursor = doc.cursor();
330
330
+
if cursor.offset > max {
331
331
+
doc.set_cursor_offset(max);
332
332
+
}
333
333
+
doc.set_selection(None);
334
334
+
true
335
335
+
} else {
336
336
+
false
337
337
+
}
338
338
+
}
339
339
+
340
340
+
fn execute_toggle_format<D: EditorDocument>(doc: &mut D, marker: &str) -> bool {
341
341
+
let cursor_offset = doc.cursor_offset();
342
342
+
let (start, end) = if let Some(sel) = doc.selection() {
343
343
+
(sel.start(), sel.end())
344
344
+
} else {
345
345
+
find_word_boundaries(doc, cursor_offset)
346
346
+
};
347
347
+
348
348
+
// Insert end marker first so start position stays valid.
349
349
+
doc.insert(end, marker);
350
350
+
doc.insert(start, marker);
351
351
+
doc.set_cursor_offset(end + marker.len() * 2);
352
352
+
doc.set_selection(None);
353
353
+
true
354
354
+
}
355
355
+
356
356
+
fn execute_insert_link<D: EditorDocument>(doc: &mut D) -> bool {
357
357
+
let cursor_offset = doc.cursor_offset();
358
358
+
let (start, end) = if let Some(sel) = doc.selection() {
359
359
+
(sel.start(), sel.end())
360
360
+
} else {
361
361
+
find_word_boundaries(doc, cursor_offset)
362
362
+
};
363
363
+
364
364
+
// Insert [selected text](url)
365
365
+
doc.insert(end, "](url)");
366
366
+
doc.insert(start, "[");
367
367
+
doc.set_cursor_offset(end + 8);
368
368
+
doc.set_selection(None);
369
369
+
true
370
370
+
}
371
371
+
372
372
+
fn execute_select_all<D: EditorDocument>(doc: &mut D) -> bool {
373
373
+
let len = doc.len_chars();
374
374
+
doc.set_selection(Some(Selection::new(0, len)));
375
375
+
doc.set_cursor_offset(len);
376
376
+
true
377
377
+
}
378
378
+
379
379
+
fn execute_move_cursor<D: EditorDocument>(doc: &mut D, offset: usize) -> bool {
380
380
+
let offset = offset.min(doc.len_chars());
381
381
+
doc.set_cursor_offset(offset);
382
382
+
doc.set_selection(None);
383
383
+
true
384
384
+
}
385
385
+
386
386
+
fn execute_extend_selection<D: EditorDocument>(doc: &mut D, offset: usize) -> bool {
387
387
+
let offset = offset.min(doc.len_chars());
388
388
+
let anchor = doc
389
389
+
.selection()
390
390
+
.map(|s| s.anchor)
391
391
+
.unwrap_or_else(|| doc.cursor_offset());
392
392
+
doc.set_selection(Some(Selection::new(anchor, offset)));
393
393
+
doc.set_cursor_offset(offset);
394
394
+
true
395
395
+
}
396
396
+
397
397
+
/// Find word boundaries around cursor position.
398
398
+
fn find_word_boundaries<D: EditorDocument>(doc: &D, offset: usize) -> (usize, usize) {
399
399
+
let len = doc.len_chars();
400
400
+
401
401
+
// Find start by scanning backwards.
402
402
+
let mut start = 0;
403
403
+
for i in (0..offset).rev() {
404
404
+
match doc.char_at(i) {
405
405
+
Some(c) if c.is_whitespace() => {
406
406
+
start = i + 1;
407
407
+
break;
408
408
+
}
409
409
+
Some(_) => continue,
410
410
+
None => break,
411
411
+
}
412
412
+
}
413
413
+
414
414
+
// Find end by scanning forwards.
415
415
+
let mut end = len;
416
416
+
for i in offset..len {
417
417
+
match doc.char_at(i) {
418
418
+
Some(c) if c.is_whitespace() => {
419
419
+
end = i;
420
420
+
break;
421
421
+
}
422
422
+
Some(_) => continue,
423
423
+
None => break,
424
424
+
}
425
425
+
}
426
426
+
427
427
+
(start, end)
428
428
+
}
429
429
+
430
430
+
/// Generate list continuation text.
431
431
+
fn list_continuation(ctx: &ListContext) -> String {
432
432
+
match ctx {
433
433
+
ListContext::Unordered { indent, marker } => {
434
434
+
format!("\n{}{} ", indent, marker)
435
435
+
}
436
436
+
ListContext::Ordered { indent, number } => {
437
437
+
format!("\n{}{}. ", indent, number + 1)
438
438
+
}
439
439
+
}
440
440
+
}
441
441
+
442
442
+
#[cfg(test)]
443
443
+
mod tests {
444
444
+
use super::*;
445
445
+
use crate::{EditorRope, PlainEditor, UndoableBuffer};
446
446
+
447
447
+
type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>;
448
448
+
449
449
+
fn make_editor(content: &str) -> TestEditor {
450
450
+
let rope = EditorRope::from_str(content);
451
451
+
let buf = UndoableBuffer::new(rope, 100);
452
452
+
PlainEditor::new(buf)
453
453
+
}
454
454
+
455
455
+
#[test]
456
456
+
fn test_insert() {
457
457
+
let mut editor = make_editor("hello");
458
458
+
let action = EditorAction::Insert {
459
459
+
text: " world".to_string(),
460
460
+
range: Range::caret(5),
461
461
+
};
462
462
+
assert!(execute_action(&mut editor, &action));
463
463
+
assert_eq!(editor.content_string(), "hello world");
464
464
+
}
465
465
+
466
466
+
#[test]
467
467
+
fn test_delete_backward() {
468
468
+
let mut editor = make_editor("hello");
469
469
+
editor.set_cursor_offset(5);
470
470
+
let action = EditorAction::DeleteBackward {
471
471
+
range: Range::caret(5),
472
472
+
};
473
473
+
assert!(execute_action(&mut editor, &action));
474
474
+
assert_eq!(editor.content_string(), "hell");
475
475
+
}
476
476
+
477
477
+
#[test]
478
478
+
fn test_delete_selection() {
479
479
+
let mut editor = make_editor("hello world");
480
480
+
editor.set_selection(Some(Selection::new(5, 11)));
481
481
+
let action = EditorAction::DeleteBackward {
482
482
+
range: Range::new(5, 11),
483
483
+
};
484
484
+
assert!(execute_action(&mut editor, &action));
485
485
+
assert_eq!(editor.content_string(), "hello");
486
486
+
}
487
487
+
488
488
+
#[test]
489
489
+
fn test_undo_redo() {
490
490
+
let mut editor = make_editor("hello");
491
491
+
492
492
+
let action = EditorAction::Insert {
493
493
+
text: " world".to_string(),
494
494
+
range: Range::caret(5),
495
495
+
};
496
496
+
execute_action(&mut editor, &action);
497
497
+
assert_eq!(editor.content_string(), "hello world");
498
498
+
499
499
+
assert!(execute_action(&mut editor, &EditorAction::Undo));
500
500
+
assert_eq!(editor.content_string(), "hello");
501
501
+
502
502
+
assert!(execute_action(&mut editor, &EditorAction::Redo));
503
503
+
assert_eq!(editor.content_string(), "hello world");
504
504
+
}
505
505
+
506
506
+
#[test]
507
507
+
fn test_select_all() {
508
508
+
let mut editor = make_editor("hello world");
509
509
+
assert!(execute_action(&mut editor, &EditorAction::SelectAll));
510
510
+
let sel = editor.selection().unwrap();
511
511
+
assert_eq!(sel.start(), 0);
512
512
+
assert_eq!(sel.end(), 11);
513
513
+
}
514
514
+
515
515
+
#[test]
516
516
+
fn test_toggle_bold() {
517
517
+
let mut editor = make_editor("hello");
518
518
+
editor.set_selection(Some(Selection::new(0, 5)));
519
519
+
assert!(execute_action(&mut editor, &EditorAction::ToggleBold));
520
520
+
assert_eq!(editor.content_string(), "**hello**");
521
521
+
}
522
522
+
}
+13
crates/weaver-editor-core/src/lib.rs
···
6
6
//! - `UndoableBuffer<T>` - TextBuffer wrapper with undo/redo
7
7
//! - `EditorDocument` trait - interface for editor implementations
8
8
//! - `PlainEditor<T>` - simple field-based EditorDocument impl
9
9
+
//! - `EditorAction`, `InputType`, `Key` - platform-agnostic input/action types
9
10
//! - Rendering types and offset mapping utilities
10
11
12
12
+
pub mod actions;
11
13
pub mod document;
14
14
+
pub mod execute;
12
15
pub mod offset_map;
13
16
pub mod paragraph;
14
17
pub mod platform;
15
18
pub mod render;
16
19
pub mod syntax;
17
20
pub mod text;
21
21
+
pub mod text_helpers;
18
22
pub mod types;
19
23
pub mod undo;
20
24
pub mod visibility;
···
38
42
pub use visibility::VisibilityState;
39
43
pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
40
44
pub use platform::{CursorPlatform, CursorSync, PlatformError};
45
45
+
pub use actions::{
46
46
+
EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range,
47
47
+
};
48
48
+
pub use execute::execute_action;
49
49
+
pub use text_helpers::{
50
50
+
ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start,
51
51
+
find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
52
52
+
is_zero_width_char,
53
53
+
};
+286
crates/weaver-editor-core/src/text_helpers.rs
···
1
1
+
//! Text navigation and analysis helpers.
2
2
+
//!
3
3
+
//! These functions work with the `EditorDocument` trait to provide
4
4
+
//! common text operations like finding line boundaries and word boundaries.
5
5
+
6
6
+
use crate::document::EditorDocument;
7
7
+
8
8
+
/// Find start of line containing offset.
9
9
+
pub fn find_line_start<D: EditorDocument>(doc: &D, offset: usize) -> usize {
10
10
+
if offset == 0 {
11
11
+
return 0;
12
12
+
}
13
13
+
14
14
+
let mut pos = offset;
15
15
+
while pos > 0 {
16
16
+
if let Some('\n') = doc.char_at(pos - 1) {
17
17
+
return pos;
18
18
+
}
19
19
+
pos -= 1;
20
20
+
}
21
21
+
0
22
22
+
}
23
23
+
24
24
+
/// Find end of line containing offset (position of newline or end of doc).
25
25
+
pub fn find_line_end<D: EditorDocument>(doc: &D, offset: usize) -> usize {
26
26
+
let len = doc.len_chars();
27
27
+
if offset >= len {
28
28
+
return len;
29
29
+
}
30
30
+
31
31
+
let mut pos = offset;
32
32
+
while pos < len {
33
33
+
if let Some('\n') = doc.char_at(pos) {
34
34
+
return pos;
35
35
+
}
36
36
+
pos += 1;
37
37
+
}
38
38
+
len
39
39
+
}
40
40
+
41
41
+
/// Find word boundary backward from cursor.
42
42
+
pub fn find_word_boundary_backward<D: EditorDocument>(doc: &D, cursor: usize) -> usize {
43
43
+
if cursor == 0 {
44
44
+
return 0;
45
45
+
}
46
46
+
47
47
+
let mut pos = cursor;
48
48
+
49
49
+
// Skip any whitespace/punctuation immediately before cursor.
50
50
+
while pos > 0 {
51
51
+
match doc.char_at(pos - 1) {
52
52
+
Some(c) if c.is_alphanumeric() || c == '_' => break,
53
53
+
Some(_) => pos -= 1,
54
54
+
None => break,
55
55
+
}
56
56
+
}
57
57
+
58
58
+
// Skip the word characters.
59
59
+
while pos > 0 {
60
60
+
match doc.char_at(pos - 1) {
61
61
+
Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1,
62
62
+
_ => break,
63
63
+
}
64
64
+
}
65
65
+
66
66
+
pos
67
67
+
}
68
68
+
69
69
+
/// Find word boundary forward from cursor.
70
70
+
pub fn find_word_boundary_forward<D: EditorDocument>(doc: &D, cursor: usize) -> usize {
71
71
+
let len = doc.len_chars();
72
72
+
if cursor >= len {
73
73
+
return len;
74
74
+
}
75
75
+
76
76
+
let mut pos = cursor;
77
77
+
78
78
+
// Skip word characters first.
79
79
+
while pos < len {
80
80
+
match doc.char_at(pos) {
81
81
+
Some(c) if c.is_alphanumeric() || c == '_' => pos += 1,
82
82
+
_ => break,
83
83
+
}
84
84
+
}
85
85
+
86
86
+
// Then skip whitespace/punctuation.
87
87
+
while pos < len {
88
88
+
match doc.char_at(pos) {
89
89
+
Some(c) if c.is_alphanumeric() || c == '_' => break,
90
90
+
Some(_) => pos += 1,
91
91
+
None => break,
92
92
+
}
93
93
+
}
94
94
+
95
95
+
pos
96
96
+
}
97
97
+
98
98
+
/// Describes what kind of list item the cursor is in, if any.
99
99
+
#[derive(Debug, Clone)]
100
100
+
pub enum ListContext {
101
101
+
/// Unordered list with the given marker char ('-' or '*') and indentation.
102
102
+
Unordered { indent: String, marker: char },
103
103
+
/// Ordered list with the current number and indentation.
104
104
+
Ordered { indent: String, number: usize },
105
105
+
}
106
106
+
107
107
+
/// Detect if cursor is in a list item and return context for continuation.
108
108
+
pub fn detect_list_context<D: EditorDocument>(doc: &D, cursor_offset: usize) -> Option<ListContext> {
109
109
+
let line_start = find_line_start(doc, cursor_offset);
110
110
+
let line_end = find_line_end(doc, cursor_offset);
111
111
+
112
112
+
if line_start >= line_end {
113
113
+
return None;
114
114
+
}
115
115
+
116
116
+
let line = doc.slice(line_start..line_end)?;
117
117
+
118
118
+
// Parse indentation.
119
119
+
let indent: String = line
120
120
+
.chars()
121
121
+
.take_while(|c| *c == ' ' || *c == '\t')
122
122
+
.collect();
123
123
+
let trimmed = &line[indent.len()..];
124
124
+
125
125
+
// Check for unordered list marker: "- " or "* ".
126
126
+
if trimmed.starts_with("- ") {
127
127
+
return Some(ListContext::Unordered {
128
128
+
indent,
129
129
+
marker: '-',
130
130
+
});
131
131
+
}
132
132
+
if trimmed.starts_with("* ") {
133
133
+
return Some(ListContext::Unordered {
134
134
+
indent,
135
135
+
marker: '*',
136
136
+
});
137
137
+
}
138
138
+
139
139
+
// Check for ordered list marker: "1. ", "2. ", etc.
140
140
+
if let Some(dot_pos) = trimmed.find(". ") {
141
141
+
let num_part = &trimmed[..dot_pos];
142
142
+
if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
143
143
+
if let Ok(number) = num_part.parse::<usize>() {
144
144
+
return Some(ListContext::Ordered { indent, number });
145
145
+
}
146
146
+
}
147
147
+
}
148
148
+
149
149
+
None
150
150
+
}
151
151
+
152
152
+
/// Check if the current list item is empty (just the marker, no content).
153
153
+
pub fn is_list_item_empty<D: EditorDocument>(
154
154
+
doc: &D,
155
155
+
cursor_offset: usize,
156
156
+
ctx: &ListContext,
157
157
+
) -> bool {
158
158
+
let line_start = find_line_start(doc, cursor_offset);
159
159
+
let line_end = find_line_end(doc, cursor_offset);
160
160
+
161
161
+
let line = match doc.slice(line_start..line_end) {
162
162
+
Some(s) => s,
163
163
+
None => return false,
164
164
+
};
165
165
+
166
166
+
// Calculate expected marker length.
167
167
+
let marker_len = match ctx {
168
168
+
ListContext::Unordered { indent, .. } => indent.len() + 2, // "- "
169
169
+
ListContext::Ordered { indent, number } => {
170
170
+
indent.len() + number.to_string().len() + 2 // "1. "
171
171
+
}
172
172
+
};
173
173
+
174
174
+
line.len() <= marker_len
175
175
+
}
176
176
+
177
177
+
/// Count leading zero-width characters before offset.
178
178
+
pub fn count_leading_zero_width<D: EditorDocument>(doc: &D, offset: usize) -> usize {
179
179
+
let mut count = 0;
180
180
+
let mut pos = offset;
181
181
+
182
182
+
while pos > 0 {
183
183
+
match doc.char_at(pos - 1) {
184
184
+
Some('\u{200C}') | Some('\u{200B}') => {
185
185
+
count += 1;
186
186
+
pos -= 1;
187
187
+
}
188
188
+
_ => break,
189
189
+
}
190
190
+
}
191
191
+
192
192
+
count
193
193
+
}
194
194
+
195
195
+
/// Check if character at offset is a zero-width character.
196
196
+
pub fn is_zero_width_char<D: EditorDocument>(doc: &D, offset: usize) -> bool {
197
197
+
matches!(doc.char_at(offset), Some('\u{200C}') | Some('\u{200B}'))
198
198
+
}
199
199
+
200
200
+
#[cfg(test)]
201
201
+
mod tests {
202
202
+
use super::*;
203
203
+
use crate::{EditorRope, PlainEditor, UndoableBuffer};
204
204
+
205
205
+
type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>;
206
206
+
207
207
+
fn make_editor(content: &str) -> TestEditor {
208
208
+
let rope = EditorRope::from_str(content);
209
209
+
let buf = UndoableBuffer::new(rope, 100);
210
210
+
PlainEditor::new(buf)
211
211
+
}
212
212
+
213
213
+
#[test]
214
214
+
fn test_find_line_start() {
215
215
+
let editor = make_editor("hello\nworld\ntest");
216
216
+
217
217
+
assert_eq!(find_line_start(&editor, 0), 0);
218
218
+
assert_eq!(find_line_start(&editor, 3), 0);
219
219
+
assert_eq!(find_line_start(&editor, 5), 0); // at newline
220
220
+
assert_eq!(find_line_start(&editor, 6), 6); // start of "world"
221
221
+
assert_eq!(find_line_start(&editor, 8), 6);
222
222
+
assert_eq!(find_line_start(&editor, 12), 12); // start of "test"
223
223
+
}
224
224
+
225
225
+
#[test]
226
226
+
fn test_find_line_end() {
227
227
+
let editor = make_editor("hello\nworld\ntest");
228
228
+
229
229
+
assert_eq!(find_line_end(&editor, 0), 5);
230
230
+
assert_eq!(find_line_end(&editor, 3), 5);
231
231
+
assert_eq!(find_line_end(&editor, 6), 11);
232
232
+
assert_eq!(find_line_end(&editor, 12), 16);
233
233
+
}
234
234
+
235
235
+
#[test]
236
236
+
fn test_find_word_boundary_backward() {
237
237
+
let editor = make_editor("hello world test");
238
238
+
239
239
+
assert_eq!(find_word_boundary_backward(&editor, 16), 12); // from end
240
240
+
assert_eq!(find_word_boundary_backward(&editor, 12), 6); // from "test"
241
241
+
assert_eq!(find_word_boundary_backward(&editor, 11), 6); // from space before "test"
242
242
+
assert_eq!(find_word_boundary_backward(&editor, 5), 0); // from end of "hello"
243
243
+
}
244
244
+
245
245
+
#[test]
246
246
+
fn test_find_word_boundary_forward() {
247
247
+
let editor = make_editor("hello world test");
248
248
+
249
249
+
assert_eq!(find_word_boundary_forward(&editor, 0), 6); // from start
250
250
+
assert_eq!(find_word_boundary_forward(&editor, 6), 12); // from space
251
251
+
assert_eq!(find_word_boundary_forward(&editor, 12), 16); // from "test"
252
252
+
}
253
253
+
254
254
+
#[test]
255
255
+
fn test_detect_list_context_unordered() {
256
256
+
let editor = make_editor("- item one\n- item two");
257
257
+
258
258
+
let ctx = detect_list_context(&editor, 5);
259
259
+
assert!(matches!(ctx, Some(ListContext::Unordered { marker: '-', .. })));
260
260
+
261
261
+
let ctx = detect_list_context(&editor, 15);
262
262
+
assert!(matches!(ctx, Some(ListContext::Unordered { marker: '-', .. })));
263
263
+
}
264
264
+
265
265
+
#[test]
266
266
+
fn test_detect_list_context_ordered() {
267
267
+
let editor = make_editor("1. first\n2. second");
268
268
+
269
269
+
let ctx = detect_list_context(&editor, 5);
270
270
+
assert!(matches!(ctx, Some(ListContext::Ordered { number: 1, .. })));
271
271
+
272
272
+
let ctx = detect_list_context(&editor, 12);
273
273
+
assert!(matches!(ctx, Some(ListContext::Ordered { number: 2, .. })));
274
274
+
}
275
275
+
276
276
+
#[test]
277
277
+
fn test_is_list_item_empty() {
278
278
+
let editor = make_editor("- \n- item");
279
279
+
280
280
+
let ctx = detect_list_context(&editor, 1).unwrap();
281
281
+
assert!(is_list_item_empty(&editor, 1, &ctx));
282
282
+
283
283
+
let ctx = detect_list_context(&editor, 5).unwrap();
284
284
+
assert!(!is_list_item_empty(&editor, 5, &ctx));
285
285
+
}
286
286
+
}
+132
docs/graph-data.json
···
1286
1286
"created_at": "2026-01-06T11:35:39.555773236-05:00",
1287
1287
"updated_at": "2026-01-06T11:35:39.555773236-05:00",
1288
1288
"metadata_json": "{\"confidence\":95}"
1289
1289
+
},
1290
1290
+
{
1291
1291
+
"id": 119,
1292
1292
+
"change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e",
1293
1293
+
"node_type": "action",
1294
1294
+
"title": "core: actions.rs with Range, EditorAction, InputType, Key, Modifiers, KeyCombo",
1295
1295
+
"description": null,
1296
1296
+
"status": "pending",
1297
1297
+
"created_at": "2026-01-06T11:48:31.913440141-05:00",
1298
1298
+
"updated_at": "2026-01-06T11:48:31.913440141-05:00",
1299
1299
+
"metadata_json": "{\"confidence\":90}"
1300
1300
+
},
1301
1301
+
{
1302
1302
+
"id": 120,
1303
1303
+
"change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7",
1304
1304
+
"node_type": "action",
1305
1305
+
"title": "core: text_helpers.rs with find_line_start/end, word boundaries, list detection",
1306
1306
+
"description": null,
1307
1307
+
"status": "pending",
1308
1308
+
"created_at": "2026-01-06T11:48:48.907582364-05:00",
1309
1309
+
"updated_at": "2026-01-06T11:48:48.907582364-05:00",
1310
1310
+
"metadata_json": "{\"confidence\":90}"
1311
1311
+
},
1312
1312
+
{
1313
1313
+
"id": 121,
1314
1314
+
"change_id": "1206764e-ee77-4dd4-b924-019177b1a495",
1315
1315
+
"node_type": "outcome",
1316
1316
+
"title": "weaver-editor-browser and weaver-editor-core compile with extracted actions, text helpers, and events",
1317
1317
+
"description": null,
1318
1318
+
"status": "pending",
1319
1319
+
"created_at": "2026-01-06T11:54:09.803705832-05:00",
1320
1320
+
"updated_at": "2026-01-06T11:54:09.803705832-05:00",
1321
1321
+
"metadata_json": "{\"confidence\":95}"
1322
1322
+
},
1323
1323
+
{
1324
1324
+
"id": 122,
1325
1325
+
"change_id": "7179434c-6064-4ae7-9eac-1f89465e2479",
1326
1326
+
"node_type": "goal",
1327
1327
+
"title": "Extract execute_action and related code to core/browser crates",
1328
1328
+
"description": null,
1329
1329
+
"status": "pending",
1330
1330
+
"created_at": "2026-01-06T11:57:25.012098492-05:00",
1331
1331
+
"updated_at": "2026-01-06T11:57:25.012098492-05:00",
1332
1332
+
"metadata_json": "{\"confidence\":85,\"prompt\":\"User asked: extract execute_action in generic fashion and scan editor components for other extractable code (non-crdt, non-dioxus)\"}"
1333
1333
+
},
1334
1334
+
{
1335
1335
+
"id": 123,
1336
1336
+
"change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a",
1337
1337
+
"node_type": "action",
1338
1338
+
"title": "Created execute.rs in core with generic execute_action",
1339
1339
+
"description": null,
1340
1340
+
"status": "pending",
1341
1341
+
"created_at": "2026-01-06T12:01:21.232311329-05:00",
1342
1342
+
"updated_at": "2026-01-06T12:01:21.232311329-05:00",
1343
1343
+
"metadata_json": "{\"confidence\":95}"
1344
1344
+
},
1345
1345
+
{
1346
1346
+
"id": 124,
1347
1347
+
"change_id": "51a84f63-8720-4a90-97af-50c9212d14d6",
1348
1348
+
"node_type": "outcome",
1349
1349
+
"title": "Core execute.rs complete with 75 tests passing",
1350
1350
+
"description": null,
1351
1351
+
"status": "pending",
1352
1352
+
"created_at": "2026-01-06T12:01:52.561290209-05:00",
1353
1353
+
"updated_at": "2026-01-06T12:01:52.561290209-05:00",
1354
1354
+
"metadata_json": "{\"confidence\":95}"
1289
1355
}
1290
1356
],
1291
1357
"edges": [
···
2718
2784
"weight": 1.0,
2719
2785
"rationale": "API refinement",
2720
2786
"created_at": "2026-01-06T11:35:39.734624305-05:00"
2787
2787
+
},
2788
2788
+
{
2789
2789
+
"id": 132,
2790
2790
+
"from_node_id": 81,
2791
2791
+
"to_node_id": 119,
2792
2792
+
"from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91",
2793
2793
+
"to_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e",
2794
2794
+
"edge_type": "leads_to",
2795
2795
+
"weight": 1.0,
2796
2796
+
"rationale": "core actions support browser crate",
2797
2797
+
"created_at": "2026-01-06T11:48:48.924160167-05:00"
2798
2798
+
},
2799
2799
+
{
2800
2800
+
"id": 133,
2801
2801
+
"from_node_id": 116,
2802
2802
+
"to_node_id": 119,
2803
2803
+
"from_change_id": "cd5cc522-d717-43a7-b116-94f86845db10",
2804
2804
+
"to_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e",
2805
2805
+
"edge_type": "leads_to",
2806
2806
+
"weight": 1.0,
2807
2807
+
"rationale": "pattern applied: generic logic in core",
2808
2808
+
"created_at": "2026-01-06T11:48:48.940647360-05:00"
2809
2809
+
},
2810
2810
+
{
2811
2811
+
"id": 134,
2812
2812
+
"from_node_id": 119,
2813
2813
+
"to_node_id": 120,
2814
2814
+
"from_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e",
2815
2815
+
"to_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7",
2816
2816
+
"edge_type": "leads_to",
2817
2817
+
"weight": 1.0,
2818
2818
+
"rationale": "text helpers support action execution",
2819
2819
+
"created_at": "2026-01-06T11:54:09.820070547-05:00"
2820
2820
+
},
2821
2821
+
{
2822
2822
+
"id": 135,
2823
2823
+
"from_node_id": 81,
2824
2824
+
"to_node_id": 120,
2825
2825
+
"from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91",
2826
2826
+
"to_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7",
2827
2827
+
"edge_type": "leads_to",
2828
2828
+
"weight": 1.0,
2829
2829
+
"rationale": "browser crate extraction progress",
2830
2830
+
"created_at": "2026-01-06T11:54:09.837098743-05:00"
2831
2831
+
},
2832
2832
+
{
2833
2833
+
"id": 136,
2834
2834
+
"from_node_id": 122,
2835
2835
+
"to_node_id": 123,
2836
2836
+
"from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479",
2837
2837
+
"to_change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a",
2838
2838
+
"edge_type": "leads_to",
2839
2839
+
"weight": 1.0,
2840
2840
+
"rationale": "Part of extracting action execution",
2841
2841
+
"created_at": "2026-01-06T12:01:21.248171774-05:00"
2842
2842
+
},
2843
2843
+
{
2844
2844
+
"id": 137,
2845
2845
+
"from_node_id": 123,
2846
2846
+
"to_node_id": 124,
2847
2847
+
"from_change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a",
2848
2848
+
"to_change_id": "51a84f63-8720-4a90-97af-50c9212d14d6",
2849
2849
+
"edge_type": "leads_to",
2850
2850
+
"weight": 1.0,
2851
2851
+
"rationale": "Execute action implementation outcome",
2852
2852
+
"created_at": "2026-01-06T12:01:52.577885198-05:00"
2721
2853
}
2722
2854
]
2723
2855
}