longcipher_leptos_components/components/editor/
core.rs1use leptos::prelude::*;
6
7use super::state::{EditorConfig, EditorState};
8
9#[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 #[prop(into)]
62 value: Signal<String>,
63
64 #[prop(into, optional)]
66 on_change: Option<Callback<String>>,
67
68 #[prop(into, optional)]
70 placeholder: Option<String>,
71
72 #[prop(into, optional)]
74 language: Option<String>,
75
76 #[prop(optional, default = false)]
78 read_only: bool,
79
80 #[prop(optional, default = true)]
82 show_line_numbers: bool,
83
84 #[prop(optional, default = true)]
86 word_wrap: bool,
87
88 #[prop(optional, default = 4)]
90 tab_size: usize,
91
92 #[prop(optional, default = 14.0)]
94 font_size: f32,
95
96 #[prop(into, optional)]
98 class: Option<String>,
99
100 #[prop(into, optional)]
102 min_height: Option<String>,
103
104 #[prop(into, optional)]
106 max_height: Option<String>,
107
108 #[prop(into, optional)]
110 id: Option<String>,
111
112 #[prop(into, optional)]
114 on_focus: Option<Callback<()>>,
115
116 #[prop(into, optional)]
118 on_blur: Option<Callback<()>>,
119
120 #[prop(into, optional)]
122 on_cursor_change: Option<Callback<(usize, usize)>>,
123
124 #[prop(into, optional)]
126 on_selection_change: Option<Callback<Option<String>>>,
127
128 #[prop(optional, default = false)]
130 autofocus: bool,
131
132 #[prop(optional, default = true)]
134 match_brackets: bool,
135
136 #[prop(optional, default = true)]
138 highlight_current_line: bool,
139) -> impl IntoView {
140 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 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 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 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 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 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 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 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 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 let handle_select = move |ev: web_sys::Event| {
268 let target = event_target::<web_sys::HtmlTextAreaElement>(&ev);
269
270 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 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)); }
285
286 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 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 if key == "Tab" && !read_only {
306 ev.prevent_default();
307
308 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 } else {
323 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 #[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 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 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 if ctrl_or_cmd && key == "a" {
365 }
367 };
368
369 view! {
370 <div class=css_class style=inline_style>
371 {line_numbers_view}
373
374 <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 {
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 <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
426fn 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
449pub 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";