From 24fae927c20c903cd507fb476e1e3ddca254ec91 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Mon, 3 Aug 2026 10:05:55 +0700 Subject: [PATCH 1/3] fix: expand tabs on paste to match tab-width setting When code with tab characters is pasted into the textarea, the raw tabs now get expanded to spaces based on the current tab-width setting. Previously, pressing Tab inserted spaces correctly, but pasting code with actual tab characters left them as-is in the textarea, causing a visual mismatch between the textarea display and the canvas output. Changes: - Add on:paste handler to both code textareas - Expand tabs to spaces using current tab_width before inserting - Add ClipboardEvent and DataTransfer web-sys features --- crates/app/Cargo.toml | 2 ++ crates/app/src/controls.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a36811c..5e163dc 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -25,7 +25,9 @@ web-sys = { version = "0.3", features = [ "BlobPropertyBag", "CanvasRenderingContext2d", "Clipboard", + "ClipboardEvent", "ClipboardItem", + "DataTransfer", "Document", "Element", "Event", diff --git a/crates/app/src/controls.rs b/crates/app/src/controls.rs index e7d72ae..1d83ae7 100644 --- a/crates/app/src/controls.rs +++ b/crates/app/src/controls.rs @@ -126,6 +126,25 @@ pub fn Controls(settings: Settings) -> impl IntoView { spellcheck="false" autocomplete="off" on:input=move |ev| settings.code.set(event_target_value(&ev)) + on:paste=move |ev| { + ev.prevent_default(); + let web_event: &web_sys::Event = ev.as_ref(); + let clipboard_event: &web_sys::ClipboardEvent = web_event.unchecked_ref(); + let text = clipboard_event.clipboard_data() + .and_then(|cb| cb.get_data("text/plain").ok()) + .unwrap_or_default(); + let expanded = text.replace('\t', &" ".repeat(settings.tab_width.get())); + let target = ev.target().unwrap(); + let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into(); + let start = textarea.selection_start().unwrap_or_default().unwrap_or(0) as usize; + let end = textarea.selection_end().unwrap_or_default().unwrap_or(0) as usize; + let value = textarea.value(); + let new_value = format!("{}{}{}", &value[..start], expanded, &value[end..]); + settings.code.set(new_value.clone()); + textarea.set_value(&new_value); + let pos = (start + expanded.len()) as u32; + let _ = textarea.set_selection_range(pos, pos); + } on:keydown=move |ev| { if ev.key() == "Tab" { ev.prevent_default(); @@ -159,6 +178,25 @@ pub fn Controls(settings: Settings) -> impl IntoView { autocomplete="off" prop:value=move || code_signal.get() on:input=move |ev| code_signal.set(event_target_value(&ev)) + on:paste=move |ev| { + ev.prevent_default(); + let web_event: &web_sys::Event = ev.as_ref(); + let clipboard_event: &web_sys::ClipboardEvent = web_event.unchecked_ref(); + let text = clipboard_event.clipboard_data() + .and_then(|cb| cb.get_data("text/plain").ok()) + .unwrap_or_default(); + let expanded = text.replace('\t', &" ".repeat(settings.tab_width.get())); + let target = ev.target().unwrap(); + let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into(); + let start = textarea.selection_start().unwrap_or_default().unwrap_or(0) as usize; + let end = textarea.selection_end().unwrap_or_default().unwrap_or(0) as usize; + let value = textarea.value(); + let new_value = format!("{}{}{}", &value[..start], expanded, &value[end..]); + code_signal.set(new_value.clone()); + textarea.set_value(&new_value); + let pos = (start + expanded.len()) as u32; + let _ = textarea.set_selection_range(pos, pos); + } on:keydown=move |ev| { if ev.key() == "Tab" { ev.prevent_default(); From fc515e777ca552447e0d27d7073f47504440fe29 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Mon, 3 Aug 2026 10:13:18 +0700 Subject: [PATCH 2/3] fix: use get_untracked for NodeRef in async context canvas_ref.get() inside spawn_local async block was outside reactive tracking context, causing a Leptos warning. Changed to get_untracked() since NodeRef access here doesn't need reactive tracking. --- crates/app/src/preview.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/preview.rs b/crates/app/src/preview.rs index 685e82b..9ad8150 100644 --- a/crates/app/src/preview.rs +++ b/crates/app/src/preview.rs @@ -58,7 +58,7 @@ pub fn Preview(settings: Settings) -> impl IntoView { if generation.get_untracked() != my_generation { return; // superseded by a newer draw } - let Some(canvas) = canvas_ref.get() else { + let Some(canvas) = canvas_ref.get_untracked() else { return; }; From 07d8c7ac60ad5b7e2d6c436e039a191574a8fd35 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Mon, 3 Aug 2026 10:20:24 +0700 Subject: [PATCH 3/3] revert: remove tab-width user control Tab width setting was causing bugs with pasted code and wasn't essential. Hardcode tab_width=4 in ExportOptions. Tab key in textarea still inserts 4 spaces. Remove paste handlers and ClipboardEvent/DataTransfer features. --- crates/app/Cargo.toml | 2 -- crates/app/src/controls.rs | 71 +++----------------------------------- crates/app/src/state.rs | 4 +-- 3 files changed, 5 insertions(+), 72 deletions(-) diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5e163dc..a36811c 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -25,9 +25,7 @@ web-sys = { version = "0.3", features = [ "BlobPropertyBag", "CanvasRenderingContext2d", "Clipboard", - "ClipboardEvent", "ClipboardItem", - "DataTransfer", "Document", "Element", "Event", diff --git a/crates/app/src/controls.rs b/crates/app/src/controls.rs index 1d83ae7..2a0675f 100644 --- a/crates/app/src/controls.rs +++ b/crates/app/src/controls.rs @@ -8,7 +8,6 @@ use wasm_bindgen::JsCast; use crate::state::{Settings, SAMPLE_CODE}; const SCALE_PRESETS: [f64; 4] = [1.0, 2.0, 4.0, 8.0]; -const TAB_WIDTH_PRESETS: [usize; 3] = [2, 4, 8]; /// CSS value used to paint a background swatch button. fn background_css(background: &Background) -> String { @@ -126,25 +125,6 @@ pub fn Controls(settings: Settings) -> impl IntoView { spellcheck="false" autocomplete="off" on:input=move |ev| settings.code.set(event_target_value(&ev)) - on:paste=move |ev| { - ev.prevent_default(); - let web_event: &web_sys::Event = ev.as_ref(); - let clipboard_event: &web_sys::ClipboardEvent = web_event.unchecked_ref(); - let text = clipboard_event.clipboard_data() - .and_then(|cb| cb.get_data("text/plain").ok()) - .unwrap_or_default(); - let expanded = text.replace('\t', &" ".repeat(settings.tab_width.get())); - let target = ev.target().unwrap(); - let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into(); - let start = textarea.selection_start().unwrap_or_default().unwrap_or(0) as usize; - let end = textarea.selection_end().unwrap_or_default().unwrap_or(0) as usize; - let value = textarea.value(); - let new_value = format!("{}{}{}", &value[..start], expanded, &value[end..]); - settings.code.set(new_value.clone()); - textarea.set_value(&new_value); - let pos = (start + expanded.len()) as u32; - let _ = textarea.set_selection_range(pos, pos); - } on:keydown=move |ev| { if ev.key() == "Tab" { ev.prevent_default(); @@ -153,11 +133,10 @@ pub fn Controls(settings: Settings) -> impl IntoView { let start = textarea.selection_start().unwrap_or_default().unwrap_or(0) as usize; let end = textarea.selection_end().unwrap_or_default().unwrap_or(0) as usize; let value = textarea.value(); - let spaces = " ".repeat(settings.tab_width.get()); - let new_value = format!("{}{}{}", &value[..start], spaces, &value[end..]); + let new_value = format!("{} {}", &value[..start], &value[end..]); settings.code.set(new_value.clone()); textarea.set_value(&new_value); - let pos = (start + spaces.len()) as u32; + let pos = (start + 4) as u32; let _ = textarea.set_selection_range(pos, pos); } } @@ -178,25 +157,6 @@ pub fn Controls(settings: Settings) -> impl IntoView { autocomplete="off" prop:value=move || code_signal.get() on:input=move |ev| code_signal.set(event_target_value(&ev)) - on:paste=move |ev| { - ev.prevent_default(); - let web_event: &web_sys::Event = ev.as_ref(); - let clipboard_event: &web_sys::ClipboardEvent = web_event.unchecked_ref(); - let text = clipboard_event.clipboard_data() - .and_then(|cb| cb.get_data("text/plain").ok()) - .unwrap_or_default(); - let expanded = text.replace('\t', &" ".repeat(settings.tab_width.get())); - let target = ev.target().unwrap(); - let textarea: web_sys::HtmlTextAreaElement = target.unchecked_into(); - let start = textarea.selection_start().unwrap_or_default().unwrap_or(0) as usize; - let end = textarea.selection_end().unwrap_or_default().unwrap_or(0) as usize; - let value = textarea.value(); - let new_value = format!("{}{}{}", &value[..start], expanded, &value[end..]); - code_signal.set(new_value.clone()); - textarea.set_value(&new_value); - let pos = (start + expanded.len()) as u32; - let _ = textarea.set_selection_range(pos, pos); - } on:keydown=move |ev| { if ev.key() == "Tab" { ev.prevent_default(); @@ -205,11 +165,10 @@ pub fn Controls(settings: Settings) -> impl IntoView { let start = textarea.selection_start().unwrap_or_default().unwrap_or(0) as usize; let end = textarea.selection_end().unwrap_or_default().unwrap_or(0) as usize; let value = textarea.value(); - let spaces = " ".repeat(settings.tab_width.get()); - let new_value = format!("{}{}{}", &value[..start], spaces, &value[end..]); + let new_value = format!("{} {}", &value[..start], &value[end..]); code_signal.set(new_value.clone()); textarea.set_value(&new_value); - let pos = (start + spaces.len()) as u32; + let pos = (start + 4) as u32; let _ = textarea.set_selection_range(pos, pos); } } @@ -280,28 +239,6 @@ pub fn Controls(settings: Settings) -> impl IntoView { -
- -
- {TAB_WIDTH_PRESETS - .into_iter() - .map(|preset| { - view! { - - } - }) - .collect_view()} -
-
-
diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 638bcb0..5f5fc22 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -91,7 +91,6 @@ pub struct Settings { pub background: RwSignal, pub ui_theme: RwSignal, pub line_height: RwSignal, - pub tab_width: RwSignal, pub filename_template: RwSignal, /// When `Some(px)`, export width is clamped to this value and scale is /// computed automatically. `None` means use the manual scale slider. @@ -123,7 +122,6 @@ impl Settings { background: RwSignal::new(Background::default()), ui_theme: RwSignal::new(initial_ui_theme), line_height: RwSignal::new(1.5), - tab_width: RwSignal::new(4), filename_template: RwSignal::new("codeframe-{scale}x".to_string()), target_width: RwSignal::new(None), split_enabled: RwSignal::new(false), @@ -150,7 +148,7 @@ impl Settings { font_size: self.font_size.get(), line_height: self.line_height.get(), corner_radius: self.corner_radius.get(), - tab_width: self.tab_width.get(), + tab_width: 4, } }