From 2f53feccb1265c1651f8f7e9a5226fe46b89be5e Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Sat, 15 Aug 2026 09:32:26 +0700 Subject: [PATCH 1/7] feat(highlighter): render token streams as inline-HTML spans Add highlight_to_html() (plus the token_to_html and escape_html helpers) so the code editor can overlay syntax-colored text behind a transparent-text textarea. Pure string building - no web-sys or Leptos types, unit-tested on any host. --- crates/highlighter/src/lib.rs | 213 ++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/crates/highlighter/src/lib.rs b/crates/highlighter/src/lib.rs index d9801fc..df70f76 100644 --- a/crates/highlighter/src/lib.rs +++ b/crates/highlighter/src/lib.rs @@ -182,6 +182,98 @@ pub fn highlight( Ok(tokens) } +/// Escape `&`, `<`, and `>` for safe embedding of source text in HTML +/// (quotes are safe in element text content). +/// +/// # Example +/// ``` +/// assert_eq!(codeframe_highlighter::escape_html("a < b && c > d"), "a < b && c > d"); +/// ``` +pub fn escape_html(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } + out +} + +/// Render a single [`Token`] as an inline-HTML ``. +/// +/// # Example +/// ``` +/// use codeframe_models::{FontStyle, RgbColor, Token}; +/// let token = Token { +/// text: "let".to_string(), +/// color: RgbColor::new(0x98, 0xc3, 0x79), +/// font_style: FontStyle { bold: false, italic: true, underline: false }, +/// }; +/// let html = codeframe_highlighter::token_to_html(&token); +/// assert_eq!(html, "let"); +/// ``` +pub fn token_to_html(token: &Token) -> String { + let mut out = String::with_capacity(token.text.len() * 2 + 48); + out.push_str("'); + out.push_str(&escape_html(&token.text)); + out.push_str(""); + out +} + +/// Highlight `code` as `language` under `theme_choice` and render the token +/// stream as inline-HTML spans, suitable for the `innerHTML` of an overlay +/// `
` behind a transparent-text textarea.
+///
+/// * Every token becomes a `` with `color:` plus `font-weight` /
+///   `font-style` / `text-decoration` when the theme marks them (see
+///   [`token_to_html`]).
+/// * Text is HTML-escaped; newlines are preserved verbatim (the overlay uses
+///   `white-space: pre-wrap` so the browser lays them out like the textarea).
+/// * Requires no web-sys or Leptos types - pure string building, unit-testable
+///   on any host.
+///
+/// # Example
+/// ```
+/// use codeframe_models::{Language, ThemeChoice};
+/// let html = codeframe_highlighter::highlight_to_html(
+///     "fn main() {}",
+///     Language::Rust,
+///     ThemeChoice::Dracula,
+/// )?;
+/// assert!(html.contains("color:#ff79c6"), "expected a pink `fn` span: {html}");
+/// assert!(html.starts_with("(())
+/// ```
+pub fn highlight_to_html(
+  code: &str,
+  language: Language,
+  theme_choice: ThemeChoice,
+) -> Result {
+  let tokens = highlight(code, language, theme_choice)?;
+  let mut out = String::with_capacity(code.len() * 2);
+  for token in &tokens {
+    out.push_str(&token_to_html(token));
+  }
+  Ok(out)
+}
+
 #[cfg(test)]
 mod tests {
   use super::*;
@@ -268,4 +360,125 @@ mod tests {
       "expected `interface` as a keyword token, got: {tokens:?}"
     );
   }
