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 {
` 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 `