···1238123812391239 onkeydown: {
12401240 let mut doc = document.clone();
12411241- let keybindings = KeybindingConfig::default_for_platform(&platform::platform());
12411241+ let keybindings = super::actions::default_keybindings(platform::platform());
12421242 move |evt| {
12431243 use dioxus::prelude::keyboard_types::Key;
12441244 use std::time::Duration;
···12841284 }
1285128512861286 // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.)
12871287- let combo = KeyCombo::from_keyboard_event(&evt.data());
12871287+ let combo = super::actions::keycombo_from_dioxus_event(&evt.data());
12881288 let cursor_offset = doc.cursor.read().offset;
12891289 let selection = *doc.selection.read();
12901290 let range = selection
+3-15
crates/weaver-app/src/components/editor/cursor.rs
···88//! 4. Setting cursor with web_sys Selection API
991010use weaver_editor_core::OffsetMapping;
1111+pub use weaver_editor_core::{CursorRect, SelectionRect};
1112#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1213use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position};
1314···194195 Err("no text node found in container".into())
195196}
196197197197-/// Screen coordinates for a cursor position.
198198-#[derive(Debug, Clone, Copy)]
199199-pub struct CursorRect {
200200- pub x: f64,
201201- pub y: f64,
202202- pub height: f64,
203203-}
198198+// CursorRect is imported from weaver_editor_core.
204199205200/// Get screen coordinates for a character offset in the editor.
206201///
···290285 None
291286}
292287293293-/// A rectangle for part of a selection (one per line).
294294-#[derive(Debug, Clone, Copy)]
295295-pub struct SelectionRect {
296296- pub x: f64,
297297- pub y: f64,
298298- pub width: f64,
299299- pub height: f64,
300300-}
288288+// SelectionRect is imported from weaver_editor_core.
301289302290/// Get screen rectangles for a selection range, relative to editor.
303291///
···2828use weaver_api::com_atproto::repo::strong_ref::StrongRef;
2929use weaver_api::sh_weaver::embed::images::Image;
3030use weaver_api::sh_weaver::embed::records::RecordEmbed;
3131+pub use weaver_editor_core::{
3232+ Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
3333+};
3134use weaver_api::sh_weaver::notebook::entry::Entry;
32353336/// Helper for working with editor images.
···151154 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
152155}
153156154154-/// Cursor state including position and affinity.
155155-#[derive(Clone, Debug, Copy)]
156156-pub struct CursorState {
157157- /// Character offset in text (NOT byte offset!)
158158- pub offset: usize,
159159-160160- /// Prefer left/right when at boundary (for vertical cursor movement)
161161- pub affinity: Affinity,
162162-}
163163-164164-/// Cursor affinity for vertical movement.
165165-#[derive(Clone, Debug, Copy, PartialEq, Eq)]
166166-pub enum Affinity {
167167- Before,
168168- After,
169169-}
170170-171171-/// Text selection with anchor and head positions.
172172-#[derive(Clone, Debug, Copy)]
173173-pub struct Selection {
174174- /// Where selection started
175175- pub anchor: usize,
176176- /// Where cursor is now
177177- pub head: usize,
178178-}
179179-180180-/// IME composition state (for international text input).
181181-#[derive(Clone, Debug)]
182182-pub struct CompositionState {
183183- pub start_offset: usize,
184184- pub text: String,
185185-}
186186-187187-/// Information about the most recent edit, used for incremental rendering optimization.
188188-/// Derives PartialEq so it can be used with Dioxus memos for change detection.
189189-#[derive(Clone, Debug, PartialEq)]
190190-pub struct EditInfo {
191191- /// Character offset where the edit occurred
192192- pub edit_char_pos: usize,
193193- /// Number of characters inserted
194194- pub inserted_len: usize,
195195- /// Number of characters deleted
196196- pub deleted_len: usize,
197197- /// Whether the edit contains a newline (boundary-affecting)
198198- pub contains_newline: bool,
199199- /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
200200- /// Edits here could affect block-level syntax like headings, lists, code fences.
201201- pub in_block_syntax_zone: bool,
202202- /// Document length (in chars) after this edit was applied.
203203- /// Used to detect stale edit info - if current doc length doesn't match,
204204- /// the edit info is from a previous render cycle and shouldn't be used.
205205- pub doc_len_after: usize,
206206- /// When this edit occurred. Used for idle detection in collaborative sync.
207207- pub timestamp: web_time::Instant,
208208-}
209209-210210-/// Max distance from line start where block syntax can appear.
211211-/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
212212-const BLOCK_SYNTAX_ZONE: usize = 6;
157157+// CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE
158158+// are imported from weaver_editor_core.
213159214160/// Pre-loaded document state that can be created outside of reactive context.
215161///
···11//! Conditional syntax visibility based on cursor position.
22//!
33-//! Implements Obsidian-style formatting character visibility: syntax markers
44-//! are hidden when cursor is not near them, revealed when cursor approaches.
55-66-use weaver_editor_core::SmolStr;
77-88-use super::document::Selection;
99-use super::paragraph::ParagraphRender;
1010-use super::writer::{SyntaxSpanInfo, SyntaxType};
1111-use std::collections::HashSet;
1212-use std::ops::Range;
1313-1414-/// Determines which syntax spans should be visible based on cursor/selection.
1515-#[derive(Debug, Clone, Default)]
1616-pub struct VisibilityState {
1717- /// Set of syn_ids that should be visible
1818- pub visible_span_ids: HashSet<SmolStr>,
1919-}
2020-2121-impl VisibilityState {
2222- /// Calculate visibility based on cursor position and selection.
2323- pub fn calculate(
2424- cursor_offset: usize,
2525- selection: Option<&Selection>,
2626- syntax_spans: &[SyntaxSpanInfo],
2727- paragraphs: &[ParagraphRender],
2828- ) -> Self {
2929- let mut visible = HashSet::new();
3030-3131- for span in syntax_spans {
3232- // Find the paragraph containing this span for boundary clamping
3333- let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
33+//! Re-exports core visibility logic and browser DOM updates.
3443535- let should_show = match span.syntax_type {
3636- SyntaxType::Inline => {
3737- // Show if cursor within formatted span content OR adjacent to markers
3838- // "Adjacent" means within 1 char of the syntax boundaries,
3939- // clamped to paragraph bounds (paragraphs are split by newlines,
4040- // so clamping to para bounds prevents cross-line extension)
4141- let extended_start =
4242- safe_extend_left(span.char_range.start, 1, para_bounds.as_ref());
4343- let extended_end =
4444- safe_extend_right(span.char_range.end, 1, para_bounds.as_ref());
4545- let extended_range = extended_start..extended_end;
55+// Core visibility calculation.
66+pub use weaver_editor_core::VisibilityState;
4674747- // Also show if cursor is anywhere in the formatted_range
4848- // (the region between paired opening/closing markers)
4949- // Extend by 1 char on BOTH sides for symmetric "approaching" behavior,
5050- // clamped to paragraph bounds.
5151- let in_formatted_region = span
5252- .formatted_range
5353- .as_ref()
5454- .map(|r| {
5555- let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref());
5656- let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref());
5757- cursor_offset >= ext_start && cursor_offset <= ext_end
5858- })
5959- .unwrap_or(false);
6060-6161- let in_extended = extended_range.contains(&cursor_offset);
6262- let result = in_extended
6363- || in_formatted_region
6464- || selection_overlaps(selection, &span.char_range)
6565- || span
6666- .formatted_range
6767- .as_ref()
6868- .map(|r| selection_overlaps(selection, r))
6969- .unwrap_or(false);
7070-7171- result
7272- }
7373- SyntaxType::Block => {
7474- // Show if cursor anywhere in same paragraph (with slop for edge cases)
7575- // The slop handles typing at the end of a heading like "# |"
7676- let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
7777- let in_paragraph = para_bounds
7878- .as_ref()
7979- .map(|p| {
8080- // Extend paragraph bounds by 1 char on each side for slop
8181- let ext_start = p.start.saturating_sub(1);
8282- let ext_end = p.end.saturating_add(1);
8383- cursor_offset >= ext_start && cursor_offset <= ext_end
8484- })
8585- .unwrap_or(false);
8686-8787- in_paragraph || selection_overlaps(selection, &span.char_range)
8888- }
8989- };
9090-9191- if should_show {
9292- visible.insert(span.syn_id.clone());
9393- }
9494- }
9595-9696- tracing::debug!(
9797- target: "weaver::visibility",
9898- cursor_offset,
9999- total_spans = syntax_spans.len(),
100100- visible_count = visible.len(),
101101- "calculated visibility"
102102- );
103103-104104- Self {
105105- visible_span_ids: visible,
106106- }
107107- }
108108-109109- /// Check if a specific span should be visible.
110110- pub fn is_visible(&self, syn_id: &str) -> bool {
111111- self.visible_span_ids.contains(syn_id)
112112- }
113113-}
114114-115115-/// Check if selection overlaps with a char range.
116116-fn selection_overlaps(selection: Option<&Selection>, range: &Range<usize>) -> bool {
117117- let Some(sel) = selection else {
118118- return false;
119119- };
120120-121121- let sel_start = sel.anchor.min(sel.head);
122122- let sel_end = sel.anchor.max(sel.head);
123123-124124- // Check if ranges overlap
125125- sel_start < range.end && sel_end > range.start
126126-}
127127-128128-/// Check if cursor is in the same paragraph as a syntax span.
129129-#[allow(dead_code)]
130130-fn cursor_in_same_paragraph(
131131- cursor_offset: usize,
132132- syntax_range: &Range<usize>,
133133- paragraphs: &[ParagraphRender],
134134-) -> bool {
135135- // Find which paragraph contains the syntax span
136136- for para in paragraphs {
137137- // Skip gap paragraphs (they have no syntax spans)
138138- if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
139139- continue;
140140- }
141141-142142- // Check if this paragraph contains the syntax span
143143- if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
144144- // Check if cursor is also in this paragraph
145145- return para.char_range.contains(&cursor_offset);
146146- }
147147- }
148148-149149- false
150150-}
151151-152152-/// Find the paragraph bounds containing a syntax span.
153153-fn find_paragraph_bounds(
154154- syntax_range: &Range<usize>,
155155- paragraphs: &[ParagraphRender],
156156-) -> Option<Range<usize>> {
157157- for para in paragraphs {
158158- // Skip gap paragraphs
159159- if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
160160- continue;
161161- }
162162-163163- if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
164164- return Some(para.char_range.clone());
165165- }
166166- }
167167- None
168168-}
169169-170170-/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds.
171171-///
172172-/// Paragraphs are already split by newlines, so clamping to paragraph bounds
173173-/// naturally prevents extending across line boundaries.
174174-fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
175175- let min_pos = para_bounds.map(|p| p.start).unwrap_or(0);
176176- pos.saturating_sub(amount).max(min_pos)
177177-}
178178-179179-/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds.
180180-///
181181-/// Paragraphs are already split by newlines, so clamping to paragraph bounds
182182-/// naturally prevents extending across line boundaries.
183183-fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
184184- let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX);
185185- pos.saturating_add(amount).min(max_pos)
186186-}
187187-188188-/// Update syntax span visibility in the DOM based on cursor position.
189189-///
190190-/// Toggles the "hidden" class on syntax spans based on calculated visibility.
191191-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
192192-pub fn update_syntax_visibility(
193193- cursor_offset: usize,
194194- selection: Option<&Selection>,
195195- syntax_spans: &[SyntaxSpanInfo],
196196- paragraphs: &[ParagraphRender],
197197-) {
198198- use wasm_bindgen::JsCast;
199199-200200- let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
201201-202202- let Some(window) = web_sys::window() else {
203203- return;
204204- };
205205- let Some(document) = window.document() else {
206206- return;
207207- };
208208-209209- // Single querySelectorAll instead of N individual queries
210210- let Ok(node_list) = document.query_selector_all("[data-syn-id]") else {
211211- return;
212212- };
213213-214214- for i in 0..node_list.length() {
215215- let Some(node) = node_list.item(i) else {
216216- continue;
217217- };
218218-219219- // Cast to Element to access attributes and class_list
220220- let Some(element) = node.dyn_ref::<web_sys::Element>() else {
221221- continue;
222222- };
223223-224224- let Some(syn_id) = element.get_attribute("data-syn-id") else {
225225- continue;
226226- };
227227-228228- let class_list = element.class_list();
229229- if visibility.is_visible(&syn_id) {
230230- let _ = class_list.remove_1("hidden");
231231- } else {
232232- let _ = class_list.add_1("hidden");
233233- }
234234- }
235235-}
236236-237237-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
238238-pub fn update_syntax_visibility(
239239- _cursor_offset: usize,
240240- _selection: Option<&Selection>,
241241- _syntax_spans: &[SyntaxSpanInfo],
242242- _paragraphs: &[ParagraphRender],
243243-) {
244244- // No-op on non-wasm
245245-}
246246-247247-#[cfg(test)]
248248-mod tests {
249249- use super::*;
250250-251251- fn make_span(
252252- syn_id: &str,
253253- start: usize,
254254- end: usize,
255255- syntax_type: SyntaxType,
256256- ) -> SyntaxSpanInfo {
257257- SyntaxSpanInfo {
258258- syn_id: syn_id.into(),
259259- char_range: start..end,
260260- syntax_type,
261261- formatted_range: None,
262262- }
263263- }
264264-265265- fn make_span_with_range(
266266- syn_id: &str,
267267- start: usize,
268268- end: usize,
269269- syntax_type: SyntaxType,
270270- formatted_range: Range<usize>,
271271- ) -> SyntaxSpanInfo {
272272- SyntaxSpanInfo {
273273- syn_id: syn_id.into(),
274274- char_range: start..end,
275275- syntax_type,
276276- formatted_range: Some(formatted_range),
277277- }
278278- }
279279-280280- fn make_para(start: usize, end: usize, syntax_spans: Vec<SyntaxSpanInfo>) -> ParagraphRender {
281281- ParagraphRender {
282282- id: format!("test-{}-{}", start, end),
283283- byte_range: start..end,
284284- char_range: start..end,
285285- html: String::new(),
286286- offset_map: vec![],
287287- syntax_spans,
288288- source_hash: 0,
289289- }
290290- }
291291-292292- #[test]
293293- fn test_inline_visibility_cursor_inside() {
294294- // **bold** at chars 0-2 (opening **) and 6-8 (closing **)
295295- // Text positions: 0-1 = **, 2-5 = bold, 6-7 = **
296296- // formatted_range is 0..8 (the whole **bold** region)
297297- let spans = vec![
298298- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
299299- make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
300300- ];
301301- let paras = vec![make_para(0, 8, spans.clone())];
302302-303303- // Cursor at position 4 (middle of "bold", inside formatted region)
304304- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
305305- assert!(
306306- vis.is_visible("s0"),
307307- "opening ** should be visible when cursor inside formatted region"
308308- );
309309- assert!(
310310- vis.is_visible("s1"),
311311- "closing ** should be visible when cursor inside formatted region"
312312- );
313313-314314- // Cursor at position 2 (adjacent to opening **, start of "bold")
315315- let vis = VisibilityState::calculate(2, None, &spans, ¶s);
316316- assert!(
317317- vis.is_visible("s0"),
318318- "opening ** should be visible when cursor adjacent at start of bold"
319319- );
320320-321321- // Cursor at position 5 (adjacent to closing **, end of "bold")
322322- let vis = VisibilityState::calculate(5, None, &spans, ¶s);
323323- assert!(
324324- vis.is_visible("s1"),
325325- "closing ** should be visible when cursor adjacent at end of bold"
326326- );
327327- }
328328-329329- #[test]
330330- fn test_inline_visibility_without_formatted_range() {
331331- // Test without formatted_range - just adjacency-based visibility
332332- let spans = vec![
333333- make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range)
334334- make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range)
335335- ];
336336- let paras = vec![make_para(0, 8, spans.clone())];
337337-338338- // Cursor at position 4 (middle of "bold", not adjacent to either marker)
339339- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
340340- assert!(
341341- !vis.is_visible("s0"),
342342- "opening ** should be hidden when no formatted_range and cursor not adjacent"
343343- );
344344- assert!(
345345- !vis.is_visible("s1"),
346346- "closing ** should be hidden when no formatted_range and cursor not adjacent"
347347- );
348348- }
349349-350350- #[test]
351351- fn test_inline_visibility_cursor_adjacent() {
352352- // "test **bold** after"
353353- // 5 7
354354- let spans = vec![
355355- make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
356356- ];
357357- let paras = vec![make_para(0, 19, spans.clone())];
358358-359359- // Cursor at position 4 (one before ** which starts at 5)
360360- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
361361- assert!(
362362- vis.is_visible("s0"),
363363- "** should be visible when cursor adjacent"
364364- );
365365-366366- // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
367367- let vis = VisibilityState::calculate(7, None, &spans, ¶s);
368368- assert!(
369369- vis.is_visible("s0"),
370370- "** should be visible when cursor adjacent after span"
371371- );
372372- }
373373-374374- #[test]
375375- fn test_inline_visibility_cursor_far() {
376376- let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
377377- let paras = vec![make_para(0, 33, spans.clone())];
378378-379379- // Cursor at position 0 (far from **)
380380- let vis = VisibilityState::calculate(0, None, &spans, ¶s);
381381- assert!(
382382- !vis.is_visible("s0"),
383383- "** should be hidden when cursor far away"
384384- );
385385- }
386386-387387- #[test]
388388- fn test_block_visibility_same_paragraph() {
389389- // # at start of heading
390390- let spans = vec![
391391- make_span("s0", 0, 2, SyntaxType::Block), // "# "
392392- ];
393393- let paras = vec![
394394- make_para(0, 10, spans.clone()), // heading paragraph
395395- make_para(12, 30, vec![]), // next paragraph
396396- ];
397397-398398- // Cursor at position 5 (inside heading)
399399- let vis = VisibilityState::calculate(5, None, &spans, ¶s);
400400- assert!(
401401- vis.is_visible("s0"),
402402- "# should be visible when cursor in same paragraph"
403403- );
404404- }
405405-406406- #[test]
407407- fn test_block_visibility_different_paragraph() {
408408- let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
409409- let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
410410-411411- // Cursor at position 20 (in second paragraph)
412412- let vis = VisibilityState::calculate(20, None, &spans, ¶s);
413413- assert!(
414414- !vis.is_visible("s0"),
415415- "# should be hidden when cursor in different paragraph"
416416- );
417417- }
418418-419419- #[test]
420420- fn test_selection_reveals_syntax() {
421421- let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
422422- let paras = vec![make_para(0, 24, spans.clone())];
423423-424424- // Selection overlaps the syntax span
425425- let selection = Selection {
426426- anchor: 3,
427427- head: 10,
428428- };
429429- let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
430430- assert!(
431431- vis.is_visible("s0"),
432432- "** should be visible when selection overlaps"
433433- );
434434- }
435435-436436- #[test]
437437- fn test_paragraph_boundary_blocks_extension() {
438438- // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1,
439439- // even if cursor is only 1 char after the paragraph boundary
440440- // (paragraph bounds clamp the extension)
441441- let spans = vec![
442442- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
443443- make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
444444- ];
445445- let paras = vec![
446446- make_para(0, 8, spans.clone()), // "**bold**"
447447- make_para(9, 13, vec![]), // "text" (after newline)
448448- ];
449449-450450- // Cursor at position 9 (start of second paragraph)
451451- // Should NOT reveal the closing ** because para bounds clamp extension
452452- let vis = VisibilityState::calculate(9, None, &spans, ¶s);
453453- assert!(
454454- !vis.is_visible("s1"),
455455- "closing ** should NOT be visible when cursor is in next paragraph"
456456- );
457457- }
458458-459459- #[test]
460460- fn test_extension_clamps_to_paragraph() {
461461- // Syntax at very start of paragraph - extension left should stop at para start
462462- let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
463463- let paras = vec![make_para(0, 8, spans.clone())];
464464-465465- // Cursor at position 0 - should still see the opening **
466466- let vis = VisibilityState::calculate(0, None, &spans, ¶s);
467467- assert!(
468468- vis.is_visible("s0"),
469469- "** at start should be visible when cursor at position 0"
470470- );
471471- }
472472-}
88+// Browser DOM updates.
99+pub use weaver_editor_browser::update_syntax_visibility;
+191
crates/weaver-editor-browser/src/events.rs
···255255256256 Ok(result.as_string())
257257}
258258+259259+// === BeforeInput handler ===
260260+261261+use weaver_editor_core::{EditorAction, EditorDocument, execute_action};
262262+263263+/// Handle a beforeinput event, dispatching to the appropriate action.
264264+///
265265+/// This is the main entry point for beforeinput-based input handling.
266266+/// The `current_range` parameter should be the current cursor/selection range
267267+/// from the document when `ctx.target_range` is None.
268268+///
269269+/// Returns the handling result indicating whether default should be prevented.
270270+pub fn handle_beforeinput<D: EditorDocument>(
271271+ doc: &mut D,
272272+ ctx: &BeforeInputContext<'_>,
273273+ current_range: Range,
274274+) -> BeforeInputResult {
275275+ // During composition, let the browser handle most things.
276276+ if ctx.is_composing {
277277+ match ctx.input_type {
278278+ InputType::HistoryUndo | InputType::HistoryRedo => {
279279+ // Handle undo/redo even during composition.
280280+ }
281281+ InputType::InsertCompositionText => {
282282+ return BeforeInputResult::PassThrough;
283283+ }
284284+ _ => {
285285+ return BeforeInputResult::PassThrough;
286286+ }
287287+ }
288288+ }
289289+290290+ // Use target range from event, or fall back to current range.
291291+ let range = ctx.target_range.unwrap_or(current_range);
292292+293293+ match ctx.input_type {
294294+ // === Insertion ===
295295+ InputType::InsertText => {
296296+ if let Some(ref text) = ctx.data {
297297+ let action = EditorAction::Insert {
298298+ text: text.clone(),
299299+ range,
300300+ };
301301+ execute_action(doc, &action);
302302+ BeforeInputResult::Handled
303303+ } else {
304304+ BeforeInputResult::PassThrough
305305+ }
306306+ }
307307+308308+ InputType::InsertLineBreak => {
309309+ let action = EditorAction::InsertLineBreak { range };
310310+ execute_action(doc, &action);
311311+ BeforeInputResult::Handled
312312+ }
313313+314314+ InputType::InsertParagraph => {
315315+ let action = EditorAction::InsertParagraph { range };
316316+ execute_action(doc, &action);
317317+ BeforeInputResult::Handled
318318+ }
319319+320320+ InputType::InsertFromPaste | InputType::InsertReplacementText => {
321321+ if let Some(ref text) = ctx.data {
322322+ let action = EditorAction::Insert {
323323+ text: text.clone(),
324324+ range,
325325+ };
326326+ execute_action(doc, &action);
327327+ BeforeInputResult::Handled
328328+ } else {
329329+ BeforeInputResult::PassThrough
330330+ }
331331+ }
332332+333333+ InputType::InsertFromDrop => BeforeInputResult::PassThrough,
334334+335335+ InputType::InsertCompositionText => BeforeInputResult::PassThrough,
336336+337337+ // === Deletion ===
338338+ InputType::DeleteContentBackward => {
339339+ // Android Chrome workaround: backspace sometimes doesn't work properly.
340340+ if ctx.is_android && ctx.is_chrome && range.is_caret() {
341341+ let action = EditorAction::DeleteBackward { range };
342342+ return BeforeInputResult::DeferredCheck {
343343+ fallback_action: action,
344344+ };
345345+ }
346346+347347+ let action = EditorAction::DeleteBackward { range };
348348+ execute_action(doc, &action);
349349+ BeforeInputResult::Handled
350350+ }
351351+352352+ InputType::DeleteContentForward => {
353353+ let action = EditorAction::DeleteForward { range };
354354+ execute_action(doc, &action);
355355+ BeforeInputResult::Handled
356356+ }
357357+358358+ InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => {
359359+ let action = EditorAction::DeleteWordBackward { range };
360360+ execute_action(doc, &action);
361361+ BeforeInputResult::Handled
362362+ }
363363+364364+ InputType::DeleteWordForward | InputType::DeleteEntireWordForward => {
365365+ let action = EditorAction::DeleteWordForward { range };
366366+ execute_action(doc, &action);
367367+ BeforeInputResult::Handled
368368+ }
369369+370370+ InputType::DeleteSoftLineBackward => {
371371+ let action = EditorAction::DeleteSoftLineBackward { range };
372372+ execute_action(doc, &action);
373373+ BeforeInputResult::Handled
374374+ }
375375+376376+ InputType::DeleteSoftLineForward => {
377377+ let action = EditorAction::DeleteSoftLineForward { range };
378378+ execute_action(doc, &action);
379379+ BeforeInputResult::Handled
380380+ }
381381+382382+ InputType::DeleteHardLineBackward => {
383383+ let action = EditorAction::DeleteToLineStart { range };
384384+ execute_action(doc, &action);
385385+ BeforeInputResult::Handled
386386+ }
387387+388388+ InputType::DeleteHardLineForward => {
389389+ let action = EditorAction::DeleteToLineEnd { range };
390390+ execute_action(doc, &action);
391391+ BeforeInputResult::Handled
392392+ }
393393+394394+ InputType::DeleteByCut => {
395395+ if !range.is_caret() {
396396+ let action = EditorAction::DeleteBackward { range };
397397+ execute_action(doc, &action);
398398+ }
399399+ BeforeInputResult::Handled
400400+ }
401401+402402+ InputType::DeleteByDrag | InputType::DeleteContent => {
403403+ if !range.is_caret() {
404404+ let action = EditorAction::DeleteBackward { range };
405405+ execute_action(doc, &action);
406406+ }
407407+ BeforeInputResult::Handled
408408+ }
409409+410410+ // === History ===
411411+ InputType::HistoryUndo => {
412412+ execute_action(doc, &EditorAction::Undo);
413413+ BeforeInputResult::Handled
414414+ }
415415+416416+ InputType::HistoryRedo => {
417417+ execute_action(doc, &EditorAction::Redo);
418418+ BeforeInputResult::Handled
419419+ }
420420+421421+ // === Formatting ===
422422+ InputType::FormatBold => {
423423+ execute_action(doc, &EditorAction::ToggleBold);
424424+ BeforeInputResult::Handled
425425+ }
426426+427427+ InputType::FormatItalic => {
428428+ execute_action(doc, &EditorAction::ToggleItalic);
429429+ BeforeInputResult::Handled
430430+ }
431431+432432+ InputType::FormatStrikethrough => {
433433+ execute_action(doc, &EditorAction::ToggleStrikethrough);
434434+ BeforeInputResult::Handled
435435+ }
436436+437437+ // === Not handled ===
438438+ InputType::InsertFromYank
439439+ | InputType::InsertHorizontalRule
440440+ | InputType::InsertOrderedList
441441+ | InputType::InsertUnorderedList
442442+ | InputType::InsertLink
443443+ | InputType::FormatUnderline
444444+ | InputType::FormatSuperscript
445445+ | InputType::FormatSubscript
446446+ | InputType::Unknown(_) => BeforeInputResult::PassThrough,
447447+ }
448448+}
+5-1
crates/weaver-editor-browser/src/lib.rs
···2424pub mod dom_sync;
2525pub mod events;
2626pub mod platform;
2727+pub mod visibility;
27282829// Browser cursor implementation
2930pub use cursor::BrowserCursor;
···3435// Event handling
3536pub use events::{
3637 BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event,
3737- get_input_type_from_event, get_target_range_from_event, is_composing,
3838+ get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing,
3839 parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type,
3940};
40414142// Platform detection
4243pub use platform::{Platform, platform};
4444+4545+// Visibility updates
4646+pub use visibility::update_syntax_visibility;
+90
crates/weaver-editor-browser/src/visibility.rs
···11+//! DOM-based syntax visibility updates.
22+//!
33+//! This module applies visibility state to the DOM by toggling CSS classes
44+//! on syntax span elements. Works with the core `VisibilityState` calculation.
55+//!
66+//! # How it works
77+//!
88+//! 1. Core's `VisibilityState::calculate()` determines which syntax spans should be visible
99+//! 2. This module's `update_syntax_visibility()` applies that state to the DOM
1010+//! 3. Elements with `data-syn-id` attributes get "hidden" class toggled
1111+//!
1212+//! # CSS Integration
1313+//!
1414+//! Your CSS should hide elements with the "hidden" class:
1515+//! ```css
1616+//! [data-syn-id].hidden {
1717+//! opacity: 0;
1818+//! /* or display: none, visibility: hidden, etc. */
1919+//! }
2020+//! ```
2121+2222+use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState};
2323+2424+/// Update syntax span visibility in the DOM based on cursor position.
2525+///
2626+/// Calculates which syntax spans should be visible using `VisibilityState::calculate()`,
2727+/// then toggles the "hidden" class on matching DOM elements.
2828+///
2929+/// # Parameters
3030+/// - `cursor_offset`: Current cursor position in characters
3131+/// - `selection`: Optional text selection
3232+/// - `syntax_spans`: All syntax spans from rendered paragraphs
3333+/// - `paragraphs`: Rendered paragraph data for boundary detection
3434+///
3535+/// # DOM Requirements
3636+/// Syntax span elements must have `data-syn-id` attributes matching `SyntaxSpanInfo.syn_id`.
3737+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
3838+pub fn update_syntax_visibility(
3939+ cursor_offset: usize,
4040+ selection: Option<&Selection>,
4141+ syntax_spans: &[SyntaxSpanInfo],
4242+ paragraphs: &[ParagraphRender],
4343+) {
4444+ use wasm_bindgen::JsCast;
4545+4646+ let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
4747+4848+ let Some(window) = web_sys::window() else {
4949+ return;
5050+ };
5151+ let Some(document) = window.document() else {
5252+ return;
5353+ };
5454+5555+ // Single querySelectorAll instead of N individual queries.
5656+ let Ok(node_list) = document.query_selector_all("[data-syn-id]") else {
5757+ return;
5858+ };
5959+6060+ for i in 0..node_list.length() {
6161+ let Some(node) = node_list.item(i) else {
6262+ continue;
6363+ };
6464+6565+ let Some(element) = node.dyn_ref::<web_sys::Element>() else {
6666+ continue;
6767+ };
6868+6969+ let Some(syn_id) = element.get_attribute("data-syn-id") else {
7070+ continue;
7171+ };
7272+7373+ let class_list = element.class_list();
7474+ if visibility.is_visible(&syn_id) {
7575+ let _ = class_list.remove_1("hidden");
7676+ } else {
7777+ let _ = class_list.add_1("hidden");
7878+ }
7979+ }
8080+}
8181+8282+/// No-op on non-WASM targets.
8383+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
8484+pub fn update_syntax_visibility(
8585+ _cursor_offset: usize,
8686+ _selection: Option<&Selection>,
8787+ _syntax_spans: &[SyntaxSpanInfo],
8888+ _paragraphs: &[ParagraphRender],
8989+) {
9090+}