···808 };
809810 // Navigation keys (with or without Shift for selection)
0811 let navigation = matches!(
812 evt.key(),
813 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown |
814 Key::Home | Key::End | Key::PageUp | Key::PageDown
815 );
816817- // Cmd/Ctrl+A for select all
818- let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl())
819- && matches!(evt.key(), Key::Character(ref c) if c == "a");
820821- if navigation || select_all {
822 tracing::debug!(
823 key = ?evt.key(),
824- navigation,
825- select_all,
826 "onkeyup navigation - syncing cursor from DOM"
827 );
828 let paras = cached_paragraphs();
···808 };
809810 // Navigation keys (with or without Shift for selection)
811+ // We sync cursor from DOM for these because we let the browser handle them
812 let navigation = matches!(
813 evt.key(),
814 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown |
815 Key::Home | Key::End | Key::PageUp | Key::PageDown
816 );
817818+ // Ctrl+A/Cmd+A is handled by browser natively, onselectionchange syncs it.
00819820+ if navigation {
821 tracing::debug!(
822 key = ?evt.key(),
00823 "onkeyup navigation - syncing cursor from DOM"
824 );
825 let paras = cached_paragraphs();
···37] }
3839[dev-dependencies]
40+insta = { version = "1.40", features = ["yaml"] }
41+serde = { workspace = true, features = ["derive"] }
+28-6
crates/weaver-editor-core/src/actions.rs
···716 }
717718 // === Selection ===
719- bindings.insert(
720- KeyCombo::primary(Key::character("a"), is_mac),
721- EditorAction::SelectAll,
722- );
0723724 // === Line deletion ===
725 if is_mac {
···764 range: Range::caret(0),
765 },
766 );
767- bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
768769 Self { bindings }
770 }
···772 /// Look up an action for the given key combo.
773 ///
774 /// The range in the returned action is updated to the provided range.
00775 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> {
776- self.bindings.get(combo).cloned().map(|a| a.with_range(range))
0000000000000000000777 }
778779 /// Add or replace a keybinding.
···716 }
717718 // === Selection ===
719+ // Let browser handle Ctrl+A/Cmd+A natively - onselectionchange syncs to our state
720+ // bindings.insert(
721+ // KeyCombo::primary(Key::character("a"), is_mac),
722+ // EditorAction::SelectAll,
723+ // );
724725 // === Line deletion ===
726 if is_mac {
···765 range: Range::caret(0),
766 },
767 );
768+ // bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
769770 Self { bindings }
771 }
···773 /// Look up an action for the given key combo.
774 ///
775 /// The range in the returned action is updated to the provided range.
776+ /// Character keys are normalized to lowercase for matching (browsers report
777+ /// uppercase when modifiers like Ctrl are held).
778 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> {
779+ // Try exact match first
780+ if let Some(action) = self.bindings.get(combo) {
781+ return Some(action.clone().with_range(range));
782+ }
783+784+ // For character keys, try lowercase version (browsers report "A" when Ctrl+A)
785+ if let Key::Character(ref s) = combo.key {
786+ let lower = s.to_lowercase();
787+ if lower != s.as_str() {
788+ let normalized = KeyCombo {
789+ key: Key::Character(lower.into()),
790+ modifiers: combo.modifiers,
791+ };
792+ if let Some(action) = self.bindings.get(&normalized) {
793+ return Some(action.clone().with_range(range));
794+ }
795+ }
796+ }
797+798+ None
799 }
800801 /// Add or replace a keybinding.
+6-1
crates/weaver-editor-core/src/text.rs
···76 let search_start = offset.saturating_sub(BLOCK_SYNTAX_ZONE + 1);
77 match self.slice(search_start..offset) {
78 Some(s) => match s.rfind('\n') {
79- Some(pos) => (offset - search_start - pos - 1) <= BLOCK_SYNTAX_ZONE,
0000080 None => false, // No newline in range, offset > BLOCK_SYNTAX_ZONE.
81 },
82 None => false,
···76 let search_start = offset.saturating_sub(BLOCK_SYNTAX_ZONE + 1);
77 match self.slice(search_start..offset) {
78 Some(s) => match s.rfind('\n') {
79+ Some(pos) => {
80+ // Distance from character after newline to current offset
81+ let newline_abs_pos = search_start + pos;
82+ let dist = offset.saturating_sub(newline_abs_pos + 1);
83+ dist <= BLOCK_SYNTAX_ZONE
84+ }
85 None => false, // No newline in range, offset > BLOCK_SYNTAX_ZONE.
86 },
87 None => false,
+59-9
crates/weaver-editor-core/src/writer/events.rs
···38 // For End events, emit any trailing content within the event's range
39 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
40 //
41- // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough),
42 // the closing syntax must be emitted AFTER the closing HTML tag, not before.
43- // Otherwise the closing `**` span ends up INSIDE the <strong> element.
44 // These tags handle their own closing syntax in end_tag().
45 // Image and Embed handle ALL their syntax in the Start event, so exclude them too.
46 let is_self_handled_end = matches!(
···49 TagEnd::Strong
50 | TagEnd::Emphasis
51 | TagEnd::Strikethrough
052 | TagEnd::Image
53 | TagEnd::Embed
54 )
···60 } else if !matches!(&event, Event::End(_)) {
61 // For paragraph-level start events, capture pre-gap position so the
62 // paragraph's char_range includes leading whitespace/gap content.
0063 let is_para_start = matches!(
64 &event,
65 Event::Start(
···67 | Tag::Heading { .. }
68 | Tag::CodeBlock(_)
69 | Tag::List(_)
70- | Tag::BlockQuote(_)
71 | Tag::HtmlBlock
72 )
73 );
···7879 // For other events, emit any gap before range.start
80 // (emit_syntax handles char offset tracking)
81- self.emit_gap_before(range.start)?;
0000000082 }
83 // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML
8485 // Store last_byte before processing
86 let last_byte_before = self.last_byte_offset;
87000000000000000088 // Process the event (passing range for tag syntax)
89 self.process_event(event, range.clone())?;
9091- // Update tracking - but don't override if start_tag manually updated it
92- // (for inline formatting tags that emit opening syntax)
93- if self.last_byte_offset == last_byte_before {
94- // Event didn't update offset, so we update it
95 self.last_byte_offset = range.end;
96 }
97- // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
98 }
99100 // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing.
···164 || !self.current_para.syntax_spans.is_empty()
165 || !self.ref_collector.refs.is_empty()
166 {
00000000000000000000000167 self.offset_maps_by_para
168 .push(std::mem::take(&mut self.current_para.offset_maps));
169 self.syntax_spans_by_para
···38 // For End events, emit any trailing content within the event's range
39 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
40 //
41+ // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough, Link),
42 // the closing syntax must be emitted AFTER the closing HTML tag, not before.
43+ // Otherwise the closing `**` or `]]` span ends up INSIDE the element.
44 // These tags handle their own closing syntax in end_tag().
45 // Image and Embed handle ALL their syntax in the Start event, so exclude them too.
46 let is_self_handled_end = matches!(
···49 TagEnd::Strong
50 | TagEnd::Emphasis
51 | TagEnd::Strikethrough
52+ | TagEnd::Link
53 | TagEnd::Image
54 | TagEnd::Embed
55 )
···61 } else if !matches!(&event, Event::End(_)) {
62 // For paragraph-level start events, capture pre-gap position so the
63 // paragraph's char_range includes leading whitespace/gap content.
64+ // Note: BlockQuote is NOT included - it defers `>` syntax to emit inside
65+ // the next paragraph via pending_blockquote_range.
66 let is_para_start = matches!(
67 &event,
68 Event::Start(
···70 | Tag::Heading { .. }
71 | Tag::CodeBlock(_)
72 | Tag::List(_)
73+ | Tag::FootnoteDefinition(_)
74 | Tag::HtmlBlock
75 )
76 );
···8182 // For other events, emit any gap before range.start
83 // (emit_syntax handles char offset tracking)
84+ //
85+ // EXCEPTION: For Paragraph starts when we have pending_blockquote_range,
86+ // skip gap emission here - the Paragraph handler will emit the `>` marker
87+ // inside the <p> tag so it gets proper visibility toggling.
88+ let skip_gap_for_blockquote = matches!(&event, Event::Start(Tag::Paragraph(_)))
89+ && self.pending_blockquote_range.is_some();
90+ if !skip_gap_for_blockquote {
91+ self.emit_gap_before(range.start)?;
92+ }
93 }
94 // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML
9596 // Store last_byte before processing
97 let last_byte_before = self.last_byte_offset;
9899+ // Check if this is a container start - container ranges span their entire content,
100+ // so we should NOT jump last_byte_offset to range.end for these.
101+ let is_container_start = matches!(
102+ &event,
103+ Event::Start(
104+ Tag::BlockQuote(_)
105+ | Tag::List(_)
106+ | Tag::Item
107+ | Tag::Table(_)
108+ | Tag::TableHead
109+ | Tag::TableRow
110+ | Tag::TableCell
111+ | Tag::FootnoteDefinition(_)
112+ )
113+ );
114+115 // Process the event (passing range for tag syntax)
116 self.process_event(event, range.clone())?;
117118+ // Update tracking - but don't override if:
119+ // 1. start_tag manually updated it (for inline formatting tags that emit opening syntax)
120+ // 2. This is a container start (range.end is end of whole container, not current position)
121+ if self.last_byte_offset == last_byte_before && !is_container_start {
122 self.last_byte_offset = range.end;
123 }
124+ // else: Event updated offset, or it's a container - keep current value
125 }
126127 // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing.
···191 || !self.current_para.syntax_spans.is_empty()
192 || !self.ref_collector.refs.is_empty()
193 {
194+ // If we have offset_maps but no paragraph range was recorded (e.g., pure newlines
195+ // with no actual paragraph from the parser), synthesize a range from the maps.
196+ if !self.current_para.offset_maps.is_empty() {
197+ let maps = &self.current_para.offset_maps;
198+ let byte_start = maps.iter().map(|m| m.byte_range.start).min().unwrap_or(0);
199+ let byte_end = maps.iter().map(|m| m.byte_range.end).max().unwrap_or(0);
200+ let char_start = maps.iter().map(|m| m.char_range.start).min().unwrap_or(0);
201+ let char_end = maps.iter().map(|m| m.char_range.end).max().unwrap_or(0);
202+203+ // Only add if we have actual content and no range was already recorded
204+ if byte_end > byte_start
205+ && self
206+ .paragraphs
207+ .ranges
208+ .last()
209+ .is_none_or(|(br, _)| br.end <= byte_start)
210+ {
211+ self.paragraphs
212+ .ranges
213+ .push((byte_start..byte_end, char_start..char_end));
214+ }
215+ }
216+217 self.offset_maps_by_para
218 .push(std::mem::take(&mut self.current_para.offset_maps));
219 self.syntax_spans_by_para
+2
crates/weaver-editor-core/src/writer/mod.rs
···8mod state;
9mod syntax;
10mod tags;
001112pub use embed::EditorImageResolver;
13pub use state::*;
···8mod state;
9mod syntax;
10mod tags;
11+#[cfg(test)]
12+mod tests;
1314pub use embed::EditorImageResolver;
15pub use state::*;
···232 }
233 self.begin_node(node_id.clone());
234235- // Map the start position of the paragraph (before any content)
236- // This allows cursor to be placed at the very beginning
237- let para_start_char = self.last_char_offset;
238- let mapping = OffsetMapping {
239- byte_range: range.start..range.start,
240- char_range: para_start_char..para_start_char,
241- node_id,
242- char_offset_in_node: 0,
243- child_index: Some(0), // position before first child
244- utf16_len: 0,
245- };
246- self.current_para.offset_maps.push(mapping);
247-248- // Emit > syntax if we're inside a blockquote
249 if let Some(bq_range) = self.pending_blockquote_range.take() {
250 if bq_range.start < bq_range.end {
251 let raw_text = &self.source[bq_range.clone()];
252 if let Some(gt_pos) = raw_text.find('>') {
253- // Extract > [!NOTE] or just >
254 let after_gt = &raw_text[gt_pos + 1..];
255 let syntax_end = if after_gt.trim_start().starts_with("[!") {
256 // Find the closing ]
···260 gt_pos + 1
261 }
262 } else {
263- // Just > and maybe a space
264- (gt_pos + 1).min(raw_text.len())
0000265 };
266267 let syntax = &raw_text[gt_pos..syntax_end];
···270 }
271 }
272 }
000000000000000273 Ok(())
274 }
275 Tag::Heading {
···1466 syntax_type: SyntaxType::Inline,
1467 formatted_range: None, // Will be set by finalize
1468 });
000014691470 self.last_char_offset = char_end;
1471 self.last_byte_offset = range.end;
···232 }
233 self.begin_node(node_id.clone());
234235+ // Emit > syntax if we're inside a blockquote (BEFORE start mapping)
0000000000000236 if let Some(bq_range) = self.pending_blockquote_range.take() {
237 if bq_range.start < bq_range.end {
238 let raw_text = &self.source[bq_range.clone()];
239 if let Some(gt_pos) = raw_text.find('>') {
240+ // Extract "> " or "> [!NOTE]" etc
241 let after_gt = &raw_text[gt_pos + 1..];
242 let syntax_end = if after_gt.trim_start().starts_with("[!") {
243 // Find the closing ]
···247 gt_pos + 1
248 }
249 } else {
250+ // Include > and the space after it (for consistent hiding)
251+ if after_gt.starts_with(' ') {
252+ gt_pos + 2 // "> "
253+ } else {
254+ gt_pos + 1 // just ">"
255+ }
256 };
257258 let syntax = &raw_text[gt_pos..syntax_end];
···261 }
262 }
263 }
264+265+ // Map the start position of the paragraph (after blockquote marker if any)
266+ // This allows cursor to be placed at the start of actual content
267+ let para_start_byte = self.last_byte_offset;
268+ let para_start_char = self.last_char_offset;
269+ let mapping = OffsetMapping {
270+ byte_range: para_start_byte..para_start_byte,
271+ char_range: para_start_char..para_start_char,
272+ node_id,
273+ char_offset_in_node: self.current_node.char_offset,
274+ child_index: Some(self.current_node.child_count),
275+ utf16_len: 0,
276+ };
277+ self.current_para.offset_maps.push(mapping);
278+279 Ok(())
280 }
281 Tag::Heading {
···1472 syntax_type: SyntaxType::Inline,
1473 formatted_range: None, // Will be set by finalize
1474 });
1475+1476+ // Record offset mapping for the ]]
1477+ let byte_start = range.end - 2; // ]] is 2 bytes
1478+ self.record_mapping(byte_start..range.end, char_start..char_end);
14791480 self.last_char_offset = char_end;
1481 self.last_byte_offset = range.end;