···1+//! Cursor position restoration in the DOM.
2+//!
3+//! After re-rendering HTML, we need to restore the cursor to its original
4+//! position in the source text. This involves:
5+//! 1. Finding the offset mapping for the cursor's char position
6+//! 2. Getting the DOM element by node ID
7+//! 3. Walking text nodes to find the UTF-16 offset within the element
8+//! 4. Setting cursor with web_sys Selection API
9+10+use super::offset_map::{find_mapping_for_char, OffsetMapping};
11+use jumprope::JumpRopeBuf;
12+13+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
14+use wasm_bindgen::JsCast;
15+16+/// Restore cursor position in the DOM after re-render.
17+///
18+/// # Arguments
19+/// - `rope`: The document content (for length bounds checking)
20+/// - `char_offset`: Cursor position as char offset in rope
21+/// - `offset_map`: Mappings from source to DOM positions
22+/// - `editor_id`: DOM ID of the contenteditable element
23+///
24+/// # Algorithm
25+/// 1. Find offset mapping containing char_offset
26+/// 2. Get DOM node by mapping.node_id
27+/// 3. Walk text nodes to find UTF-16 position
28+/// 4. Set cursor with Selection API
29+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
30+pub fn restore_cursor_position(
31+ rope: &JumpRopeBuf,
32+ char_offset: usize,
33+ offset_map: &[OffsetMapping],
34+ editor_id: &str,
35+) -> Result<(), wasm_bindgen::JsValue> {
36+ // Bounds check
37+ let max_offset = rope.len_chars();
38+ if char_offset > max_offset {
39+ return Err(format!("cursor offset {} > document length {}", char_offset, max_offset).into());
40+ }
41+42+ // Empty document - no cursor to restore
43+ if offset_map.is_empty() || max_offset == 0 {
44+ return Ok(());
45+ }
46+47+ // Find mapping for this cursor position
48+ let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset)
49+ .ok_or("no mapping found for cursor offset")?;
50+51+ // If cursor is in invisible content, snap to next visible position
52+ // For now, we'll still use the mapping but this is a future enhancement
53+ if should_snap {
54+ tracing::debug!("cursor in invisible content at offset {}", char_offset);
55+ }
56+57+ // Get window and document
58+ let window = web_sys::window().ok_or("no window")?;
59+ let document = window.document().ok_or("no document")?;
60+61+ // Get the container element by node ID
62+ let container = document
63+ .get_element_by_id(&mapping.node_id)
64+ .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
65+66+ // Set selection using Range API
67+ let selection = window
68+ .get_selection()?
69+ .ok_or("no selection object")?;
70+ let range = document.create_range()?;
71+72+ // Check if this is an element-based position (e.g., after <br />)
73+ if let Some(child_index) = mapping.child_index {
74+ // Position cursor at child index in the element
75+ range.set_start(&container, child_index as u32)?;
76+ } else {
77+ // Position cursor in text content
78+ let container_element = container.dyn_into::<web_sys::HtmlElement>()?;
79+ let offset_in_range = char_offset - mapping.char_range.start;
80+ let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
81+ let (text_node, node_offset) = find_text_node_at_offset(&container_element, target_utf16_offset)?;
82+ range.set_start(&text_node, node_offset as u32)?;
83+ }
84+85+ range.collapse_with_to_start(true);
86+87+ selection.remove_all_ranges()?;
88+ selection.add_range(&range)?;
89+90+ Ok(())
91+}
92+93+/// Find text node at given UTF-16 offset within element.
94+///
95+/// Walks all text nodes in the container, accumulating their UTF-16 lengths
96+/// until we find the node containing the target offset.
97+///
98+/// Returns (text_node, offset_within_node).
99+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
100+fn find_text_node_at_offset(
101+ container: &web_sys::HtmlElement,
102+ target_utf16_offset: usize,
103+) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> {
104+ let document = web_sys::window()
105+ .ok_or("no window")?
106+ .document()
107+ .ok_or("no document")?;
108+109+ // Create tree walker to find text nodes
110+ // SHOW_TEXT = 4 (from DOM spec)
111+ let walker = document.create_tree_walker_with_what_to_show(
112+ container,
113+ 4,
114+ )?;
115+116+ let mut accumulated_utf16 = 0;
117+ let mut last_node: Option<web_sys::Node> = None;
118+119+ while let Some(node) = walker.next_node()? {
120+ last_node = Some(node.clone());
121+122+ if let Some(text) = node.text_content() {
123+ let text_len = text.encode_utf16().count();
124+125+ // Found the node containing target offset
126+ if accumulated_utf16 + text_len >= target_utf16_offset {
127+ let offset_in_node = target_utf16_offset - accumulated_utf16;
128+ return Ok((node, offset_in_node));
129+ }
130+131+ accumulated_utf16 += text_len;
132+ }
133+ }
134+135+ // Fallback: return last node at its end
136+ // This handles cursor at end of document
137+ if let Some(node) = last_node {
138+ if let Some(text) = node.text_content() {
139+ let text_len = text.encode_utf16().count();
140+ return Ok((node, text_len));
141+ }
142+ }
143+144+ Err("no text node found in container".into())
145+}
146+147+/// Non-WASM stub for testing
148+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
149+pub fn restore_cursor_position(
150+ _rope: &JumpRopeBuf,
151+ _char_offset: usize,
152+ _offset_map: &[OffsetMapping],
153+ _editor_id: &str,
154+) -> Result<(), String> {
155+ Ok(())
156+}
···1+//! Markdown rendering for the editor.
2+//!
3+//! Phase 2: Full-document rendering with formatting characters visible as styled spans.
4+//! Future: Incremental paragraph rendering and contextual formatting visibility.
5+//!
6+//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
7+8+use markdown_weaver::Parser;
9+use super::offset_map::RenderResult;
10+use super::writer::EditorWriter;
11+12+/// Render markdown to HTML with visible formatting characters and offset mappings.
13+///
14+/// This function performs a full re-render of the document on every change.
15+/// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility.
16+///
17+/// Uses EditorWriter which processes offset_iter events to detect consumed
18+/// formatting characters and emit them as `<span class="md-syntax-*">` elements.
19+///
20+/// Returns both the rendered HTML and offset mappings for cursor restoration.
21+///
22+/// # Phase 2 features
23+/// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block)
24+/// - Offset map generation for cursor restoration
25+/// - Full document re-render (fast enough for current needs)
26+///
27+/// # Future improvements
28+/// - Paragraph-level incremental rendering
29+/// - Contextual formatting hiding based on cursor position
30+pub fn render_markdown_simple(source: &str) -> RenderResult {
31+ use jumprope::JumpRopeBuf;
32+33+ let source_rope = JumpRopeBuf::from(source);
34+ let parser = Parser::new_ext(source, weaver_renderer::default_md_options())
35+ .into_offset_iter();
36+ let mut output = String::new();
37+38+ match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() {
39+ Ok(offset_map) => RenderResult {
40+ html: output,
41+ offset_map,
42+ },
43+ Err(_) => {
44+ // Fallback to empty result on error
45+ RenderResult {
46+ html: String::new(),
47+ offset_map: Vec::new(),
48+ }
49+ }
50+ }
51+}
···1+//! Editor view - wraps the MarkdownEditor component for the /editor route.
2+3+use dioxus::prelude::*;
4+use crate::components::editor::MarkdownEditor;
5+6+/// Editor page view.
7+///
8+/// Displays the markdown editor at the /editor route for testing during development.
9+/// Eventually this will be integrated into the notebook editing workflow.
10+#[component]
11+pub fn Editor() -> Element {
12+ rsx! {
13+ div { class: "editor-page",
14+ MarkdownEditor { initial_content: None }
15+ }
16+ }
17+}
+1
crates/weaver-app/src/views/mod.rs
···27pub use callback::Callback;
2829mod editor;
0
···27pub use callback::Callback;
2829mod editor;
30+pub use editor::Editor;