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}