+
+  #[test]
+  fn escape_html_escapes_ampersands_and_angle_brackets() {
+    assert_eq!(
+      escape_html("a < b && c > d"),
+      "a < b && c > d"
+    );
+    assert_eq!(escape_html("plain text"), "plain text");
+    assert_eq!(escape_html(""), "");
+  }
+
+  #[test]
+  fn highlight_to_html_escapes_source_and_styles_keywords() {
+    let html = highlight_to_html(
+      "fn main() { let x = \"&\"; }",
+      Language::Rust,
+      ThemeChoice::Dracula,
+    )
+    .unwrap();
+    assert!(
+      html.contains("<b>&"),
+      "source must be escaped: {html}"
+    );
+    assert!(
+      html.contains("color:#ff79c6"),
+      "expected pink `fn` span: {html}"
+    );
+    assert!(html.starts_with(""));
+  }
+
+  #[test]
+  fn token_to_html_emits_theme_styles() {
+    let bold = Token {
+      text: "B".into(),
+      color: RgbColor::new(0xff, 0x00, 0x00),
+      font_style: FontStyle {
+        bold: true,
+        italic: false,
+        underline: false,
+      },
+    };
+    assert_eq!(
+      token_to_html(&bold),
+      "B"
+    );
+    let italic = Token {
+      text: "i".into(),
+      color: RgbColor::new(0x00, 0xff, 0x00),
+      font_style: FontStyle {
+        bold: false,
+        italic: true,
+        underline: false,
+      },
+    };
+    assert_eq!(
+      token_to_html(&italic),
+      "i"
+    );
+    let underlined = Token {
+      text: "u".into(),
+      color: RgbColor::new(0x00, 0x00, 0xff),
+      font_style: FontStyle {
+        bold: false,
+        italic: false,
+        underline: true,
+      },
+    };
+    assert_eq!(
+      token_to_html(&underlined),
+      "u"
+    );
+    let plain = Token {
+      text: "p".into(),
+      color: RgbColor::new(0xaa, 0xbb, 0xcc),
+      font_style: FontStyle::default(),
+    };
+    assert_eq!(
+      token_to_html(&plain),
+      "p"
+    );
+  }
+
+  #[test]
+  fn token_to_html_escapes_angle_brackets_in_text() {
+    let token = Token {
+      text: "&".into(),
+      color: RgbColor::new(0xaa, 0xbb, 0xcc),
+      font_style: FontStyle::default(),
+    };
+    assert_eq!(
+      token_to_html(&token),
+      "<tag>&"
+    );
+  }
+
+  #[test]
+  fn highlight_to_html_preserves_newlines_and_round_trips_source() {
+    let code = "fn main() {\n\tlet s = \"hi\";\n}\n";
+    let html = highlight_to_html(code, Language::Rust, ThemeChoice::Nord).unwrap();
+    assert!(html.contains('\n'));
+    assert_eq!(
+      html.matches("").count()
+    );
+    let mut reconstructed = String::new();
+    for part in html.split("") {
+      let Some(inner) = part.split_once('>') else {
+        continue;
+      };
+      reconstructed.push_str(&inner.1);
+    }
+    assert_eq!(reconstructed, code, "html must round-trip source text");
+  }
+
+  #[test]
+  fn highlight_to_html_empty_input_is_empty() {
+    assert_eq!(
+      highlight_to_html("", Language::Rust, ThemeChoice::Dracula).unwrap(),
+      ""
+    );
+  }
 }

From 63de24c5e8889f66771c95108159c27a0dca5135 Mon Sep 17 00:00:00 2001
From: Suradet Pratomsak 
Date: Sat, 15 Aug 2026 09:33:39 +0700
Subject: [PATCH 2/7] feat(app): add syntax-highlighted code editor overlay

New CodeEditor component: a transparent-text textarea over a pre
layer whose innerHTML comes from highlighter::highlight_to_html.
Both panels (main + split) now mirror the selected syntax theme -
the editor background and caret use the theme palette, scroll syncs
between layers, and Tab still inserts spaces.
---
 crates/app/src/controls.rs | 54 ++--------------------
 crates/app/src/editor.rs   | 92 ++++++++++++++++++++++++++++++++++++++
 crates/app/src/main.rs     |  1 +
 3 files changed, 97 insertions(+), 50 deletions(-)
 create mode 100644 crates/app/src/editor.rs

diff --git a/crates/app/src/controls.rs b/crates/app/src/controls.rs
index 06e5ed0..9d42c5c 100644
--- a/crates/app/src/controls.rs
+++ b/crates/app/src/controls.rs
@@ -3,9 +3,9 @@
 
 use codeframe_models::{Background, FontChoice, Language, ThemeChoice};
 use leptos::prelude::*;
-use wasm_bindgen::JsCast;
 
-use crate::state::{Settings, SAMPLE_CODE};
+use crate::editor::CodeEditor;
+use crate::state::Settings;
 
 const SCALE_PRESETS: [f64; 4] = [1.0, 2.0, 4.0, 8.0];
 
