Skip to main content

dioxus_nox_markdown/
types.rs

1use std::fmt;
2use std::ops::Range;
3/// The three display modes of the markdown component.
4#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum Mode {
6    /// Rendered HTML output only (read-only display).
7    Read,
8    /// Raw markdown textarea editor.
9    #[default]
10    Source,
11    /// Split pane: editor + rendered preview.
12    LivePreview,
13}
14
15impl Mode {
16    /// Returns the string used for `data-md-mode` attribute values.
17    /// Uses kebab-case: "read", "source", "live-preview".
18    pub fn to_data_attr_value(&self) -> &'static str {
19        match self {
20            Mode::Read => "read",
21            Mode::Source => "source",
22            Mode::LivePreview => "live-preview",
23        }
24    }
25}
26
27impl fmt::Display for Mode {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str(self.to_data_attr_value())
30    }
31}
32
33/// Layout direction for the LivePreview split pane.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Layout {
36    /// Editor on left, preview on right (default).
37    #[default]
38    Horizontal,
39    /// Editor on top, preview on bottom.
40    Vertical,
41}
42
43impl Layout {
44    /// Returns the `data-md-layout` attribute value for this layout.
45    pub fn as_attr(self) -> &'static str {
46        match self {
47            Layout::Horizontal => "horizontal",
48            Layout::Vertical => "vertical",
49        }
50    }
51}
52
53/// Orientation for toolbar and separator components.
54/// Structurally identical to [`Layout`] — uses the same horizontal/vertical variants.
55pub type Orientation = Layout;
56
57/// Cursor position within the editor textarea.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct CursorPosition {
60    /// 0-based line number.
61    pub line: u32,
62    /// 0-based column number.
63    pub column: u32,
64    /// Byte offset into the value string.
65    pub offset: usize,
66}
67
68impl CursorPosition {
69    pub fn new(line: u32, column: u32, offset: usize) -> Self {
70        Self {
71            line,
72            column,
73            offset,
74        }
75    }
76}
77
78/// A text selection range in the editor.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct Selection {
81    /// Byte offset where the selection started.
82    pub anchor: usize,
83    /// Byte offset of the cursor end (may differ from anchor).
84    pub head: usize,
85}
86
87impl Selection {
88    pub fn new(anchor: usize, head: usize) -> Self {
89        Self { anchor, head }
90    }
91
92    /// Returns `true` when anchor equals head (no text selected).
93    pub fn is_collapsed(&self) -> bool {
94        self.anchor == self.head
95    }
96
97    /// Returns the absolute length of the selection in bytes.
98    pub fn len(&self) -> usize {
99        self.anchor.abs_diff(self.head)
100    }
101
102    /// Returns `true` when the selection has zero length (same as `is_collapsed`).
103    pub fn is_empty(&self) -> bool {
104        self.anchor == self.head
105    }
106
107    /// Returns `true` if the selection direction is forward (anchor <= head).
108    pub fn is_forward(&self) -> bool {
109        self.anchor <= self.head
110    }
111
112    /// Returns `(start, end)` with start <= end regardless of selection direction.
113    pub fn ordered(&self) -> (usize, usize) {
114        if self.anchor <= self.head {
115            (self.anchor, self.head)
116        } else {
117            (self.head, self.anchor)
118        }
119    }
120}
121
122/// Parser pipeline state, reflected via `data-md-parse-state`.
123#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum ParseState {
125    /// No parse has been triggered yet.
126    #[default]
127    Idle,
128    /// A parse is currently in progress.
129    Parsing,
130    /// Parse completed successfully.
131    Done,
132    /// Parse encountered an error.
133    Error,
134}
135
136impl ParseState {
137    /// Returns the string used for `data-md-parse-state` attribute values.
138    pub fn to_data_attr_value(&self) -> &'static str {
139        match self {
140            ParseState::Idle => "idle",
141            ParseState::Parsing => "parsing",
142            ParseState::Done => "done",
143            ParseState::Error => "error",
144        }
145    }
146}
147
148impl fmt::Display for ParseState {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(self.to_data_attr_value())
151    }
152}
153
154/// Configuration options for the markdown parse pipeline.
155#[derive(Debug, Clone, PartialEq)]
156pub struct ParseOptions {
157    /// Debounce delay in milliseconds before triggering a re-parse.
158    pub debounce_ms: u64,
159    /// Tab size for indentation in the editor.
160    pub tab_size: u8,
161    /// Enable GFM tables.
162    pub tables: bool,
163    /// Enable GFM task lists.
164    pub task_lists: bool,
165    /// Enable GFM strikethrough.
166    pub strikethrough: bool,
167    /// Enable footnotes.
168    pub footnotes: bool,
169    /// Enable front matter parsing with the given delimiter (e.g., `"---"`).
170    pub front_matter_delimiter: Option<String>,
171    /// Enable autolinks.
172    pub autolink: bool,
173}
174
175impl Default for ParseOptions {
176    fn default() -> Self {
177        Self {
178            debounce_ms: 300,
179            tab_size: 2,
180            tables: true,
181            task_lists: true,
182            strikethrough: true,
183            footnotes: true,
184            front_matter_delimiter: Some("---".to_string()),
185            autolink: true,
186        }
187    }
188}
189
190/// An entry in the heading index extracted from the parsed AST.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct HeadingEntry {
193    /// Heading level (1-6).
194    pub level: u8,
195    /// Heading text content.
196    pub text: String,
197    /// Slugified anchor ID for deep linking.
198    pub anchor: String,
199    /// Source line number (0-based) for cursor sync.
200    pub line: usize,
201}
202
203/// Sub-variant for `Mode::LivePreview`.
204///
205/// Controls whether the live preview renders as a side-by-side split pane
206/// (existing behaviour) or as an inline Obsidian-style editor where all
207/// blocks render as formatted HTML except the one under the cursor.
208#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
209pub enum LivePreviewVariant {
210    /// Editor on one side, rendered preview on the other (default, backwards-compatible).
211    #[default]
212    SplitPane,
213    /// Single surface: every block is rendered HTML except the cursor block,
214    /// which reverts to raw markdown for editing.
215    Inline,
216}
217
218/// An entry in the block list extracted for the inline editor.
219///
220/// Each top-level AST block (paragraph, heading, code block, …) gets one
221/// `BlockEntry`.  Top-level lists are **split into per-item blocks** — each
222/// `Item` / `TaskItem` child becomes its own entry with `is_list_item: true`.
223/// Front matter is excluded.
224#[derive(Debug, Clone, PartialEq)]
225pub struct BlockEntry {
226    /// Zero-based index within the document's top-level block list (front matter excluded).
227    pub index: usize,
228    /// Raw markdown source text for this block, extracted via comrak `sourcepos`.
229    pub raw: String,
230    /// Pre-rendered HTML fragment for this block, wrapped in
231    /// `<div data-block-index="{index}">…</div>` for use with `innerHTML`
232    /// in the inline editor.
233    pub html: String,
234    /// First source line of this block (1-indexed, from comrak `sourcepos`).
235    pub start_line: u32,
236    /// Last source line of this block (1-indexed, from comrak `sourcepos`).
237    pub end_line: u32,
238    /// `true` when this entry represents a single list item (`Item` or `TaskItem`).
239    /// Consecutive list-item blocks are joined with `"\n"` during reconstruction;
240    /// all other block boundaries use `"\n\n"`.
241    pub is_list_item: bool,
242}
243
244/// The type of an AST node, abstracting away from `pulldown_cmark::Event`
245/// to provide a `'static`, owned structure suitable for Dioxus `Props`
246/// and headless block component overrides.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum NodeType {
249    // Blocks
250    Paragraph,
251    Heading(u8),
252    BlockQuote,
253    CodeBlock(String), // Language
254    List(Option<u64>), // Start index
255    Item,
256    Table,
257    TableHead,
258    TableRow,
259    TableCell,
260    Rule,
261    HtmlBlock,
262    DefinitionList,
263    DefinitionListTitle,
264    DefinitionListDefinition,
265    Superscript,
266    Subscript,
267    // Inlines
268    Text(String),
269    Code(String),
270    Html(String),
271    Emphasis,
272    Strong,
273    Strikethrough,
274    Link { url: String, title: String },
275    Image { url: String, title: String },
276    FootnoteReference(String),
277    SoftBreak,
278    HardBreak,
279    TaskListMarker(bool),
280    // Custom Extensions
281    Wikilink(String),
282    Tag(String),
283}
284
285/// A fully owned, `'static` AST node mapped from byte boundaries in the Rope.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct OwnedAstNode {
288    pub node_type: NodeType,
289    pub range: Range<usize>,
290    pub children: Vec<OwnedAstNode>,
291}
292
293/// The parsed document produced by `parse_document()`.
294/// Contains the pre-rendered Dioxus Element plus extracted metadata.
295///
296/// `PartialEq` always returns `false` because `Element` is not comparable.
297/// This ensures `Memo<Rc<ParsedDoc>>` always notifies subscribers on update,
298/// which is correct — every re-parse produces a semantically new document.
299pub struct ParsedDoc {
300    /// The rendered Dioxus element tree.
301    pub element: dioxus::prelude::Element,
302    /// Headings extracted from the AST for `use_heading_index()`.
303    pub headings: Vec<HeadingEntry>,
304    /// Raw front matter string (consumer parses YAML/TOML).
305    pub front_matter: Option<String>,
306    /// Top-level blocks for the inline editor (cursor-aware block switching).
307    pub blocks: Vec<BlockEntry>,
308    /// The owned, strictly-typed Abstract Syntax Tree representing the entire document.
309    pub ast: Vec<OwnedAstNode>,
310}
311
312impl PartialEq for ParsedDoc {
313    fn eq(&self, _other: &Self) -> bool {
314        // Element is not comparable; every re-parse is a new document.
315        false
316    }
317}
318
319// ── HTML render policy ───────────────────────────────────────────────
320
321/// Controls how raw HTML blocks and inline HTML in markdown are rendered.
322///
323/// By default, raw HTML is **escaped** (displayed as visible text) to prevent
324/// cross-site scripting (XSS) attacks. Choose a policy based on how much you
325/// trust the markdown source:
326///
327/// | Policy | Use when | XSS safe? |
328/// |-----------|----------------------------------------------|-----------|
329/// | `Escape` | Untrusted / user-generated markdown (default)| Yes |
330/// | `Sanitized` | User-generated markdown where you want HTML formatting but not scripts (requires `sanitize` feature) | Yes |
331/// | `Trusted` | You control the markdown source entirely | **No** |
332///
333/// # Security
334///
335/// **`Trusted` mode renders arbitrary HTML without any sanitization.** If the
336/// markdown contains `<script>`, `<iframe>`, `onload=`, or any other active
337/// content, it **will** be injected into the DOM. Never use `Trusted` with
338/// user-generated or untrusted markdown — this is a direct XSS vector.
339///
340/// For user-generated content that needs HTML rendering, enable the `sanitize`
341/// feature and use [`HtmlRenderPolicy::Sanitized`], which strips dangerous
342/// elements and attributes via the [`ammonia`](https://docs.rs/ammonia) crate.
343#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
344pub enum HtmlRenderPolicy {
345    /// Escape HTML — render as visible text. Safe for all inputs.
346    #[default]
347    Escape,
348
349    /// Sanitize HTML with [`ammonia`](https://docs.rs/ammonia) before rendering.
350    ///
351    /// Strips dangerous elements (`<script>`, `<iframe>`, `<object>`, etc.) and
352    /// event-handler attributes (`onload`, `onclick`, etc.) while preserving safe
353    /// formatting tags (`<b>`, `<i>`, `<a>`, `<code>`, etc.).
354    ///
355    /// Requires the `sanitize` Cargo feature. Falls back to `Escape` if the
356    /// feature is not enabled.
357    Sanitized,
358
359    /// Render raw HTML via `dangerous_inner_html` **without any sanitization**.
360    ///
361    /// # Security Warning
362    ///
363    /// **This is a direct XSS vector.** Only use this when you fully control the
364    /// markdown source (e.g., static content compiled into your binary). Never
365    /// use with user-generated input.
366    Trusted,
367}
368
369// ── Vim modal editing types ──────────────────────────────────────────
370
371/// Vim modal editing mode.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
373pub enum VimMode {
374    /// Default: all keystrokes pass through to the textarea (browser default behavior).
375    #[default]
376    Insert,
377    /// Normal mode: hjkl navigation, mode transitions.
378    Normal,
379    /// Visual mode: text selection.
380    Visual,
381    /// Command mode: colon commands.
382    Command,
383}
384
385/// Action returned by `VimState::handle_key`.
386#[derive(Debug, Clone, PartialEq)]
387pub enum VimAction {
388    /// Key passes through to the browser/textarea unchanged.
389    PassThrough,
390    /// Prevent default and run this eval() JS string.
391    PreventAndEval(String),
392    /// Transition to a new vim mode.
393    ModeChange(VimMode),
394    /// Execute a command string (from Command mode).
395    ExecuteCommand(String),
396}
397
398/// Vim modal editing state.
399#[derive(Debug, Clone, Default)]
400pub struct VimState {
401    /// Current mode.
402    pub mode: VimMode,
403    /// Accumulated command buffer (for Command mode input).
404    pub command_buffer: String,
405}
406
407impl VimState {
408    /// Handle a key event and return the action to take.
409    /// Pure function — no side effects, no eval, no DOM access.
410    ///
411    /// # Arguments
412    /// - `key`: the key string from `KeyboardEvent::key().to_string()`
413    /// - `ctrl`: whether Ctrl is held
414    /// - `shift`: whether Shift is held
415    /// - `editor_id`: DOM ID of the editor textarea for generated JS
416    pub fn handle_key(&mut self, key: &str, ctrl: bool, shift: bool, editor_id: &str) -> VimAction {
417        match self.mode {
418            VimMode::Insert => {
419                if key == "Escape" {
420                    self.mode = VimMode::Normal;
421                    return VimAction::ModeChange(VimMode::Normal);
422                }
423                VimAction::PassThrough
424            }
425            VimMode::Normal => match key {
426                "i" if !ctrl && !shift => {
427                    self.mode = VimMode::Insert;
428                    VimAction::ModeChange(VimMode::Insert)
429                }
430                "v" if !ctrl && !shift => {
431                    self.mode = VimMode::Visual;
432                    VimAction::ModeChange(VimMode::Visual)
433                }
434                ":" => {
435                    self.mode = VimMode::Command;
436                    self.command_buffer.clear();
437                    VimAction::ModeChange(VimMode::Command)
438                }
439                "Escape" => VimAction::PassThrough, // already Normal
440                "h" => VimAction::PreventAndEval(vim_move_js(editor_id, "left")),
441                "l" => VimAction::PreventAndEval(vim_move_js(editor_id, "right")),
442                "j" => VimAction::PreventAndEval(vim_move_js(editor_id, "down")),
443                "k" => VimAction::PreventAndEval(vim_move_js(editor_id, "up")),
444                _ => VimAction::PassThrough,
445            },
446            VimMode::Visual => {
447                if key == "Escape" {
448                    self.mode = VimMode::Normal;
449                    return VimAction::ModeChange(VimMode::Normal);
450                }
451                VimAction::PassThrough
452            }
453            VimMode::Command => {
454                if key == "Escape" {
455                    self.mode = VimMode::Normal;
456                    self.command_buffer.clear();
457                    return VimAction::ModeChange(VimMode::Normal);
458                }
459                if key == "Enter" {
460                    let cmd = self.command_buffer.clone();
461                    self.command_buffer.clear();
462                    self.mode = VimMode::Normal;
463                    return VimAction::ExecuteCommand(cmd);
464                }
465                if key.len() == 1 {
466                    self.command_buffer.push_str(key);
467                }
468                VimAction::PassThrough
469            }
470        }
471    }
472}
473
474// ── Source map types ─────────────────────────────────────────────────
475
476/// Entry in the source map linking a rendered DOM element to its source line range.
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct SourceMapEntry {
479    /// First source line (1-indexed) covered by this element.
480    pub source_line_start: usize,
481    /// Last source line (1-indexed) covered by this element.
482    pub source_line_end: usize,
483    /// The `id` attribute of the rendered DOM element.
484    pub element_id: String,
485}
486
487/// Source map linking rendered DOM elements back to source line ranges.
488#[derive(Debug, Clone, Default)]
489pub struct SourceMap {
490    /// Entries sorted by `source_line_start` ascending.
491    pub entries: Vec<SourceMapEntry>,
492}
493
494impl SourceMap {
495    /// Returns the first entry whose line range contains `line`.
496    ///
497    /// `line` is 1-indexed. Returns `None` if no entry covers the given line.
498    pub fn find_entry_by_line(&self, line: usize) -> Option<&SourceMapEntry> {
499        self.entries
500            .iter()
501            .find(|e| e.source_line_start <= line && line <= e.source_line_end)
502    }
503}
504
505/// Fired by [`InlineEditor`] on every `oninput` with the active block's raw text
506/// and cursor offset within that block.  Used by consumers to wire inline-trigger
507/// suggestions (e.g. `dioxus-nox-suggest`) without coupling markdown to suggest.
508#[derive(Debug, Clone, PartialEq)]
509pub struct ActiveBlockInputEvent {
510    /// Raw markdown text of the active block.
511    pub raw_text: String,
512    /// Visible text projection of the active block (markers may be concealed).
513    pub visible_text: String,
514    /// Cursor position as UTF-16 code-unit offset in `raw_text`.
515    pub cursor_raw_utf16: usize,
516    /// Cursor position as UTF-16 code-unit offset in `visible_text`.
517    pub cursor_visible_utf16: usize,
518    /// Absolute start byte offset of the active block in the full document.
519    pub block_start: usize,
520    /// Absolute end byte offset of the active block in the full document.
521    pub block_end: usize,
522}
523
524/// Generate JS for vim cursor movement via `eval()`.
525/// Targets the textarea with the given `editor_id`.
526pub(crate) fn vim_move_js(editor_id: &str, direction: &str) -> String {
527    match direction {
528        "left" => format!(
529            "(function(){{ const el = document.getElementById('{editor_id}'); if(!el) return; \
530            el.selectionStart = el.selectionEnd = Math.max(0, el.selectionStart - 1); }})();"
531        ),
532        "right" => format!(
533            "(function(){{ const el = document.getElementById('{editor_id}'); if(!el) return; \
534            const max = el.value.length; \
535            el.selectionStart = el.selectionEnd = Math.min(max, el.selectionEnd + 1); }})();"
536        ),
537        "up" => format!(
538            "(function(){{ const el = document.getElementById('{editor_id}'); if(!el) return; \
539            const pos = el.selectionStart; const text = el.value; \
540            const lineStart = text.lastIndexOf('\\n', pos - 1) + 1; \
541            const col = pos - lineStart; \
542            const prevLineEnd = lineStart > 0 ? lineStart - 1 : 0; \
543            const prevLineStart = text.lastIndexOf('\\n', prevLineEnd - 1) + 1; \
544            const newPos = Math.min(prevLineStart + col, prevLineEnd); \
545            el.selectionStart = el.selectionEnd = newPos; }})();"
546        ),
547        "down" => format!(
548            "(function(){{ const el = document.getElementById('{editor_id}'); if(!el) return; \
549            const pos = el.selectionStart; const text = el.value; \
550            const lineStart = text.lastIndexOf('\\n', pos - 1) + 1; \
551            const col = pos - lineStart; \
552            const lineEnd = text.indexOf('\\n', pos); \
553            if(lineEnd === -1) return; \
554            const nextLineStart = lineEnd + 1; \
555            const nextLineEnd = text.indexOf('\\n', nextLineStart); \
556            const nextLineLen = (nextLineEnd === -1 ? text.length : nextLineEnd) - nextLineStart; \
557            el.selectionStart = el.selectionEnd = nextLineStart + Math.min(col, nextLineLen); }})();"
558        ),
559        _ => String::new(),
560    }
561}