longcipher_leptos_components/components/editor/
core.rs

1//! Core Editor Component
2//!
3//! The main text editor component with full editing capabilities.
4
5use leptos::prelude::*;
6
7use super::state::{EditorConfig, EditorState};
8
9/// A production-ready text editor component.
10///
11/// The Editor provides a full-featured text editing experience with:
12/// - Syntax highlighting (with `syntax-highlighting` feature)
13/// - Line numbers
14/// - Find and replace (with `find-replace` feature)
15/// - Undo/redo
16/// - Multiple cursors (planned)
17/// - Code folding (with `folding` feature)
18///
19/// # Example
20///
21/// ```rust,ignore
22/// use leptos::prelude::*;
23/// use longcipher_leptos_components::editor::Editor;
24///
25/// #[component]
26/// fn MyEditor() -> impl IntoView {
27///     let (content, set_content) = signal(String::new());
28///
29///     view! {
30///         <Editor
31///             value=content
32///             on_change=move |v| set_content.set(v)
33///             language="rust"
34///             show_line_numbers=true
35///         />
36///     }
37/// }
38/// ```
39///
40/// # Styling
41///
42/// The editor uses CSS custom properties for theming. Override these in your CSS:
43///
44/// ```css
45/// .leptos-editor {
46///     --editor-bg: #1e1e1e;
47///     --editor-fg: #d4d4d4;
48///     --editor-line-number-fg: #858585;
49///     --editor-selection-bg: #264f78;
50///     --editor-cursor: #aeafad;
51/// }
52/// ```
53#[component]
54#[allow(
55    clippy::too_many_lines,
56    clippy::needless_pass_by_value,
57    clippy::fn_params_excessive_bools
58)]
59pub fn Editor(
60    /// The current value of the editor (controlled)
61    #[prop(into)]
62    value: Signal<String>,
63
64    /// Callback when the value changes
65    #[prop(into, optional)]
66    on_change: Option<Callback<String>>,
67
68    /// Placeholder text shown when editor is empty
69    #[prop(into, optional)]
70    placeholder: Option<String>,
71
72    /// Programming language for syntax highlighting (e.g., "rust", "javascript")
73    #[prop(into, optional)]
74    language: Option<String>,
75
76    /// Whether the editor is read-only
77    #[prop(optional, default = false)]
78    read_only: bool,
79
80    /// Whether to show line numbers
81    #[prop(optional, default = true)]
82    show_line_numbers: bool,
83
84    /// Whether word wrap is enabled
85    #[prop(optional, default = true)]
86    word_wrap: bool,
87
88    /// Tab size in spaces
89    #[prop(optional, default = 4)]
90    tab_size: usize,
91
92    /// Font size in pixels
93    #[prop(optional, default = 14.0)]
94    font_size: f32,
95
96    /// Additional CSS classes to apply
97    #[prop(into, optional)]
98    class: Option<String>,
99
100    /// Minimum height (CSS value like "200px" or "10rem")
101    #[prop(into, optional)]
102    min_height: Option<String>,
103
104    /// Maximum height (CSS value like "500px" or "80vh")
105    #[prop(into, optional)]
106    max_height: Option<String>,
107
108    /// ID attribute for the editor element
109    #[prop(into, optional)]
110    id: Option<String>,
111
112    /// Callback when the editor receives focus
113    #[prop(into, optional)]
114    on_focus: Option<Callback<()>>,
115
116    /// Callback when the editor loses focus
117    #[prop(into, optional)]
118    on_blur: Option<Callback<()>>,
119
120    /// Callback when cursor position changes (line, column)
121    #[prop(into, optional)]
122    on_cursor_change: Option<Callback<(usize, usize)>>,
123
124    /// Callback when selection changes (selected text or None)
125    #[prop(into, optional)]
126    on_selection_change: Option<Callback<Option<String>>>,
127
128    /// Whether to auto-focus on mount
129    #[prop(optional, default = false)]
130    autofocus: bool,
131
132    /// Whether bracket matching is enabled
133    #[prop(optional, default = true)]
134    match_brackets: bool,
135
136    /// Whether to highlight the current line
137    #[prop(optional, default = true)]
138    highlight_current_line: bool,
139) -> impl IntoView {
140    // Internal state
141    let (cursor_line, set_cursor_line) = signal(0usize);
142    let (cursor_col, set_cursor_col) = signal(0usize);
143    let (is_focused, set_is_focused) = signal(false);
144
145    // Create editor state
146    let editor_state = StoredValue::new(EditorState::with_config(
147        value.get_untracked(),
148        EditorConfig {
149            tab_size,
150            word_wrap,
151            show_line_numbers,
152            highlight_current_line,
153            match_brackets,
154            font_size,
155            read_only,
156            ..Default::default()
157        },
158    ));
159
160    // Compute line count for line numbers
161    let line_count = Memo::new(move |_| {
162        let content = value.get();
163        if content.is_empty() {
164            1
165        } else {
166            content.chars().filter(|&c| c == '\n').count() + 1
167        }
168    });
169
170    // Generate line number elements
171    let line_numbers_view = move || {
172        if !show_line_numbers {
173            return None;
174        }
175
176        let count = line_count.get();
177        let current_line = cursor_line.get();
178
179        Some(view! {
180          <div class="leptos-editor-line-numbers" aria-hidden="true">
181            {(1..=count)
182              .map(|n| {
183                let is_current = n - 1 == current_line;
184                view! {
185                  <div class="leptos-editor-line-number" class:current=is_current>
186                    {n}
187                  </div>
188                }
189              })
190              .collect::<Vec<_>>()}
191          </div>
192        })
193    };
194
195    // Build CSS class string
196    let css_class = move || {
197        let mut classes = vec!["leptos-editor"];
198
199        if is_focused.get() {
200            classes.push("focused");
201        }
202        if read_only {
203            classes.push("read-only");
204        }
205        if word_wrap {
206            classes.push("word-wrap");
207        }
208        if show_line_numbers {
209            classes.push("with-line-numbers");
210        }
211
212        if let Some(ref custom) = class {
213            classes.push(custom);
214        }
215
216        classes.join(" ")
217    };
218
219    // Build inline styles
220    let inline_style = move || {
221        let mut styles = vec![
222            format!("--editor-font-size: {}px", font_size),
223            format!("--editor-tab-size: {}", tab_size),
224        ];
225
226        if let Some(ref min_h) = min_height {
227            styles.push(format!("min-height: {min_h}"));
228        }
229        if let Some(ref max_h) = max_height {
230            styles.push(format!("max-height: {max_h}"));
231        }
232
233        styles.join("; ")
234    };
235
236    // Handle input changes
237    let handle_input = move |ev: web_sys::Event| {
238        if read_only {
239            return;
240        }
241
242        let target = event_target::<web_sys::HtmlTextAreaElement>(&ev);
243        let new_value = target.value();
244
245        if let Some(callback) = on_change.as_ref() {
246            callback.run(new_value);
247        }
248    };
249
250    // Handle focus
251    let handle_focus = move |_| {
252        set_is_focused.set(true);
253        if let Some(callback) = on_focus.as_ref() {
254            callback.run(());
255        }
256    };
257
258    // Handle blur
259    let handle_blur = move |_| {
260        set_is_focused.set(false);
261        if let Some(callback) = on_blur.as_ref() {
262            callback.run(());
263        }
264    };
265
266    // Handle selection change and cursor position
267    let handle_select = move |ev: web_sys::Event| {
268        let target = event_target::<web_sys::HtmlTextAreaElement>(&ev);
269
270        // Get cursor position
271        if let (Ok(start), Ok(end)) = (target.selection_start(), target.selection_end()) {
272            let start = start.unwrap_or(0) as usize;
273            let end = end.unwrap_or(0) as usize;
274
275            // Calculate line and column from offset
276            let content = value.get();
277            let (line, col) = offset_to_line_col(&content, start);
278
279            set_cursor_line.set(line);
280            set_cursor_col.set(col);
281
282            if let Some(callback) = on_cursor_change.as_ref() {
283                callback.run((line + 1, col + 1)); // 1-indexed for display
284            }
285
286            // Selection changed
287            if let Some(callback) = on_selection_change.as_ref() {
288                let selected = if start != end && end <= content.len() {
289                    content.get(start..end).map(String::from)
290                } else {
291                    None
292                };
293                callback.run(selected);
294            }
295        }
296    };
297
298    // Handle keyboard shortcuts
299    let handle_keydown = move |ev: web_sys::KeyboardEvent| {
300        let key = ev.key();
301        let ctrl_or_cmd = ev.ctrl_key() || ev.meta_key();
302        let shift = ev.shift_key();
303
304        // Tab handling
305        if key == "Tab" && !read_only {
306            ev.prevent_default();
307
308            // Get current textarea
309            let target = event_target::<web_sys::HtmlTextAreaElement>(&ev);
310            if let (Ok(Some(start)), Ok(Some(end))) =
311                (target.selection_start(), target.selection_end())
312            {
313                let start = start as usize;
314                let end = end as usize;
315                let content = value.get();
316
317                let indent = " ".repeat(tab_size);
318
319                if shift {
320                    // Shift+Tab: Unindent
321                    // TODO: Implement unindent
322                } else {
323                    // Tab: Indent
324                    let new_content = format!("{}{}{}", &content[..start], indent, &content[end..]);
325
326                    if let Some(callback) = on_change.as_ref() {
327                        callback.run(new_content);
328                    }
329
330                    // Restore cursor position
331                    #[allow(clippy::cast_possible_truncation)]
332                    let new_pos = (start + tab_size) as u32;
333                    let _ = target.set_selection_start(Some(new_pos));
334                    let _ = target.set_selection_end(Some(new_pos));
335                }
336            }
337        }
338
339        // Undo: Ctrl+Z
340        if ctrl_or_cmd && key == "z" && !shift {
341            ev.prevent_default();
342            editor_state.update_value(|state| {
343                if state.undo()
344                    && let Some(callback) = on_change.as_ref()
345                {
346                    callback.run(state.content.clone());
347                }
348            });
349        }
350
351        // Redo: Ctrl+Shift+Z or Ctrl+Y
352        if ctrl_or_cmd && ((key == "z" && shift) || key == "y") {
353            ev.prevent_default();
354            editor_state.update_value(|state| {
355                if state.redo()
356                    && let Some(callback) = on_change.as_ref()
357                {
358                    callback.run(state.content.clone());
359                }
360            });
361        }
362
363        // Select All: Ctrl+A
364        if ctrl_or_cmd && key == "a" {
365            // Let browser handle this
366        }
367    };
368
369    view! {
370      <div class=css_class style=inline_style>
371        // Line numbers gutter
372        {line_numbers_view}
373
374        // Main editor area
375        <div class="leptos-editor-content">
376          <textarea
377            id=id
378            class="leptos-editor-textarea"
379            prop:value=move || value.get()
380            placeholder=placeholder.clone().unwrap_or_default()
381            readonly=read_only
382            spellcheck="false"
383            autocomplete="off"
384            aria-label="Code editor"
385            aria-multiline="true"
386            on:input=handle_input
387            on:focus=handle_focus
388            on:blur=handle_blur
389            on:select=handle_select
390            on:keydown=handle_keydown
391            autofocus=autofocus
392          />
393
394          // Placeholder overlay (for styled placeholder)
395          {
396            let placeholder_for_show = placeholder.clone();
397            let placeholder_for_render = placeholder.clone();
398            view! {
399              <Show when=move || value.get().is_empty() && placeholder_for_show.is_some()>
400                <div class="leptos-editor-placeholder" aria-hidden="true">
401                  {placeholder_for_render.clone().unwrap_or_default()}
402                </div>
403              </Show>
404            }
405          }
406        </div>
407
408        // Status bar
409        <div class="leptos-editor-status">
410          <span class="leptos-editor-status-position">
411            "Ln " {move || cursor_line.get() + 1} ", Col " {move || cursor_col.get() + 1}
412          </span>
413          {
414            let language_for_status = language.clone();
415            language_for_status
416              .as_ref()
417              .map(|lang| {
418                view! { <span class="leptos-editor-status-language">{lang.clone()}</span> }
419              })
420          }
421        </div>
422      </div>
423    }
424}
425
426/// Convert a byte offset to line and column (0-indexed).
427fn offset_to_line_col(text: &str, offset: usize) -> (usize, usize) {
428    let mut line = 0;
429    let mut col = 0;
430    let mut current_offset = 0;
431
432    for ch in text.chars() {
433        if current_offset >= offset {
434            break;
435        }
436        current_offset += ch.len_utf8();
437
438        if ch == '\n' {
439            line += 1;
440            col = 0;
441        } else {
442            col += 1;
443        }
444    }
445
446    (line, col)
447}
448
449/// Default CSS styles for the editor component.
450///
451/// Include this in your application to get the default styling.
452pub const DEFAULT_STYLES: &str = r"
453.leptos-editor {
454    --editor-bg: #1e1e1e;
455    --editor-fg: #d4d4d4;
456    --editor-line-number-fg: #858585;
457    --editor-line-number-active-fg: #c6c6c6;
458    --editor-selection-bg: #264f78;
459    --editor-cursor: #aeafad;
460    --editor-gutter-bg: #1e1e1e;
461    --editor-border: #3c3c3c;
462    --editor-current-line-bg: rgba(255, 255, 255, 0.04);
463    --editor-font-size: 14px;
464    --editor-line-height: 1.5;
465    --editor-tab-size: 4;
466
467    display: flex;
468    flex-direction: column;
469    background: var(--editor-bg);
470    color: var(--editor-fg);
471    border: 1px solid var(--editor-border);
472    border-radius: 4px;
473    font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
474    font-size: var(--editor-font-size);
475    line-height: var(--editor-line-height);
476    overflow: hidden;
477    position: relative;
478}
479
480.leptos-editor.focused {
481    border-color: #3b82f6;
482    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
483}
484
485.leptos-editor.read-only {
486    opacity: 0.7;
487    cursor: not-allowed;
488}
489
490.leptos-editor-content {
491    display: flex;
492    flex: 1;
493    overflow: hidden;
494    position: relative;
495}
496
497.leptos-editor-line-numbers {
498    background: var(--editor-gutter-bg);
499    color: var(--editor-line-number-fg);
500    padding: 8px 12px 8px 8px;
501    text-align: right;
502    user-select: none;
503    border-right: 1px solid var(--editor-border);
504    overflow: hidden;
505    flex-shrink: 0;
506    min-width: 3em;
507}
508
509.leptos-editor-line-number {
510    line-height: var(--editor-line-height);
511}
512
513.leptos-editor-line-number.current {
514    color: var(--editor-line-number-active-fg);
515    font-weight: 600;
516}
517
518.leptos-editor-textarea {
519    flex: 1;
520    width: 100%;
521    height: 100%;
522    min-height: 100px;
523    padding: 8px 12px;
524    margin: 0;
525    border: none;
526    outline: none;
527    background: transparent;
528    color: inherit;
529    font: inherit;
530    line-height: inherit;
531    resize: none;
532    tab-size: var(--editor-tab-size);
533    -moz-tab-size: var(--editor-tab-size);
534    overflow: auto;
535}
536
537.leptos-editor-textarea::selection {
538    background: var(--editor-selection-bg);
539}
540
541.leptos-editor-textarea::-webkit-scrollbar {
542    width: 10px;
543    height: 10px;
544}
545
546.leptos-editor-textarea::-webkit-scrollbar-track {
547    background: var(--editor-bg);
548}
549
550.leptos-editor-textarea::-webkit-scrollbar-thumb {
551    background: #424242;
552    border-radius: 5px;
553}
554
555.leptos-editor-textarea::-webkit-scrollbar-thumb:hover {
556    background: #4f4f4f;
557}
558
559.leptos-editor-placeholder {
560    position: absolute;
561    top: 8px;
562    left: 12px;
563    color: var(--editor-line-number-fg);
564    pointer-events: none;
565    font-style: italic;
566}
567
568.leptos-editor.with-line-numbers .leptos-editor-placeholder {
569    left: calc(3em + 24px);
570}
571
572.leptos-editor-status {
573    display: flex;
574    justify-content: space-between;
575    align-items: center;
576    padding: 4px 12px;
577    background: rgba(0, 0, 0, 0.2);
578    border-top: 1px solid var(--editor-border);
579    font-size: 0.85em;
580    color: var(--editor-line-number-fg);
581}
582
583.leptos-editor-status-position {
584    font-family: inherit;
585}
586
587.leptos-editor-status-language {
588    text-transform: capitalize;
589}
590
591/* Light theme variant */
592.leptos-editor.light {
593    --editor-bg: #ffffff;
594    --editor-fg: #1e293b;
595    --editor-line-number-fg: #94a3b8;
596    --editor-line-number-active-fg: #334155;
597    --editor-selection-bg: #bfdbfe;
598    --editor-cursor: #1e293b;
599    --editor-gutter-bg: #f8fafc;
600    --editor-border: #e2e8f0;
601    --editor-current-line-bg: rgba(0, 0, 0, 0.02);
602}
603
604/* Word wrap disabled */
605.leptos-editor:not(.word-wrap) .leptos-editor-textarea {
606    white-space: pre;
607    overflow-x: auto;
608}
609
610/* Accessibility: Respect reduced motion preference */
611@media (prefers-reduced-motion: reduce) {
612    .leptos-editor,
613    .leptos-editor * {
614        transition: none !important;
615    }
616}
617";