···808808 };
809809810810 // Navigation keys (with or without Shift for selection)
811811+ // We sync cursor from DOM for these because we let the browser handle them
811812 let navigation = matches!(
812813 evt.key(),
813814 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown |
814815 Key::Home | Key::End | Key::PageUp | Key::PageDown
815816 );
816817817817- // Cmd/Ctrl+A for select all
818818- let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl())
819819- && matches!(evt.key(), Key::Character(ref c) if c == "a");
818818+ // Ctrl+A/Cmd+A is handled by browser natively, onselectionchange syncs it.
820819821821- if navigation || select_all {
820820+ if navigation {
822821 tracing::debug!(
823822 key = ?evt.key(),
824824- navigation,
825825- select_all,
826823 "onkeyup navigation - syncing cursor from DOM"
827824 );
828825 let paras = cached_paragraphs();
+1
crates/weaver-editor-browser/Cargo.toml
···83838484[dev-dependencies]
8585wasm-bindgen-test = "0.3"
8686+insta = { version = "1.40", features = ["yaml"] }
+2
crates/weaver-editor-core/Cargo.toml
···3737] }
38383939[dev-dependencies]
4040+insta = { version = "1.40", features = ["yaml"] }
4141+serde = { workspace = true, features = ["derive"] }
+28-6
crates/weaver-editor-core/src/actions.rs
···716716 }
717717718718 // === Selection ===
719719- bindings.insert(
720720- KeyCombo::primary(Key::character("a"), is_mac),
721721- EditorAction::SelectAll,
722722- );
719719+ // Let browser handle Ctrl+A/Cmd+A natively - onselectionchange syncs to our state
720720+ // bindings.insert(
721721+ // KeyCombo::primary(Key::character("a"), is_mac),
722722+ // EditorAction::SelectAll,
723723+ // );
723724724725 // === Line deletion ===
725726 if is_mac {
···764765 range: Range::caret(0),
765766 },
766767 );
767767- bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
768768+ // bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
768769769770 Self { bindings }
770771 }
···772773 /// Look up an action for the given key combo.
773774 ///
774775 /// The range in the returned action is updated to the provided range.
776776+ /// Character keys are normalized to lowercase for matching (browsers report
777777+ /// uppercase when modifiers like Ctrl are held).
775778 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> {
776776- self.bindings.get(combo).cloned().map(|a| a.with_range(range))
779779+ // Try exact match first
780780+ if let Some(action) = self.bindings.get(combo) {
781781+ return Some(action.clone().with_range(range));
782782+ }
783783+784784+ // For character keys, try lowercase version (browsers report "A" when Ctrl+A)
785785+ if let Key::Character(ref s) = combo.key {
786786+ let lower = s.to_lowercase();
787787+ if lower != s.as_str() {
788788+ let normalized = KeyCombo {
789789+ key: Key::Character(lower.into()),
790790+ modifiers: combo.modifiers,
791791+ };
792792+ if let Some(action) = self.bindings.get(&normalized) {
793793+ return Some(action.clone().with_range(range));
794794+ }
795795+ }
796796+ }
797797+798798+ None
777799 }
778800779801 /// Add or replace a keybinding.
+6-1
crates/weaver-editor-core/src/text.rs
···7676 let search_start = offset.saturating_sub(BLOCK_SYNTAX_ZONE + 1);
7777 match self.slice(search_start..offset) {
7878 Some(s) => match s.rfind('\n') {
7979- Some(pos) => (offset - search_start - pos - 1) <= BLOCK_SYNTAX_ZONE,
7979+ Some(pos) => {
8080+ // Distance from character after newline to current offset
8181+ let newline_abs_pos = search_start + pos;
8282+ let dist = offset.saturating_sub(newline_abs_pos + 1);
8383+ dist <= BLOCK_SYNTAX_ZONE
8484+ }
8085 None => false, // No newline in range, offset > BLOCK_SYNTAX_ZONE.
8186 },
8287 None => false,
+59-9
crates/weaver-editor-core/src/writer/events.rs
···3838 // For End events, emit any trailing content within the event's range
3939 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
4040 //
4141- // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough),
4141+ // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough, Link),
4242 // the closing syntax must be emitted AFTER the closing HTML tag, not before.
4343- // Otherwise the closing `**` span ends up INSIDE the <strong> element.
4343+ // Otherwise the closing `**` or `]]` span ends up INSIDE the element.
4444 // These tags handle their own closing syntax in end_tag().
4545 // Image and Embed handle ALL their syntax in the Start event, so exclude them too.
4646 let is_self_handled_end = matches!(
···4949 TagEnd::Strong
5050 | TagEnd::Emphasis
5151 | TagEnd::Strikethrough
5252+ | TagEnd::Link
5253 | TagEnd::Image
5354 | TagEnd::Embed
5455 )
···6061 } else if !matches!(&event, Event::End(_)) {
6162 // For paragraph-level start events, capture pre-gap position so the
6263 // paragraph's char_range includes leading whitespace/gap content.
6464+ // Note: BlockQuote is NOT included - it defers `>` syntax to emit inside
6565+ // the next paragraph via pending_blockquote_range.
6366 let is_para_start = matches!(
6467 &event,
6568 Event::Start(
···6770 | Tag::Heading { .. }
6871 | Tag::CodeBlock(_)
6972 | Tag::List(_)
7070- | Tag::BlockQuote(_)
7373+ | Tag::FootnoteDefinition(_)
7174 | Tag::HtmlBlock
7275 )
7376 );
···78817982 // For other events, emit any gap before range.start
8083 // (emit_syntax handles char offset tracking)
8181- self.emit_gap_before(range.start)?;
8484+ //
8585+ // EXCEPTION: For Paragraph starts when we have pending_blockquote_range,
8686+ // skip gap emission here - the Paragraph handler will emit the `>` marker
8787+ // inside the <p> tag so it gets proper visibility toggling.
8888+ let skip_gap_for_blockquote = matches!(&event, Event::Start(Tag::Paragraph(_)))
8989+ && self.pending_blockquote_range.is_some();
9090+ if !skip_gap_for_blockquote {
9191+ self.emit_gap_before(range.start)?;
9292+ }
8293 }
8394 // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML
84958596 // Store last_byte before processing
8697 let last_byte_before = self.last_byte_offset;
87989999+ // Check if this is a container start - container ranges span their entire content,
100100+ // so we should NOT jump last_byte_offset to range.end for these.
101101+ let is_container_start = matches!(
102102+ &event,
103103+ Event::Start(
104104+ Tag::BlockQuote(_)
105105+ | Tag::List(_)
106106+ | Tag::Item
107107+ | Tag::Table(_)
108108+ | Tag::TableHead
109109+ | Tag::TableRow
110110+ | Tag::TableCell
111111+ | Tag::FootnoteDefinition(_)
112112+ )
113113+ );
114114+88115 // Process the event (passing range for tag syntax)
89116 self.process_event(event, range.clone())?;
901179191- // Update tracking - but don't override if start_tag manually updated it
9292- // (for inline formatting tags that emit opening syntax)
9393- if self.last_byte_offset == last_byte_before {
9494- // Event didn't update offset, so we update it
118118+ // Update tracking - but don't override if:
119119+ // 1. start_tag manually updated it (for inline formatting tags that emit opening syntax)
120120+ // 2. This is a container start (range.end is end of whole container, not current position)
121121+ if self.last_byte_offset == last_byte_before && !is_container_start {
95122 self.last_byte_offset = range.end;
96123 }
9797- // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
124124+ // else: Event updated offset, or it's a container - keep current value
98125 }
99126100127 // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing.
···164191 || !self.current_para.syntax_spans.is_empty()
165192 || !self.ref_collector.refs.is_empty()
166193 {
194194+ // If we have offset_maps but no paragraph range was recorded (e.g., pure newlines
195195+ // with no actual paragraph from the parser), synthesize a range from the maps.
196196+ if !self.current_para.offset_maps.is_empty() {
197197+ let maps = &self.current_para.offset_maps;
198198+ let byte_start = maps.iter().map(|m| m.byte_range.start).min().unwrap_or(0);
199199+ let byte_end = maps.iter().map(|m| m.byte_range.end).max().unwrap_or(0);
200200+ let char_start = maps.iter().map(|m| m.char_range.start).min().unwrap_or(0);
201201+ let char_end = maps.iter().map(|m| m.char_range.end).max().unwrap_or(0);
202202+203203+ // Only add if we have actual content and no range was already recorded
204204+ if byte_end > byte_start
205205+ && self
206206+ .paragraphs
207207+ .ranges
208208+ .last()
209209+ .is_none_or(|(br, _)| br.end <= byte_start)
210210+ {
211211+ self.paragraphs
212212+ .ranges
213213+ .push((byte_start..byte_end, char_start..char_end));
214214+ }
215215+ }
216216+167217 self.offset_maps_by_para
168218 .push(std::mem::take(&mut self.current_para.offset_maps));
169219 self.syntax_spans_by_para
+2
crates/weaver-editor-core/src/writer/mod.rs
···88mod state;
99mod syntax;
1010mod tags;
1111+#[cfg(test)]
1212+mod tests;
11131214pub use embed::EditorImageResolver;
1315pub use state::*;
···232232 }
233233 self.begin_node(node_id.clone());
234234235235- // Map the start position of the paragraph (before any content)
236236- // This allows cursor to be placed at the very beginning
237237- let para_start_char = self.last_char_offset;
238238- let mapping = OffsetMapping {
239239- byte_range: range.start..range.start,
240240- char_range: para_start_char..para_start_char,
241241- node_id,
242242- char_offset_in_node: 0,
243243- child_index: Some(0), // position before first child
244244- utf16_len: 0,
245245- };
246246- self.current_para.offset_maps.push(mapping);
247247-248248- // Emit > syntax if we're inside a blockquote
235235+ // Emit > syntax if we're inside a blockquote (BEFORE start mapping)
249236 if let Some(bq_range) = self.pending_blockquote_range.take() {
250237 if bq_range.start < bq_range.end {
251238 let raw_text = &self.source[bq_range.clone()];
252239 if let Some(gt_pos) = raw_text.find('>') {
253253- // Extract > [!NOTE] or just >
240240+ // Extract "> " or "> [!NOTE]" etc
254241 let after_gt = &raw_text[gt_pos + 1..];
255242 let syntax_end = if after_gt.trim_start().starts_with("[!") {
256243 // Find the closing ]
···260247 gt_pos + 1
261248 }
262249 } else {
263263- // Just > and maybe a space
264264- (gt_pos + 1).min(raw_text.len())
250250+ // Include > and the space after it (for consistent hiding)
251251+ if after_gt.starts_with(' ') {
252252+ gt_pos + 2 // "> "
253253+ } else {
254254+ gt_pos + 1 // just ">"
255255+ }
265256 };
266257267258 let syntax = &raw_text[gt_pos..syntax_end];
···270261 }
271262 }
272263 }
264264+265265+ // Map the start position of the paragraph (after blockquote marker if any)
266266+ // This allows cursor to be placed at the start of actual content
267267+ let para_start_byte = self.last_byte_offset;
268268+ let para_start_char = self.last_char_offset;
269269+ let mapping = OffsetMapping {
270270+ byte_range: para_start_byte..para_start_byte,
271271+ char_range: para_start_char..para_start_char,
272272+ node_id,
273273+ char_offset_in_node: self.current_node.char_offset,
274274+ child_index: Some(self.current_node.child_count),
275275+ utf16_len: 0,
276276+ };
277277+ self.current_para.offset_maps.push(mapping);
278278+273279 Ok(())
274280 }
275281 Tag::Heading {
···14661472 syntax_type: SyntaxType::Inline,
14671473 formatted_range: None, // Will be set by finalize
14681474 });
14751475+14761476+ // Record offset mapping for the ]]
14771477+ let byte_start = range.end - 2; // ]] is 2 bytes
14781478+ self.record_mapping(byte_start..range.end, char_start..char_end);
1469147914701480 self.last_char_offset = char_end;
14711481 self.last_byte_offset = range.end;
+223
crates/weaver-editor-core/src/writer/tests.rs
···11+//! Snapshot tests for EditorWriter output.
22+//!
33+//! These tests exercise edge cases in paragraph rendering, cursor positioning,
44+//! and offset mapping.
55+66+use markdown_weaver::Parser;
77+88+use crate::text::EditorRope;
99+use crate::weaver_renderer;
1010+1111+use super::EditorWriter;
1212+1313+/// Helper to render markdown and return HTML segments + paragraph ranges.
1414+fn render_markdown(source: &str) -> RenderOutput {
1515+ let rope = EditorRope::from(source);
1616+ let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter();
1717+1818+ let writer: EditorWriter<'_, _, _, (), (), ()> =
1919+ EditorWriter::new(source, &rope, parser).with_auto_incrementing_prefix(0);
2020+2121+ let result = writer.run().expect("render failed");
2222+2323+ RenderOutput {
2424+ html_segments: result.html_segments,
2525+ paragraph_ranges: result
2626+ .paragraph_ranges
2727+ .into_iter()
2828+ .map(|(byte_range, char_range)| ParagraphRange {
2929+ byte_start: byte_range.start,
3030+ byte_end: byte_range.end,
3131+ char_start: char_range.start,
3232+ char_end: char_range.end,
3333+ })
3434+ .collect(),
3535+ offset_maps: result
3636+ .offset_maps_by_paragraph
3737+ .into_iter()
3838+ .map(|maps| {
3939+ maps.into_iter()
4040+ .map(|m| OffsetMapEntry {
4141+ byte_range: format!("{}..{}", m.byte_range.start, m.byte_range.end),
4242+ char_range: format!("{}..{}", m.char_range.start, m.char_range.end),
4343+ node_id: m.node_id.to_string(),
4444+ char_offset_in_node: m.char_offset_in_node,
4545+ utf16_len: m.utf16_len,
4646+ })
4747+ .collect()
4848+ })
4949+ .collect(),
5050+ }
5151+}
5252+5353+#[derive(Debug, serde::Serialize)]
5454+struct RenderOutput {
5555+ html_segments: Vec<String>,
5656+ paragraph_ranges: Vec<ParagraphRange>,
5757+ offset_maps: Vec<Vec<OffsetMapEntry>>,
5858+}
5959+6060+#[derive(Debug, serde::Serialize)]
6161+struct ParagraphRange {
6262+ byte_start: usize,
6363+ byte_end: usize,
6464+ char_start: usize,
6565+ char_end: usize,
6666+}
6767+6868+#[derive(Debug, serde::Serialize)]
6969+struct OffsetMapEntry {
7070+ byte_range: String,
7171+ char_range: String,
7272+ node_id: String,
7373+ char_offset_in_node: usize,
7474+ utf16_len: usize,
7575+}
7676+7777+// === Trailing paragraph tests ===
7878+7979+#[test]
8080+fn test_single_paragraph_no_trailing() {
8181+ let output = render_markdown("hello world");
8282+ insta::assert_yaml_snapshot!(output);
8383+}
8484+8585+#[test]
8686+fn test_single_paragraph_single_newline() {
8787+ let output = render_markdown("hello world\n");
8888+ insta::assert_yaml_snapshot!(output);
8989+}
9090+9191+#[test]
9292+fn test_single_paragraph_double_newline() {
9393+ // This should create a synthetic trailing paragraph
9494+ let output = render_markdown("hello world\n\n");
9595+ insta::assert_yaml_snapshot!(output);
9696+}
9797+9898+#[test]
9999+fn test_single_paragraph_triple_newline() {
100100+ // Multiple trailing newlines
101101+ let output = render_markdown("hello world\n\n\n");
102102+ insta::assert_yaml_snapshot!(output);
103103+}
104104+105105+#[test]
106106+fn test_two_paragraphs() {
107107+ let output = render_markdown("first\n\nsecond");
108108+ insta::assert_yaml_snapshot!(output);
109109+}
110110+111111+#[test]
112112+fn test_two_paragraphs_trailing() {
113113+ // Two paragraphs plus trailing newlines
114114+ let output = render_markdown("first\n\nsecond\n\n");
115115+ insta::assert_yaml_snapshot!(output);
116116+}
117117+118118+// === Multiple blank lines tests ===
119119+120120+#[test]
121121+fn test_three_enters() {
122122+ // Simulates pressing enter 3 times from empty
123123+ let output = render_markdown("\n\n\n");
124124+ insta::assert_yaml_snapshot!(output);
125125+}
126126+127127+#[test]
128128+fn test_four_enters() {
129129+ // Bug report: 4th enter moves cursor backwards
130130+ let output = render_markdown("\n\n\n\n");
131131+ insta::assert_yaml_snapshot!(output);
132132+}
133133+134134+#[test]
135135+fn test_text_then_four_enters() {
136136+ let output = render_markdown("test\n\n\n\n");
137137+ insta::assert_yaml_snapshot!(output);
138138+}
139139+140140+#[test]
141141+fn test_many_blank_lines() {
142142+ // Bug report: arrow keys in many blank lines jumps to top
143143+ let output = render_markdown("start\n\n\n\n\n\nend");
144144+ insta::assert_yaml_snapshot!(output);
145145+}
146146+147147+// === Blockquote tests ===
148148+149149+#[test]
150150+fn test_blockquote_simple() {
151151+ let output = render_markdown("> quote");
152152+ insta::assert_yaml_snapshot!(output);
153153+}
154154+155155+#[test]
156156+fn test_blockquote_with_trailing() {
157157+ let output = render_markdown("> quote\n\n");
158158+ insta::assert_yaml_snapshot!(output);
159159+}
160160+161161+#[test]
162162+fn test_blockquote_multiline() {
163163+ let output = render_markdown("> line one\n> line two");
164164+ insta::assert_yaml_snapshot!(output);
165165+}
166166+167167+#[test]
168168+fn test_blockquote_then_text() {
169169+ // Bug report: can't type on right side of >
170170+ let output = render_markdown("> quote\n\ntext after");
171171+ insta::assert_yaml_snapshot!(output);
172172+}
173173+174174+// === Wikilink tests ===
175175+176176+#[test]
177177+fn test_wikilink_simple() {
178178+ let output = render_markdown("[[link]]");
179179+ insta::assert_yaml_snapshot!(output);
180180+}
181181+182182+#[test]
183183+fn test_wikilink_partial() {
184184+ // Partial wikilink
185185+ let output = render_markdown("[[word");
186186+ insta::assert_yaml_snapshot!(output);
187187+}
188188+189189+#[test]
190190+fn test_wikilink_nested_brackets() {
191191+ // Bug report: [[][]] structure causes trouble
192192+ let output = render_markdown("[[][]]");
193193+ insta::assert_yaml_snapshot!(output);
194194+}
195195+196196+#[test]
197197+fn test_wikilink_partial_inside_full() {
198198+ // Partial link inside full link
199199+ let output = render_markdown("[[outer[[inner]]outer]]");
200200+ insta::assert_yaml_snapshot!(output);
201201+}
202202+203203+#[test]
204204+fn test_many_opening_brackets() {
205205+ // From the screenshot - many [[ in sequence
206206+ let output = render_markdown("[[[[[[[[[[[[z]]\n]]]]");
207207+ insta::assert_yaml_snapshot!(output);
208208+}
209209+210210+// === Soft break / line continuation tests ===
211211+212212+#[test]
213213+fn test_soft_break() {
214214+ let output = render_markdown("line one\nline two");
215215+ insta::assert_yaml_snapshot!(output);
216216+}
217217+218218+#[test]
219219+fn test_hard_break() {
220220+ // Two trailing spaces = hard break
221221+ let output = render_markdown("line one \nline two");
222222+ insta::assert_yaml_snapshot!(output);
223223+}