use std::cell::RefCell;
use std::rc::Rc;
use dioxus::prelude::*;
use crate::context::{
CursorContext, MarkdownContext, handle_set_content_js, handle_set_content_with_cursor_js,
make_instance_n, read_cursor_and_selection,
};
use crate::hooks::{
select_all_children_js, sync_editor_to_preview, sync_preview_to_editor, tab_indent_js,
wrap_selection_js,
};
use crate::inline_editor::InlineEditor;
use crate::interop;
use crate::types::{
ActiveBlockInputEvent, CursorPosition, HtmlRenderPolicy, Layout, LivePreviewVariant, Mode,
Orientation, Selection, VimAction, VimState,
};
use crop::Rope;
struct RootState {
mode: Signal<Mode>,
raw_content: Signal<Rc<RefCell<Rope>>>,
parsed_doc: Memo<Rc<crate::types::ParsedDoc>>,
trigger_parse: Callback<()>,
}
struct RootConfig {
initial_mode: Option<Mode>,
controlled_mode: Option<Signal<Mode>>,
default_value: Option<String>,
controlled_value: Option<Signal<String>>,
html_render_policy: HtmlRenderPolicy,
highlight_class_prefix: String,
show_code_line_numbers: bool,
show_code_language: bool,
}
fn use_root_state(cfg: RootConfig) -> RootState {
let internal_mode = use_signal(|| cfg.initial_mode.unwrap_or(Mode::Source));
let mode = cfg.controlled_mode.unwrap_or(internal_mode);
let default_value = cfg.default_value;
let controlled_value = cfg.controlled_value;
let raw_content_ref: Rc<RefCell<Rope>> = use_hook(|| {
Rc::new(RefCell::new(Rope::from(
controlled_value
.map(|s| s.read().clone())
.or(default_value)
.unwrap_or_default(),
)))
});
let raw_content = use_signal(|| raw_content_ref.clone());
let mut parse_gen = use_signal(|| 0u64);
let raw_for_memo = raw_content;
let html_render_policy = cfg.html_render_policy;
let highlight_class_prefix = cfg.highlight_class_prefix;
let show_code_line_numbers = cfg.show_code_line_numbers;
let show_code_language = cfg.show_code_language;
let parsed_doc: Memo<Rc<crate::types::ParsedDoc>> = use_memo(move || {
let _gen = (parse_gen)();
let content_rope = raw_for_memo.read().borrow().clone();
Rc::new(crate::parser::parse_document_full_with_config(
&content_rope,
html_render_policy,
&highlight_class_prefix,
show_code_line_numbers,
show_code_language,
))
});
let trigger_parse = Callback::new(move |_: ()| {
parse_gen += 1;
});
RootState {
mode,
raw_content,
parsed_doc,
trigger_parse,
}
}
#[component]
pub fn Root(
initial_mode: Option<Mode>,
mode: Option<Signal<Mode>>,
on_mode_change: Option<EventHandler<Mode>>,
default_value: Option<String>,
value: Option<Signal<String>>,
on_value_change: Option<EventHandler<String>>,
pending_cursor: Option<Signal<Option<usize>>>,
#[props(default = false)] disabled: bool,
layout: Option<Layout>,
#[props(default)]
live_preview_variant: LivePreviewVariant,
#[props(default)]
html_render_policy: HtmlRenderPolicy,
#[props(default = "hl-".to_string())]
#[props(into)]
highlight_class_prefix: String,
#[props(default = false)]
show_code_line_numbers: bool,
#[props(default = true)]
show_code_language: bool,
#[props(default = false)]
show_editor_line_numbers: bool,
class: Option<String>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
children: Element,
) -> Element {
let state = use_root_state(RootConfig {
initial_mode,
controlled_mode: mode,
default_value,
controlled_value: value,
html_render_policy,
highlight_class_prefix: highlight_class_prefix.clone(),
show_code_line_numbers,
show_code_language,
});
let instance_n = use_hook(make_instance_n);
let is_editor_scrolling = use_signal(|| false);
let is_preview_scrolling = use_signal(|| false);
let cursor_position = use_signal(CursorPosition::default);
let selection = use_signal(|| None::<Selection>);
let preedit = use_signal(|| None::<String>);
let editor_mount: Signal<Option<Rc<MountedData>>> = use_signal(|| None);
let live_preview_variant_sig = use_signal(|| live_preview_variant);
let highlight_prefix_sig = use_signal(|| highlight_class_prefix);
use_context_provider(|| MarkdownContext {
mode: state.mode,
is_mode_controlled: mode.is_some(),
on_mode_change,
raw_content: state.raw_content,
is_value_controlled: value.is_some(),
on_value_change,
parsed_doc: state.parsed_doc,
is_editor_scrolling,
is_preview_scrolling,
instance_n,
editor_mount,
disabled,
trigger_parse: state.trigger_parse,
live_preview_variant: live_preview_variant_sig,
highlight_class_prefix: highlight_prefix_sig,
show_code_line_numbers,
show_code_language,
show_editor_line_numbers,
});
use_context_provider(|| CursorContext {
cursor_position,
selection,
preedit,
});
let raw_for_effect = state.raw_content;
let trigger = state.trigger_parse;
use_effect(move || {
let pending_offset = pending_cursor.and_then(|sig| (sig)());
if let Some(cv) = value {
let text = cv();
let current = raw_for_effect.read().borrow().to_string();
if current == text {
return;
}
*raw_for_effect.read().borrow_mut() = Rope::from(text.clone());
trigger.call(());
if let Some(byte_offset) = pending_offset {
let clamped = byte_offset.min(text.len());
{
let mut cp = cursor_position;
cp.set(CursorPosition {
offset: clamped,
line: 0,
column: 0,
});
let mut sel = selection;
sel.set(None);
}
if let Some(mut pc) = pending_cursor {
pc.set(None);
}
}
let eid = format!("nox-md-{instance_n}-editor");
spawn(async move {
let js = if let Some(byte_offset) = pending_offset {
handle_set_content_with_cursor_js(&eid, &text, byte_offset)
} else {
handle_set_content_js(&eid, &text)
};
interop::eval_void(&js).await;
});
}
});
let mode_attr = (state.mode)().to_data_attr_value();
let disabled_attr: Option<&str> = if disabled { Some("") } else { None };
let layout_attr = layout.map(|l| l.as_attr());
rsx! {
div {
class: class,
"data-md-root": "",
"data-md-mode": mode_attr,
"data-md-layout": layout_attr,
"data-disabled": disabled_attr,
..additional_attributes,
span {
aria_live: "polite",
aria_atomic: "true",
style: "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)",
"Mode: {mode_attr}"
}
{children}
}
}
}
#[component]
pub fn Editor(
class: Option<String>,
#[props(into)] placeholder: Option<String>,
#[props(default = false)] auto_focus: bool,
#[props(default = 2)] tab_size: u8,
#[props(default = true)] spell_check: bool,
#[props(default = "Markdown editor".to_string())]
#[props(into)]
editor_aria_label: String,
#[props(default = false)]
vim: bool,
on_slash_trigger: Option<EventHandler<usize>>,
on_slash_filter: Option<EventHandler<String>>,
on_active_block_input: Option<EventHandler<ActiveBlockInputEvent>>,
on_key_intercept: Option<Callback<String, bool>>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
) -> Element {
let ctx = use_context::<MarkdownContext>();
let cursor_ctx = try_use_context::<CursorContext>();
let mut vim_state = use_signal(VimState::default);
let is_composing: Rc<RefCell<bool>> = use_hook(|| Rc::new(RefCell::new(false)));
let mut is_focused = use_signal(|| false);
let mut line_count = use_signal(|| {
let initial = ctx.raw_value();
initial.chars().filter(|&c| c == '\n').count() + 1
});
let data_state = match (ctx.mode)() {
Mode::Read => "inactive",
Mode::Source | Mode::LivePreview => "active",
};
let disabled_attr: Option<&str> = if ctx.disabled { Some("") } else { None };
let focused_attr = if (is_focused)() { "true" } else { "false" };
let composing_for_input = is_composing.clone();
let composing_for_start = is_composing.clone();
let composing_for_end = is_composing;
let source_id = ctx.source_panel_id();
let editor_id = ctx.editor_id();
if (ctx.mode)() == Mode::LivePreview
&& (ctx.live_preview_variant)() == LivePreviewVariant::Inline
{
return rsx! {
div {
id: "{source_id}",
class: class,
"data-md-editor": "",
"data-state": data_state,
"data-md-editor-focused": focused_attr,
"data-md-word-wrap": "true",
"data-disabled": disabled_attr,
..additional_attributes,
InlineEditor { on_active_block_input, on_key_intercept }
}
};
}
rsx! {
div {
id: "{source_id}",
class: class,
"data-md-editor": "",
"data-state": data_state,
"data-md-editor-focused": focused_attr,
"data-md-word-wrap": "true",
"data-disabled": disabled_attr,
..additional_attributes,
if ctx.show_editor_line_numbers {
{render_editor_gutter(ctx.gutter_id(), (line_count)())}
}
textarea {
id: "{editor_id}",
role: "textbox",
aria_multiline: "true",
aria_label: editor_aria_label,
placeholder: placeholder,
spellcheck: if spell_check { "true" } else { "false" },
disabled: ctx.disabled,
initial_value: ctx.raw_value(),
onkeydown: move |evt: KeyboardEvent| {
let key = evt.key().to_string();
if let Some(ref interceptor) = on_key_intercept
&& interceptor.call(key.clone())
{
evt.prevent_default();
evt.stop_propagation();
return;
}
let ctrl = evt.modifiers().ctrl();
let shift = evt.modifiers().shift();
if vim {
let eid = ctx.editor_id();
let action = vim_state.write().handle_key(&key, ctrl, shift, &eid);
match action {
VimAction::PassThrough => {} VimAction::PreventAndEval(js) => {
evt.prevent_default(); spawn(async move { interop::eval_void(&js).await; });
return;
}
VimAction::ModeChange(_) => return, VimAction::ExecuteCommand(_) => return, }
}
if key == "Tab" {
evt.prevent_default();
let size = tab_size;
let eid = ctx.editor_id();
spawn(async move {
interop::eval_void(&tab_indent_js(&eid, size)).await;
});
return;
}
if ctrl {
let eid = ctx.editor_id();
let maybe_js = match key.as_str() {
"b" | "B" => { evt.prevent_default(); Some(wrap_selection_js(&eid, "**", "**")) }
"i" | "I" => { evt.prevent_default(); Some(wrap_selection_js(&eid, "_", "_")) }
"k" | "K" => { evt.prevent_default(); Some(wrap_selection_js(&eid, "[", "](url)")) }
_ => None,
};
if let Some(js) = maybe_js {
spawn(async move { interop::eval_void(&js).await; });
}
}
},
oninput: move |evt: FormEvent| {
let new_value = evt.value();
ctx.handle_value_change(new_value.clone());
line_count.set(new_value.chars().filter(|&c| c == '\n').count() + 1);
if !*composing_for_input.borrow() {
ctx.trigger_parse.call(());
}
if on_slash_trigger.is_some() || on_slash_filter.is_some() {
let text = new_value.clone();
let eid = ctx.editor_id();
spawn(async move {
let cursor_utf16: usize = {
let js = format!(
"dioxus.send(document.getElementById('{eid}')?.selectionStart ?? 0);"
);
let mut ev = interop::start_eval(&js);
match interop::recv_u64(&mut ev).await {
Some(pos) => pos as usize,
None => return,
}
};
if let Some(handler) = on_slash_trigger
&& let Some(trigger_offset) = detect_slash_trigger(&text, cursor_utf16)
{
handler.call(trigger_offset);
}
if let Some(handler) = on_slash_filter
&& let Some(filter) = extract_slash_filter(&text, cursor_utf16)
{
handler.call(filter);
}
});
}
if let Some(mut cursor_ctx) = cursor_ctx {
let text_clone = new_value;
let eid = ctx.editor_id();
spawn(async move {
if let Some((pos, sel)) = read_cursor_and_selection(&eid, &text_clone).await {
cursor_ctx.cursor_position.set(pos);
cursor_ctx.selection.set(sel);
}
});
}
},
oncompositionstart: move |_| {
*composing_for_start.borrow_mut() = true;
},
oncompositionend: move |_| {
*composing_for_end.borrow_mut() = false;
ctx.trigger_parse.call(());
},
onfocus: move |_| {
is_focused.set(true);
},
onblur: move |_| {
is_focused.set(false);
},
onscroll: move |_| {
if ctx.show_editor_line_numbers {
let eid = ctx.editor_id();
let gid = ctx.gutter_id();
spawn(async move {
crate::hooks::sync_gutter_scroll(&eid, &gid).await;
});
}
if (ctx.is_preview_scrolling)() { return; }
let mut editor_flag = ctx.is_editor_scrolling;
let eid = ctx.editor_id();
let pid = ctx.preview_id();
editor_flag.set(true);
spawn(async move {
sync_editor_to_preview(&eid, &pid).await;
editor_flag.set(false);
});
},
onmounted: move |evt: MountedEvent| {
let mut mount = ctx.editor_mount;
mount.set(Some(evt.data()));
if auto_focus {
spawn(async move {
let _ = evt.data().set_focus(true).await;
});
}
},
}
}
}
}
#[component]
pub fn Preview(
class: Option<String>,
#[props(default = "Markdown preview".to_string())]
#[props(into)]
preview_aria_label: String,
on_block_click: Option<EventHandler<usize>>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
) -> Element {
let ctx = use_context::<MarkdownContext>();
let block_click_task: Rc<RefCell<Option<dioxus_core::Task>>> =
use_hook(|| Rc::new(RefCell::new(None)));
{
let task_handle = block_click_task.clone();
use_drop(move || {
if let Some(task) = task_handle.borrow_mut().take() {
task.cancel();
}
});
}
let data_state = match (ctx.mode)() {
Mode::LivePreview if (ctx.live_preview_variant)() != LivePreviewVariant::Inline => "active",
_ => "inactive",
};
let parsed = (ctx.parsed_doc)();
let preview_id = ctx.preview_id();
rsx! {
div {
id: "{preview_id}",
class: class,
role: "region",
aria_label: preview_aria_label,
"data-md-preview": "",
"data-state": data_state,
"data-md-preview-loading": "false",
onscroll: move |_| {
if (ctx.is_editor_scrolling)() { return; }
let mut preview_flag = ctx.is_preview_scrolling;
let eid = ctx.editor_id();
let pid = ctx.preview_id();
preview_flag.set(true);
spawn(async move {
sync_preview_to_editor(&eid, &pid).await;
preview_flag.set(false);
});
},
onmounted: {
let task_handle = block_click_task.clone();
move |_| {
if on_block_click.is_some() {
let pid = ctx.preview_id();
let task = spawn(async move {
let mut ev = interop::start_eval(&block_click_js(&pid));
while let Some(line) = interop::recv_u64(&mut ev).await {
if let Some(handler) = on_block_click {
handler.call(line as usize);
}
}
});
*task_handle.borrow_mut() = Some(task);
}
}
},
..additional_attributes,
{parsed.element.clone()}
}
}
}
#[component]
pub fn Content(
class: Option<String>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
) -> Element {
let ctx = use_context::<MarkdownContext>();
let parsed = (ctx.parsed_doc)();
let read_id = ctx.read_panel_id();
let mut mounted: Signal<Option<Rc<MountedData>>> = use_signal(|| None);
use_effect(move || {
let mode = (ctx.mode)();
if mode == Mode::Read
&& let Some(node) = mounted.read().as_ref()
{
let node = node.clone();
spawn(async move {
let _ = node.set_focus(true).await;
});
}
});
rsx! {
div {
id: "{read_id}",
class: class,
role: "article",
tabindex: "-1",
"data-md-mode": "read",
onmounted: move |evt: MountedEvent| {
mounted.set(Some(evt.data()));
},
onkeydown: move |evt: KeyboardEvent| {
let key = evt.key().to_string();
let ctrl_or_meta = evt.modifiers().ctrl() || evt.modifiers().meta();
if ctrl_or_meta && (key == "a" || key == "A") {
evt.prevent_default();
let rid = ctx.read_panel_id();
spawn(async move {
interop::eval_void(&select_all_children_js(&rid)).await;
});
}
},
..additional_attributes,
{parsed.element.clone()}
}
}
}
#[component]
pub fn Toolbar(
class: Option<String>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
children: Element,
) -> Element {
let ctx = use_context::<MarkdownContext>();
let disabled_attr: Option<&str> = if ctx.disabled { Some("") } else { None };
rsx! {
div {
class: class,
role: "toolbar",
aria_orientation: "horizontal",
"data-disabled": disabled_attr,
..additional_attributes,
{children}
}
}
}
#[component]
pub fn ToolbarButton(
class: Option<String>,
#[props(default = false)] disabled: bool,
#[props(default = false)] as_child: bool,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
onclick: Option<EventHandler<MouseEvent>>,
on_activate: Option<EventHandler<()>>,
children: Element,
) -> Element {
let disabled_attr: Option<&str> = if disabled { Some("") } else { None };
let click_handler = move |e: MouseEvent| {
if let Some(handler) = &onclick {
handler.call(e);
}
};
if as_child {
rsx! {
span {
role: "button",
tabindex: "0",
aria_disabled: if disabled { "true" } else { "false" },
class: class,
"data-disabled": disabled_attr,
onclick: click_handler,
onkeydown: move |evt: KeyboardEvent| {
let key = evt.key().to_string();
if !disabled && (key == "Enter" || key == " ") {
evt.prevent_default();
if let Some(handler) = on_activate {
handler.call(());
}
}
},
..additional_attributes,
{children}
}
}
} else {
rsx! {
button {
class: class,
r#type: "button",
disabled: disabled,
"data-disabled": disabled_attr,
onclick: click_handler,
..additional_attributes,
{children}
}
}
}
}
#[component]
pub fn ToolbarSeparator(
class: Option<String>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
) -> Element {
rsx! {
div {
class: class,
role: "separator",
"data-orientation": "vertical",
..additional_attributes,
}
}
}
#[component]
pub fn ModeBar(
class: Option<String>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
children: Element,
) -> Element {
let ctx = use_context::<MarkdownContext>();
let mode_attr = (ctx.mode)().to_data_attr_value();
rsx! {
div {
class: class,
role: "tablist",
"data-md-mode": mode_attr,
..additional_attributes,
{children}
}
}
}
pub(crate) fn next_mode(m: Mode) -> Mode {
match m {
Mode::Read => Mode::Source,
Mode::Source => Mode::LivePreview,
Mode::LivePreview => Mode::Read,
}
}
pub(crate) fn prev_mode(m: Mode) -> Mode {
match m {
Mode::Read => Mode::LivePreview,
Mode::Source => Mode::Read,
Mode::LivePreview => Mode::Source,
}
}
pub(crate) fn panel_id_for_mode(mode: Mode, ctx: &MarkdownContext) -> String {
match mode {
Mode::Source => ctx.source_panel_id(),
Mode::LivePreview => ctx.preview_id(),
Mode::Read => ctx.read_panel_id(),
}
}
#[component]
pub fn ModeTab(
mode: Mode,
class: Option<String>,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
children: Element,
) -> Element {
let mut ctx = use_context::<MarkdownContext>();
let current_mode = (ctx.mode)();
let is_active = current_mode == mode;
let data_state = if is_active { "active" } else { "inactive" };
let aria_selected = if is_active { "true" } else { "false" };
let mode_attr = mode.to_data_attr_value();
let panel_id = panel_id_for_mode(mode, &ctx);
rsx! {
button {
class: class,
role: "tab",
tabindex: if is_active { "0" } else { "-1" },
aria_selected: aria_selected,
aria_controls: "{panel_id}",
"data-state": data_state,
"data-mode": mode_attr,
onclick: move |_| {
ctx.handle_mode_change(mode);
},
onkeydown: move |evt: KeyboardEvent| {
let key = evt.key().to_string();
let new_mode = match key.as_str() {
"ArrowRight" | "ArrowDown" => Some(next_mode(current_mode)),
"ArrowLeft" | "ArrowUp" => Some(prev_mode(current_mode)),
"Home" => Some(Mode::Read),
"End" => Some(Mode::LivePreview),
_ => None,
};
if let Some(m) = new_mode {
evt.prevent_default();
ctx.handle_mode_change(m);
}
},
..additional_attributes,
{children}
}
}
}
#[component]
pub fn Divider(
class: Option<String>,
#[props(default)] orientation: Orientation,
#[props(extends = GlobalAttributes)] additional_attributes: Vec<Attribute>,
) -> Element {
let ctx = use_context::<MarkdownContext>();
if (ctx.mode)() == Mode::LivePreview
&& (ctx.live_preview_variant)() == LivePreviewVariant::Inline
{
return rsx! {};
}
rsx! {
div {
class: class,
role: "separator",
"data-md-splitter": "",
"data-orientation": orientation.as_attr(),
"data-md-splitter-dragging": "false",
..additional_attributes,
}
}
}
fn render_editor_gutter(gutter_id: String, line_count: usize) -> Element {
let lines: Vec<usize> = (1..=line_count).collect();
rsx! {
div {
id: "{gutter_id}",
"data-md-line-gutter": "",
"data-md-editor-gutter": "",
aria_hidden: "true",
style: "user-select:none;overflow:hidden",
for i in lines {
div { "data-md-line-number": "{i}", "{i}" }
}
}
}
}
pub(crate) fn utf16_to_byte_index(s: &str, utf16_idx: usize) -> Option<usize> {
let mut utf16_count = 0usize;
for (byte_idx, ch) in s.char_indices() {
if utf16_count == utf16_idx {
return Some(byte_idx);
}
utf16_count += ch.len_utf16();
}
if utf16_count == utf16_idx {
Some(s.len())
} else {
None
}
}
pub(crate) fn block_click_js(preview_id: &str) -> String {
format!(
r#"(function() {{
var el = document.getElementById('{preview_id}');
if (!el) return;
el.addEventListener('click', function(e) {{
var target = e.target;
while (target && target !== el) {{
var line = target.getAttribute('data-source-start');
if (line !== null) {{
dioxus.send(parseInt(line, 10));
return;
}}
target = target.parentNode;
}}
}});
}})();"#,
preview_id = preview_id,
)
}
pub(crate) fn detect_slash_trigger(text: &str, cursor_utf16: usize) -> Option<usize> {
if cursor_utf16 == 0 {
return None;
}
let cursor = utf16_to_byte_index(text, cursor_utf16)?;
let before = &text[..cursor];
let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_content = &before[line_start..];
if line_content.starts_with('/') {
Some(line_start)
} else {
None
}
}
pub(crate) fn extract_slash_filter(text: &str, cursor_utf16: usize) -> Option<String> {
let slash_offset = detect_slash_trigger(text, cursor_utf16)?;
let cursor = utf16_to_byte_index(text, cursor_utf16)?;
let filter = &text[slash_offset + 1..cursor];
if filter.contains(' ') || filter.contains('\n') {
None
} else {
Some(filter.to_string())
}
}