···12381239 onkeydown: {
1240 let mut doc = document.clone();
1241- let keybindings = KeybindingConfig::default_for_platform(&platform::platform());
1242 move |evt| {
1243 use dioxus::prelude::keyboard_types::Key;
1244 use std::time::Duration;
···1284 }
12851286 // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.)
1287- let combo = KeyCombo::from_keyboard_event(&evt.data());
1288 let cursor_offset = doc.cursor.read().offset;
1289 let selection = *doc.selection.read();
1290 let range = selection
···12381239 onkeydown: {
1240 let mut doc = document.clone();
1241+ let keybindings = super::actions::default_keybindings(platform::platform());
1242 move |evt| {
1243 use dioxus::prelude::keyboard_types::Key;
1244 use std::time::Duration;
···1284 }
12851286 // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.)
1287+ let combo = super::actions::keycombo_from_dioxus_event(&evt.data());
1288 let cursor_offset = doc.cursor.read().offset;
1289 let selection = *doc.selection.read();
1290 let range = selection
+3-15
crates/weaver-app/src/components/editor/cursor.rs
···8//! 4. Setting cursor with web_sys Selection API
910use weaver_editor_core::OffsetMapping;
011#[cfg(all(target_family = "wasm", target_os = "unknown"))]
12use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position};
13···194 Err("no text node found in container".into())
195}
196197-/// Screen coordinates for a cursor position.
198-#[derive(Debug, Clone, Copy)]
199-pub struct CursorRect {
200- pub x: f64,
201- pub y: f64,
202- pub height: f64,
203-}
204205/// Get screen coordinates for a character offset in the editor.
206///
···290 None
291}
292293-/// A rectangle for part of a selection (one per line).
294-#[derive(Debug, Clone, Copy)]
295-pub struct SelectionRect {
296- pub x: f64,
297- pub y: f64,
298- pub width: f64,
299- pub height: f64,
300-}
301302/// Get screen rectangles for a selection range, relative to editor.
303///
···8//! 4. Setting cursor with web_sys Selection API
910use weaver_editor_core::OffsetMapping;
11+pub use weaver_editor_core::{CursorRect, SelectionRect};
12#[cfg(all(target_family = "wasm", target_os = "unknown"))]
13use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position};
14···195 Err("no text node found in container".into())
196}
197198+// CursorRect is imported from weaver_editor_core.
000000199200/// Get screen coordinates for a character offset in the editor.
201///
···285 None
286}
287288+// SelectionRect is imported from weaver_editor_core.
0000000289290/// Get screen rectangles for a selection range, relative to editor.
291///
···28use weaver_api::com_atproto::repo::strong_ref::StrongRef;
29use weaver_api::sh_weaver::embed::images::Image;
30use weaver_api::sh_weaver::embed::records::RecordEmbed;
00031use weaver_api::sh_weaver::notebook::entry::Entry;
3233/// Helper for working with editor images.
···151 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
152}
153154-/// Cursor state including position and affinity.
155-#[derive(Clone, Debug, Copy)]
156-pub struct CursorState {
157- /// Character offset in text (NOT byte offset!)
158- pub offset: usize,
159-160- /// Prefer left/right when at boundary (for vertical cursor movement)
161- pub affinity: Affinity,
162-}
163-164-/// Cursor affinity for vertical movement.
165-#[derive(Clone, Debug, Copy, PartialEq, Eq)]
166-pub enum Affinity {
167- Before,
168- After,
169-}
170-171-/// Text selection with anchor and head positions.
172-#[derive(Clone, Debug, Copy)]
173-pub struct Selection {
174- /// Where selection started
175- pub anchor: usize,
176- /// Where cursor is now
177- pub head: usize,
178-}
179-180-/// IME composition state (for international text input).
181-#[derive(Clone, Debug)]
182-pub struct CompositionState {
183- pub start_offset: usize,
184- pub text: String,
185-}
186-187-/// Information about the most recent edit, used for incremental rendering optimization.
188-/// Derives PartialEq so it can be used with Dioxus memos for change detection.
189-#[derive(Clone, Debug, PartialEq)]
190-pub struct EditInfo {
191- /// Character offset where the edit occurred
192- pub edit_char_pos: usize,
193- /// Number of characters inserted
194- pub inserted_len: usize,
195- /// Number of characters deleted
196- pub deleted_len: usize,
197- /// Whether the edit contains a newline (boundary-affecting)
198- pub contains_newline: bool,
199- /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
200- /// Edits here could affect block-level syntax like headings, lists, code fences.
201- pub in_block_syntax_zone: bool,
202- /// Document length (in chars) after this edit was applied.
203- /// Used to detect stale edit info - if current doc length doesn't match,
204- /// the edit info is from a previous render cycle and shouldn't be used.
205- pub doc_len_after: usize,
206- /// When this edit occurred. Used for idle detection in collaborative sync.
207- pub timestamp: web_time::Instant,
208-}
209-210-/// Max distance from line start where block syntax can appear.
211-/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
212-const BLOCK_SYNTAX_ZONE: usize = 6;
213214/// Pre-loaded document state that can be created outside of reactive context.
215///
···28use weaver_api::com_atproto::repo::strong_ref::StrongRef;
29use weaver_api::sh_weaver::embed::images::Image;
30use weaver_api::sh_weaver::embed::records::RecordEmbed;
31+pub use weaver_editor_core::{
32+ Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
33+};
34use weaver_api::sh_weaver::notebook::entry::Entry;
3536/// Helper for working with editor images.
···154 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
155}
156157+// CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE
158+// are imported from weaver_editor_core.
000000000000000000000000000000000000000000000000000000000159160/// Pre-loaded document state that can be created outside of reactive context.
161///
···1//! Conditional syntax visibility based on cursor position.
2//!
3-//! Implements Obsidian-style formatting character visibility: syntax markers
4-//! are hidden when cursor is not near them, revealed when cursor approaches.
5-6-use weaver_editor_core::SmolStr;
7-8-use super::document::Selection;
9-use super::paragraph::ParagraphRender;
10-use super::writer::{SyntaxSpanInfo, SyntaxType};
11-use std::collections::HashSet;
12-use std::ops::Range;
13-14-/// Determines which syntax spans should be visible based on cursor/selection.
15-#[derive(Debug, Clone, Default)]
16-pub struct VisibilityState {
17- /// Set of syn_ids that should be visible
18- pub visible_span_ids: HashSet<SmolStr>,
19-}
20-21-impl VisibilityState {
22- /// Calculate visibility based on cursor position and selection.
23- pub fn calculate(
24- cursor_offset: usize,
25- selection: Option<&Selection>,
26- syntax_spans: &[SyntaxSpanInfo],
27- paragraphs: &[ParagraphRender],
28- ) -> Self {
29- let mut visible = HashSet::new();
30-31- for span in syntax_spans {
32- // Find the paragraph containing this span for boundary clamping
33- let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
3435- let should_show = match span.syntax_type {
36- SyntaxType::Inline => {
37- // Show if cursor within formatted span content OR adjacent to markers
38- // "Adjacent" means within 1 char of the syntax boundaries,
39- // clamped to paragraph bounds (paragraphs are split by newlines,
40- // so clamping to para bounds prevents cross-line extension)
41- let extended_start =
42- safe_extend_left(span.char_range.start, 1, para_bounds.as_ref());
43- let extended_end =
44- safe_extend_right(span.char_range.end, 1, para_bounds.as_ref());
45- let extended_range = extended_start..extended_end;
4647- // Also show if cursor is anywhere in the formatted_range
48- // (the region between paired opening/closing markers)
49- // Extend by 1 char on BOTH sides for symmetric "approaching" behavior,
50- // clamped to paragraph bounds.
51- let in_formatted_region = span
52- .formatted_range
53- .as_ref()
54- .map(|r| {
55- let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref());
56- let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref());
57- cursor_offset >= ext_start && cursor_offset <= ext_end
58- })
59- .unwrap_or(false);
60-61- let in_extended = extended_range.contains(&cursor_offset);
62- let result = in_extended
63- || in_formatted_region
64- || selection_overlaps(selection, &span.char_range)
65- || span
66- .formatted_range
67- .as_ref()
68- .map(|r| selection_overlaps(selection, r))
69- .unwrap_or(false);
70-71- result
72- }
73- SyntaxType::Block => {
74- // Show if cursor anywhere in same paragraph (with slop for edge cases)
75- // The slop handles typing at the end of a heading like "# |"
76- let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
77- let in_paragraph = para_bounds
78- .as_ref()
79- .map(|p| {
80- // Extend paragraph bounds by 1 char on each side for slop
81- let ext_start = p.start.saturating_sub(1);
82- let ext_end = p.end.saturating_add(1);
83- cursor_offset >= ext_start && cursor_offset <= ext_end
84- })
85- .unwrap_or(false);
86-87- in_paragraph || selection_overlaps(selection, &span.char_range)
88- }
89- };
90-91- if should_show {
92- visible.insert(span.syn_id.clone());
93- }
94- }
95-96- tracing::debug!(
97- target: "weaver::visibility",
98- cursor_offset,
99- total_spans = syntax_spans.len(),
100- visible_count = visible.len(),
101- "calculated visibility"
102- );
103-104- Self {
105- visible_span_ids: visible,
106- }
107- }
108-109- /// Check if a specific span should be visible.
110- pub fn is_visible(&self, syn_id: &str) -> bool {
111- self.visible_span_ids.contains(syn_id)
112- }
113-}
114-115-/// Check if selection overlaps with a char range.
116-fn selection_overlaps(selection: Option<&Selection>, range: &Range<usize>) -> bool {
117- let Some(sel) = selection else {
118- return false;
119- };
120-121- let sel_start = sel.anchor.min(sel.head);
122- let sel_end = sel.anchor.max(sel.head);
123-124- // Check if ranges overlap
125- sel_start < range.end && sel_end > range.start
126-}
127-128-/// Check if cursor is in the same paragraph as a syntax span.
129-#[allow(dead_code)]
130-fn cursor_in_same_paragraph(
131- cursor_offset: usize,
132- syntax_range: &Range<usize>,
133- paragraphs: &[ParagraphRender],
134-) -> bool {
135- // Find which paragraph contains the syntax span
136- for para in paragraphs {
137- // Skip gap paragraphs (they have no syntax spans)
138- if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
139- continue;
140- }
141-142- // Check if this paragraph contains the syntax span
143- if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
144- // Check if cursor is also in this paragraph
145- return para.char_range.contains(&cursor_offset);
146- }
147- }
148-149- false
150-}
151-152-/// Find the paragraph bounds containing a syntax span.
153-fn find_paragraph_bounds(
154- syntax_range: &Range<usize>,
155- paragraphs: &[ParagraphRender],
156-) -> Option<Range<usize>> {
157- for para in paragraphs {
158- // Skip gap paragraphs
159- if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
160- continue;
161- }
162-163- if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
164- return Some(para.char_range.clone());
165- }
166- }
167- None
168-}
169-170-/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds.
171-///
172-/// Paragraphs are already split by newlines, so clamping to paragraph bounds
173-/// naturally prevents extending across line boundaries.
174-fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
175- let min_pos = para_bounds.map(|p| p.start).unwrap_or(0);
176- pos.saturating_sub(amount).max(min_pos)
177-}
178-179-/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds.
180-///
181-/// Paragraphs are already split by newlines, so clamping to paragraph bounds
182-/// naturally prevents extending across line boundaries.
183-fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
184- let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX);
185- pos.saturating_add(amount).min(max_pos)
186-}
187-188-/// Update syntax span visibility in the DOM based on cursor position.
189-///
190-/// Toggles the "hidden" class on syntax spans based on calculated visibility.
191-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
192-pub fn update_syntax_visibility(
193- cursor_offset: usize,
194- selection: Option<&Selection>,
195- syntax_spans: &[SyntaxSpanInfo],
196- paragraphs: &[ParagraphRender],
197-) {
198- use wasm_bindgen::JsCast;
199-200- let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
201-202- let Some(window) = web_sys::window() else {
203- return;
204- };
205- let Some(document) = window.document() else {
206- return;
207- };
208-209- // Single querySelectorAll instead of N individual queries
210- let Ok(node_list) = document.query_selector_all("[data-syn-id]") else {
211- return;
212- };
213-214- for i in 0..node_list.length() {
215- let Some(node) = node_list.item(i) else {
216- continue;
217- };
218-219- // Cast to Element to access attributes and class_list
220- let Some(element) = node.dyn_ref::<web_sys::Element>() else {
221- continue;
222- };
223-224- let Some(syn_id) = element.get_attribute("data-syn-id") else {
225- continue;
226- };
227-228- let class_list = element.class_list();
229- if visibility.is_visible(&syn_id) {
230- let _ = class_list.remove_1("hidden");
231- } else {
232- let _ = class_list.add_1("hidden");
233- }
234- }
235-}
236-237-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
238-pub fn update_syntax_visibility(
239- _cursor_offset: usize,
240- _selection: Option<&Selection>,
241- _syntax_spans: &[SyntaxSpanInfo],
242- _paragraphs: &[ParagraphRender],
243-) {
244- // No-op on non-wasm
245-}
246-247-#[cfg(test)]
248-mod tests {
249- use super::*;
250-251- fn make_span(
252- syn_id: &str,
253- start: usize,
254- end: usize,
255- syntax_type: SyntaxType,
256- ) -> SyntaxSpanInfo {
257- SyntaxSpanInfo {
258- syn_id: syn_id.into(),
259- char_range: start..end,
260- syntax_type,
261- formatted_range: None,
262- }
263- }
264-265- fn make_span_with_range(
266- syn_id: &str,
267- start: usize,
268- end: usize,
269- syntax_type: SyntaxType,
270- formatted_range: Range<usize>,
271- ) -> SyntaxSpanInfo {
272- SyntaxSpanInfo {
273- syn_id: syn_id.into(),
274- char_range: start..end,
275- syntax_type,
276- formatted_range: Some(formatted_range),
277- }
278- }
279-280- fn make_para(start: usize, end: usize, syntax_spans: Vec<SyntaxSpanInfo>) -> ParagraphRender {
281- ParagraphRender {
282- id: format!("test-{}-{}", start, end),
283- byte_range: start..end,
284- char_range: start..end,
285- html: String::new(),
286- offset_map: vec![],
287- syntax_spans,
288- source_hash: 0,
289- }
290- }
291-292- #[test]
293- fn test_inline_visibility_cursor_inside() {
294- // **bold** at chars 0-2 (opening **) and 6-8 (closing **)
295- // Text positions: 0-1 = **, 2-5 = bold, 6-7 = **
296- // formatted_range is 0..8 (the whole **bold** region)
297- let spans = vec![
298- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
299- make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
300- ];
301- let paras = vec![make_para(0, 8, spans.clone())];
302-303- // Cursor at position 4 (middle of "bold", inside formatted region)
304- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
305- assert!(
306- vis.is_visible("s0"),
307- "opening ** should be visible when cursor inside formatted region"
308- );
309- assert!(
310- vis.is_visible("s1"),
311- "closing ** should be visible when cursor inside formatted region"
312- );
313-314- // Cursor at position 2 (adjacent to opening **, start of "bold")
315- let vis = VisibilityState::calculate(2, None, &spans, ¶s);
316- assert!(
317- vis.is_visible("s0"),
318- "opening ** should be visible when cursor adjacent at start of bold"
319- );
320-321- // Cursor at position 5 (adjacent to closing **, end of "bold")
322- let vis = VisibilityState::calculate(5, None, &spans, ¶s);
323- assert!(
324- vis.is_visible("s1"),
325- "closing ** should be visible when cursor adjacent at end of bold"
326- );
327- }
328-329- #[test]
330- fn test_inline_visibility_without_formatted_range() {
331- // Test without formatted_range - just adjacency-based visibility
332- let spans = vec![
333- make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range)
334- make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range)
335- ];
336- let paras = vec![make_para(0, 8, spans.clone())];
337-338- // Cursor at position 4 (middle of "bold", not adjacent to either marker)
339- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
340- assert!(
341- !vis.is_visible("s0"),
342- "opening ** should be hidden when no formatted_range and cursor not adjacent"
343- );
344- assert!(
345- !vis.is_visible("s1"),
346- "closing ** should be hidden when no formatted_range and cursor not adjacent"
347- );
348- }
349-350- #[test]
351- fn test_inline_visibility_cursor_adjacent() {
352- // "test **bold** after"
353- // 5 7
354- let spans = vec![
355- make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
356- ];
357- let paras = vec![make_para(0, 19, spans.clone())];
358-359- // Cursor at position 4 (one before ** which starts at 5)
360- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
361- assert!(
362- vis.is_visible("s0"),
363- "** should be visible when cursor adjacent"
364- );
365-366- // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
367- let vis = VisibilityState::calculate(7, None, &spans, ¶s);
368- assert!(
369- vis.is_visible("s0"),
370- "** should be visible when cursor adjacent after span"
371- );
372- }
373-374- #[test]
375- fn test_inline_visibility_cursor_far() {
376- let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
377- let paras = vec![make_para(0, 33, spans.clone())];
378-379- // Cursor at position 0 (far from **)
380- let vis = VisibilityState::calculate(0, None, &spans, ¶s);
381- assert!(
382- !vis.is_visible("s0"),
383- "** should be hidden when cursor far away"
384- );
385- }
386-387- #[test]
388- fn test_block_visibility_same_paragraph() {
389- // # at start of heading
390- let spans = vec![
391- make_span("s0", 0, 2, SyntaxType::Block), // "# "
392- ];
393- let paras = vec![
394- make_para(0, 10, spans.clone()), // heading paragraph
395- make_para(12, 30, vec![]), // next paragraph
396- ];
397-398- // Cursor at position 5 (inside heading)
399- let vis = VisibilityState::calculate(5, None, &spans, ¶s);
400- assert!(
401- vis.is_visible("s0"),
402- "# should be visible when cursor in same paragraph"
403- );
404- }
405-406- #[test]
407- fn test_block_visibility_different_paragraph() {
408- let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
409- let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
410-411- // Cursor at position 20 (in second paragraph)
412- let vis = VisibilityState::calculate(20, None, &spans, ¶s);
413- assert!(
414- !vis.is_visible("s0"),
415- "# should be hidden when cursor in different paragraph"
416- );
417- }
418-419- #[test]
420- fn test_selection_reveals_syntax() {
421- let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
422- let paras = vec![make_para(0, 24, spans.clone())];
423-424- // Selection overlaps the syntax span
425- let selection = Selection {
426- anchor: 3,
427- head: 10,
428- };
429- let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
430- assert!(
431- vis.is_visible("s0"),
432- "** should be visible when selection overlaps"
433- );
434- }
435-436- #[test]
437- fn test_paragraph_boundary_blocks_extension() {
438- // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1,
439- // even if cursor is only 1 char after the paragraph boundary
440- // (paragraph bounds clamp the extension)
441- let spans = vec![
442- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
443- make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
444- ];
445- let paras = vec![
446- make_para(0, 8, spans.clone()), // "**bold**"
447- make_para(9, 13, vec![]), // "text" (after newline)
448- ];
449-450- // Cursor at position 9 (start of second paragraph)
451- // Should NOT reveal the closing ** because para bounds clamp extension
452- let vis = VisibilityState::calculate(9, None, &spans, ¶s);
453- assert!(
454- !vis.is_visible("s1"),
455- "closing ** should NOT be visible when cursor is in next paragraph"
456- );
457- }
458-459- #[test]
460- fn test_extension_clamps_to_paragraph() {
461- // Syntax at very start of paragraph - extension left should stop at para start
462- let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
463- let paras = vec![make_para(0, 8, spans.clone())];
464-465- // Cursor at position 0 - should still see the opening **
466- let vis = VisibilityState::calculate(0, None, &spans, ¶s);
467- assert!(
468- vis.is_visible("s0"),
469- "** at start should be visible when cursor at position 0"
470- );
471- }
472-}
···1//! Conditional syntax visibility based on cursor position.
2//!
3+//! Re-exports core visibility logic and browser DOM updates.
00000000000000000000000000000045+// Core visibility calculation.
6+pub use weaver_editor_core::VisibilityState;
00000000078+// Browser DOM updates.
9+pub use weaver_editor_browser::update_syntax_visibility;
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1+//! DOM-based syntax visibility updates.
2+//!
3+//! This module applies visibility state to the DOM by toggling CSS classes
4+//! on syntax span elements. Works with the core `VisibilityState` calculation.
5+//!
6+//! # How it works
7+//!
8+//! 1. Core's `VisibilityState::calculate()` determines which syntax spans should be visible
9+//! 2. This module's `update_syntax_visibility()` applies that state to the DOM
10+//! 3. Elements with `data-syn-id` attributes get "hidden" class toggled
11+//!
12+//! # CSS Integration
13+//!
14+//! Your CSS should hide elements with the "hidden" class:
15+//! ```css
16+//! [data-syn-id].hidden {
17+//! opacity: 0;
18+//! /* or display: none, visibility: hidden, etc. */
19+//! }
20+//! ```
21+22+use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState};
23+24+/// Update syntax span visibility in the DOM based on cursor position.
25+///
26+/// Calculates which syntax spans should be visible using `VisibilityState::calculate()`,
27+/// then toggles the "hidden" class on matching DOM elements.
28+///
29+/// # Parameters
30+/// - `cursor_offset`: Current cursor position in characters
31+/// - `selection`: Optional text selection
32+/// - `syntax_spans`: All syntax spans from rendered paragraphs
33+/// - `paragraphs`: Rendered paragraph data for boundary detection
34+///
35+/// # DOM Requirements
36+/// Syntax span elements must have `data-syn-id` attributes matching `SyntaxSpanInfo.syn_id`.
37+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
38+pub fn update_syntax_visibility(
39+ cursor_offset: usize,
40+ selection: Option<&Selection>,
41+ syntax_spans: &[SyntaxSpanInfo],
42+ paragraphs: &[ParagraphRender],
43+) {
44+ use wasm_bindgen::JsCast;
45+46+ let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
47+48+ let Some(window) = web_sys::window() else {
49+ return;
50+ };
51+ let Some(document) = window.document() else {
52+ return;
53+ };
54+55+ // Single querySelectorAll instead of N individual queries.
56+ let Ok(node_list) = document.query_selector_all("[data-syn-id]") else {
57+ return;
58+ };
59+60+ for i in 0..node_list.length() {
61+ let Some(node) = node_list.item(i) else {
62+ continue;
63+ };
64+65+ let Some(element) = node.dyn_ref::<web_sys::Element>() else {
66+ continue;
67+ };
68+69+ let Some(syn_id) = element.get_attribute("data-syn-id") else {
70+ continue;
71+ };
72+73+ let class_list = element.class_list();
74+ if visibility.is_visible(&syn_id) {
75+ let _ = class_list.remove_1("hidden");
76+ } else {
77+ let _ = class_list.add_1("hidden");
78+ }
79+ }
80+}
81+82+/// No-op on non-WASM targets.
83+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
84+pub fn update_syntax_visibility(
85+ _cursor_offset: usize,
86+ _selection: Option<&Selection>,
87+ _syntax_spans: &[SyntaxSpanInfo],
88+ _paragraphs: &[ParagraphRender],
89+) {
90+}
···181 }
182}
18300000000000000000000184/// All possible editor actions.
185///
186/// These represent semantic operations on the document, decoupled from
···181 }
182}
183184+/// High-level formatting actions for markdown editing.
185+///
186+/// These represent user-initiated formatting operations that wrap or modify
187+/// text with markdown syntax.
188+#[derive(Debug, Clone, PartialEq, Eq)]
189+#[non_exhaustive]
190+pub enum FormatAction {
191+ Bold,
192+ Italic,
193+ Strikethrough,
194+ Code,
195+ Link,
196+ Image,
197+ /// Heading level 1-6.
198+ Heading(u8),
199+ BulletList,
200+ NumberedList,
201+ Quote,
202+}
203+204/// All possible editor actions.
205///
206/// These represent semantic operations on the document, decoupled from
+7-1
crates/weaver-editor-core/src/lib.rs
···16pub mod paragraph;
17pub mod platform;
18pub mod render;
019pub mod syntax;
20pub mod text;
21pub mod text_helpers;
···43pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
44pub use platform::{CursorPlatform, CursorSync, PlatformError};
45pub use actions::{
46- EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range,
047};
48pub use execute::execute_action;
49pub use text_helpers::{
···51 find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
52 is_zero_width_char,
53};
0000
···16pub mod paragraph;
17pub mod platform;
18pub mod render;
19+pub mod render_cache;
20pub mod syntax;
21pub mod text;
22pub mod text_helpers;
···44pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
45pub use platform::{CursorPlatform, CursorSync, PlatformError};
46pub use actions::{
47+ EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult,
48+ Modifiers, Range,
49};
50pub use execute::execute_action;
51pub use text_helpers::{
···53 find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
54 is_zero_width_char,
55};
56+pub use render_cache::{
57+ CachedParagraph, IncrementalRenderResult, RenderCache, apply_delta, is_boundary_affecting,
58+ render_paragraphs_incremental,
59+};