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..440e209 --- /dev/null +++ b/crates/app/src/editor.rs @@ -0,0 +1,94 @@ +//! 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; diff --git a/crates/highlighter/src/lib.rs b/crates/highlighter/src/lib.rs index d9801fc..ebde816 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(),
+      ""
+    );
+  }
 }
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 `