···11+//! Cursor position restoration in the DOM.
22+//!
33+//! After re-rendering HTML, we need to restore the cursor to its original
44+//! position in the source text. This involves:
55+//! 1. Finding the offset mapping for the cursor's char position
66+//! 2. Getting the DOM element by node ID
77+//! 3. Walking text nodes to find the UTF-16 offset within the element
88+//! 4. Setting cursor with web_sys Selection API
99+1010+use super::offset_map::{find_mapping_for_char, OffsetMapping};
1111+use jumprope::JumpRopeBuf;
1212+1313+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1414+use wasm_bindgen::JsCast;
1515+1616+/// Restore cursor position in the DOM after re-render.
1717+///
1818+/// # Arguments
1919+/// - `rope`: The document content (for length bounds checking)
2020+/// - `char_offset`: Cursor position as char offset in rope
2121+/// - `offset_map`: Mappings from source to DOM positions
2222+/// - `editor_id`: DOM ID of the contenteditable element
2323+///
2424+/// # Algorithm
2525+/// 1. Find offset mapping containing char_offset
2626+/// 2. Get DOM node by mapping.node_id
2727+/// 3. Walk text nodes to find UTF-16 position
2828+/// 4. Set cursor with Selection API
2929+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
3030+pub fn restore_cursor_position(
3131+ rope: &JumpRopeBuf,
3232+ char_offset: usize,
3333+ offset_map: &[OffsetMapping],
3434+ editor_id: &str,
3535+) -> Result<(), wasm_bindgen::JsValue> {
3636+ // Bounds check
3737+ let max_offset = rope.len_chars();
3838+ if char_offset > max_offset {
3939+ return Err(format!("cursor offset {} > document length {}", char_offset, max_offset).into());
4040+ }
4141+4242+ // Empty document - no cursor to restore
4343+ if offset_map.is_empty() || max_offset == 0 {
4444+ return Ok(());
4545+ }
4646+4747+ // Find mapping for this cursor position
4848+ let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset)
4949+ .ok_or("no mapping found for cursor offset")?;
5050+5151+ // If cursor is in invisible content, snap to next visible position
5252+ // For now, we'll still use the mapping but this is a future enhancement
5353+ if should_snap {
5454+ tracing::debug!("cursor in invisible content at offset {}", char_offset);
5555+ }
5656+5757+ // Get window and document
5858+ let window = web_sys::window().ok_or("no window")?;
5959+ let document = window.document().ok_or("no document")?;
6060+6161+ // Get the container element by node ID
6262+ let container = document
6363+ .get_element_by_id(&mapping.node_id)
6464+ .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
6565+6666+ // Set selection using Range API
6767+ let selection = window
6868+ .get_selection()?
6969+ .ok_or("no selection object")?;
7070+ let range = document.create_range()?;
7171+7272+ // Check if this is an element-based position (e.g., after <br />)
7373+ if let Some(child_index) = mapping.child_index {
7474+ // Position cursor at child index in the element
7575+ range.set_start(&container, child_index as u32)?;
7676+ } else {
7777+ // Position cursor in text content
7878+ let container_element = container.dyn_into::<web_sys::HtmlElement>()?;
7979+ let offset_in_range = char_offset - mapping.char_range.start;
8080+ let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
8181+ let (text_node, node_offset) = find_text_node_at_offset(&container_element, target_utf16_offset)?;
8282+ range.set_start(&text_node, node_offset as u32)?;
8383+ }
8484+8585+ range.collapse_with_to_start(true);
8686+8787+ selection.remove_all_ranges()?;
8888+ selection.add_range(&range)?;
8989+9090+ Ok(())
9191+}
9292+9393+/// Find text node at given UTF-16 offset within element.
9494+///
9595+/// Walks all text nodes in the container, accumulating their UTF-16 lengths
9696+/// until we find the node containing the target offset.
9797+///
9898+/// Returns (text_node, offset_within_node).
9999+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
100100+fn find_text_node_at_offset(
101101+ container: &web_sys::HtmlElement,
102102+ target_utf16_offset: usize,
103103+) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> {
104104+ let document = web_sys::window()
105105+ .ok_or("no window")?
106106+ .document()
107107+ .ok_or("no document")?;
108108+109109+ // Create tree walker to find text nodes
110110+ // SHOW_TEXT = 4 (from DOM spec)
111111+ let walker = document.create_tree_walker_with_what_to_show(
112112+ container,
113113+ 4,
114114+ )?;
115115+116116+ let mut accumulated_utf16 = 0;
117117+ let mut last_node: Option<web_sys::Node> = None;
118118+119119+ while let Some(node) = walker.next_node()? {
120120+ last_node = Some(node.clone());
121121+122122+ if let Some(text) = node.text_content() {
123123+ let text_len = text.encode_utf16().count();
124124+125125+ // Found the node containing target offset
126126+ if accumulated_utf16 + text_len >= target_utf16_offset {
127127+ let offset_in_node = target_utf16_offset - accumulated_utf16;
128128+ return Ok((node, offset_in_node));
129129+ }
130130+131131+ accumulated_utf16 += text_len;
132132+ }
133133+ }
134134+135135+ // Fallback: return last node at its end
136136+ // This handles cursor at end of document
137137+ if let Some(node) = last_node {
138138+ if let Some(text) = node.text_content() {
139139+ let text_len = text.encode_utf16().count();
140140+ return Ok((node, text_len));
141141+ }
142142+ }
143143+144144+ Err("no text node found in container".into())
145145+}
146146+147147+/// Non-WASM stub for testing
148148+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
149149+pub fn restore_cursor_position(
150150+ _rope: &JumpRopeBuf,
151151+ _char_offset: usize,
152152+ _offset_map: &[OffsetMapping],
153153+ _editor_id: &str,
154154+) -> Result<(), String> {
155155+ Ok(())
156156+}
···11+//! Offset conversion utilities for converting between different offset systems.
22+//!
33+//! The editor deals with multiple offset systems:
44+//! 1. **JumpRope**: Unicode scalar values (Rust `char` count)
55+//! 2. **markdown-weaver**: UTF-8 byte offsets
66+//! 3. **Rust strings**: UTF-8 byte indexing
77+//! 4. **JavaScript DOM**: UTF-16 code units (Phase 2+)
88+//!
99+//! # Performance Notes
1010+//!
1111+//! **Prefer JumpRope's built-in methods:**
1212+//! - `rope.len_chars()` - O(1) character count
1313+//! - `rope.len_bytes()` - O(1) byte count
1414+//! - `rope.len_wchars()` - O(1) UTF-16 code unit count (Phase 2 with wchar_conversion)
1515+//!
1616+//! **Only use these conversion functions when:**
1717+//! - Converting markdown-weaver byte offsets to char offsets
1818+//! - Converting char offsets to byte offsets for markdown parsing
1919+//!
2020+//! For Phase 2+, use JumpRope's O(log n) UTF-16 conversions via the helpers below:
2121+//! - `char_to_utf16()` - O(log n)
2222+//! - `utf16_to_char()` - O(log n)
2323+2424+/// Convert JumpRope char offset to UTF-8 byte offset.
2525+///
2626+/// This is O(n) but acceptable for Phase 1 since we only render once per keystroke.
2727+/// For Phase 2+, we can optimize by caching or using string-offsets crate.
2828+///
2929+/// # Example
3030+/// ```
3131+/// let text = "Hello 🐻❄️ World";
3232+/// // "Hello " = 6 chars, 6 bytes
3333+/// // "🐻❄️" = 4 chars, 13 bytes
3434+/// // Total at char 6 = byte 6
3535+/// assert_eq!(char_to_byte(text, 6), 6);
3636+/// // Total at char 10 (after emoji) = byte 19
3737+/// assert_eq!(char_to_byte(text, 10), 19);
3838+/// ```
3939+pub fn char_to_byte(text: &str, char_offset: usize) -> usize {
4040+ text.char_indices()
4141+ .nth(char_offset)
4242+ .map(|(byte_idx, _)| byte_idx)
4343+ .unwrap_or(text.len())
4444+}
4545+4646+/// Convert UTF-8 byte offset to JumpRope char offset.
4747+///
4848+/// Used when we need to map markdown-weaver byte offsets back to rope positions.
4949+///
5050+/// # Example
5151+/// ```
5252+/// let text = "Hello 🐻❄️ World";
5353+/// assert_eq!(byte_to_char(text, 6), 6);
5454+/// assert_eq!(byte_to_char(text, 19), 10);
5555+/// ```
5656+pub fn byte_to_char(text: &str, byte_offset: usize) -> usize {
5757+ text.char_indices()
5858+ .take_while(|(idx, _)| *idx < byte_offset)
5959+ .count()
6060+}
6161+6262+/// Convert JumpRope char offset to UTF-16 code units (for DOM Selection API).
6363+///
6464+/// O(log n) - uses JumpRope's internal index.
6565+///
6666+/// # Example
6767+/// ```
6868+/// let rope = JumpRopeBuf::from("🐻❄️");
6969+/// // Polar bear is 4 chars, 5 UTF-16 code units
7070+/// assert_eq!(char_to_utf16(&rope, 0), 0);
7171+/// assert_eq!(char_to_utf16(&rope, 4), 5);
7272+/// ```
7373+pub fn char_to_utf16(rope: &jumprope::JumpRopeBuf, char_offset: usize) -> usize {
7474+ rope.borrow().chars_to_wchars(char_offset)
7575+}
7676+7777+/// Convert UTF-16 code units (from DOM) to JumpRope char offset.
7878+///
7979+/// O(log n) - uses JumpRope's internal index.
8080+///
8181+/// # Example
8282+/// ```
8383+/// let rope = JumpRopeBuf::from("🐻❄️");
8484+/// assert_eq!(utf16_to_char(&rope, 0), 0);
8585+/// assert_eq!(utf16_to_char(&rope, 5), 4);
8686+/// ```
8787+pub fn utf16_to_char(rope: &jumprope::JumpRopeBuf, utf16_offset: usize) -> usize {
8888+ rope.borrow().wchars_to_chars(utf16_offset)
8989+}
9090+9191+#[cfg(test)]
9292+mod tests {
9393+ use super::*;
9494+9595+ #[test]
9696+ fn test_ascii() {
9797+ let text = "hello";
9898+ assert_eq!(char_to_byte(text, 0), 0);
9999+ assert_eq!(char_to_byte(text, 2), 2);
100100+ assert_eq!(byte_to_char(text, 0), 0);
101101+ assert_eq!(byte_to_char(text, 2), 2);
102102+ }
103103+104104+ #[test]
105105+ fn test_emoji() {
106106+ // Polar bear: 4 chars, 13 bytes
107107+ let text = "🐻❄️";
108108+ assert_eq!(text.chars().count(), 4);
109109+ assert_eq!(text.len(), 13);
110110+111111+ assert_eq!(char_to_byte(text, 0), 0);
112112+ assert_eq!(char_to_byte(text, 4), 13);
113113+114114+ assert_eq!(byte_to_char(text, 0), 0);
115115+ assert_eq!(byte_to_char(text, 13), 4);
116116+ }
117117+118118+ #[test]
119119+ fn test_mixed() {
120120+ let text = "Hello 🐻❄️ World";
121121+ // "Hello " = 6 chars, 6 bytes
122122+ // "🐻❄️" = 4 chars, 13 bytes
123123+ // " World" = 6 chars, 6 bytes
124124+ // Total: 16 chars, 25 bytes
125125+126126+ assert_eq!(text.chars().count(), 16);
127127+ assert_eq!(text.len(), 25);
128128+129129+ // Char 6 is start of emoji (byte 6)
130130+ assert_eq!(char_to_byte(text, 6), 6);
131131+ // Char 10 is after emoji (byte 19)
132132+ assert_eq!(char_to_byte(text, 10), 19);
133133+ }
134134+}
+51
crates/weaver-app/src/components/editor/render.rs
···11+//! Markdown rendering for the editor.
22+//!
33+//! Phase 2: Full-document rendering with formatting characters visible as styled spans.
44+//! Future: Incremental paragraph rendering and contextual formatting visibility.
55+//!
66+//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
77+88+use markdown_weaver::Parser;
99+use super::offset_map::RenderResult;
1010+use super::writer::EditorWriter;
1111+1212+/// Render markdown to HTML with visible formatting characters and offset mappings.
1313+///
1414+/// This function performs a full re-render of the document on every change.
1515+/// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility.
1616+///
1717+/// Uses EditorWriter which processes offset_iter events to detect consumed
1818+/// formatting characters and emit them as `<span class="md-syntax-*">` elements.
1919+///
2020+/// Returns both the rendered HTML and offset mappings for cursor restoration.
2121+///
2222+/// # Phase 2 features
2323+/// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block)
2424+/// - Offset map generation for cursor restoration
2525+/// - Full document re-render (fast enough for current needs)
2626+///
2727+/// # Future improvements
2828+/// - Paragraph-level incremental rendering
2929+/// - Contextual formatting hiding based on cursor position
3030+pub fn render_markdown_simple(source: &str) -> RenderResult {
3131+ use jumprope::JumpRopeBuf;
3232+3333+ let source_rope = JumpRopeBuf::from(source);
3434+ let parser = Parser::new_ext(source, weaver_renderer::default_md_options())
3535+ .into_offset_iter();
3636+ let mut output = String::new();
3737+3838+ match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() {
3939+ Ok(offset_map) => RenderResult {
4040+ html: output,
4141+ offset_map,
4242+ },
4343+ Err(_) => {
4444+ // Fallback to empty result on error
4545+ RenderResult {
4646+ html: String::new(),
4747+ offset_map: Vec::new(),
4848+ }
4949+ }
5050+ }
5151+}
···11+//! Editor view - wraps the MarkdownEditor component for the /editor route.
22+33+use dioxus::prelude::*;
44+use crate::components::editor::MarkdownEditor;
55+66+/// Editor page view.
77+///
88+/// Displays the markdown editor at the /editor route for testing during development.
99+/// Eventually this will be integrated into the notebook editing workflow.
1010+#[component]
1111+pub fn Editor() -> Element {
1212+ rsx! {
1313+ div { class: "editor-page",
1414+ MarkdownEditor { initial_content: None }
1515+ }
1616+ }
1717+}
+1
crates/weaver-app/src/views/mod.rs
···2727pub use callback::Callback;
28282929mod editor;
3030+pub use editor::Editor;