@@ -259,64 +259,18 @@ pub fn Controls(settings: Settings) -> impl IntoView {
               
- +
{move || { settings.split_enabled.get().then(|| { - let code_signal = settings.split_code; view! {
- +
diff --git a/crates/app/src/editor.rs b/crates/app/src/editor.rs new file mode 100644 index 0000000..80b4bac --- /dev/null +++ b/crates/app/src/editor.rs @@ -0,0 +1,92 @@ +//! Syntax-highlighted code input: a transparent-text textarea overlaid on a +//! `
` rendered from the token stream, so the input mirrors the export.
+
+use codeframe_models::{Language, ThemeChoice};
+use leptos::prelude::*;
+use wasm_bindgen::JsCast;
+
+/// Palette-derived editor background + caret color, painted on the wrapper so
+/// the input matches the export canvas for the same theme.
+fn editor_colors(theme: ThemeChoice) -> String {
+  match codeframe_highlighter::theme_palette(theme) {
+    Ok(palette) => format!(
+      "background: {}; caret-color: {};",
+      palette.background.to_css(),
+      palette.foreground.to_css()
+    ),
+    Err(_) => "caret-color: var(--ink);".to_string(),
+  }
+}
+
+/// A code textarea with live syntax highlighting behind the text.
+///
+/// The `
` layer carries the token colors (HTML from
+/// `codeframe_highlighter::highlight_to_html`); the textarea above it uses
+/// transparent text so only the caret and selection are visible. Both layers
+/// share identical font metrics and wrapping, and the `
` scrolls in
+/// lockstep with the textarea.
+#[component]
+pub fn CodeEditor(
+  id: &'static str,
+  code: RwSignal,
+  language: RwSignal,
+  theme: RwSignal,
+) -> impl IntoView {
+  let pre_ref: NodeRef = NodeRef::new();
+  let textarea_ref: NodeRef = NodeRef::new();
+
+  let html = Memo::new(move |_| {
+    let code = code.get();
+    let language = language.get();
+    let theme = theme.get();
+    match codeframe_highlighter::highlight_to_html(&code, language, theme) {
+      Ok(html) => html,
+      Err(e) => format!(
+        "{}",
+        codeframe_highlighter::escape_html(&e.to_string())
+      ),
+    }
+  });
+
+  view! {
+      
+ + +
+ } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 01f30c5..2009661 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -5,6 +5,7 @@ #![deny(unsafe_code)] mod controls; +mod editor; mod export; mod fonts; mod preview; From 525cd7d004623baeab7a0d95e0d2d237ea375c02 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Sat, 15 Aug 2026 09:53:20 +0700 Subject: [PATCH 3/7] feat(ui): style the highlighted code editor overlay The editor wrapper carries the theme palette as background and the caret uses the theme foreground, so the input mirrors the export. The pre overlay and the transparent-text textarea share identical font metrics and wrapping (pre-wrap, break-all) and the pre layer scrolls in lockstep with the textarea. --- style.css | 49 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/style.css b/style.css index c2cd54d..7708925 100644 --- a/style.css +++ b/style.css @@ -480,28 +480,57 @@ body { gap: var(--sp-md); } -.code-input { - width: 100%; - resize: vertical; - background: var(--canvas-warm); - color: var(--ink); +.code-editor { + position: relative; border: 1px solid var(--hairline); + transition: border-color 0.15s ease; +} + +.code-editor:hover { + border-color: var(--stone); +} + +.code-editor:focus-within { + border-color: var(--ink); +} + +.code-editor-highlight { + position: absolute; + inset: 0; + margin: 0; padding: var(--sp-sm); + overflow: hidden; + pointer-events: none; font-family: "JetBrains Mono", monospace; font-size: 12.5px; line-height: 1.5; tab-size: 4; - transition: border-color 0.15s ease, background 0.15s ease; + white-space: pre-wrap; + word-break: break-all; } -.code-input:hover { - border-color: var(--stone); +.code-input { + position: relative; + width: 100%; + resize: vertical; + background: transparent; + color: transparent; + border: none; + padding: var(--sp-sm); + font-family: "JetBrains Mono", monospace; + font-size: 12.5px; + line-height: 1.5; + tab-size: 4; + white-space: pre-wrap; + word-break: break-all; } .code-input:focus { outline: none; - border-color: var(--ink); - background: var(--canvas); +} + +.code-input::selection { + background: color-mix(in srgb, var(--ink) 25%, transparent); } select, From c887ad470fb10e5fdad993783fbc833798beb8b5 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Sat, 15 Aug 2026 09:53:37 +0700 Subject: [PATCH 4/7] docs: document the highlighted code editor overlay pattern --- docs/DESIGN.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d4aa700..b03c23c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -208,12 +208,23 @@ image's internal geometry: `gap: var(--sp-lg)` - Field labels: 11px uppercase, `var(--stone)`, letter-spacing 0.035em -### Code Input (`.code-input`) - -- Background: `var(--canvas-warm)` -- Full hairline border, no border-radius -- Hover: border becomes `var(--stone)` -- Focus: border becomes `var(--ink)`, background lifts to `var(--canvas)` +### Code Editor (`.code-editor`) + +Syntax-highlighted input: a transparent-text `