/ concept-collection / turing-sphere
Sign in
concept-collection / turing-sphere
92 lines · 3.1 KBCodeBlameHistory
2 * A textarea with syntax highlighting, by overlay.
3 *
4 * A textarea cannot colour its own text, so the highlighted source is rendered
5 * into a <pre> underneath and the textarea sits on top with transparent text and
6 * a visible caret. The two must agree on every metric that affects layout —
7 * font, line height, padding, tab size, wrapping — and their scroll offsets are
8 * kept in sync, or the colours drift away from the characters.
9 */
10import { highlightMatlab } from './matlab.ts';
12export interface CodeEditorOptions {
13 textarea: HTMLTextAreaElement;
14 /** The <pre> behind it, holding the highlighted copy. */
15 overlay: HTMLElement;
16 /** Names to mark as host-provided operations. */
17 external?: ReadonlySet<string>;
18 /** Called on every edit. */
19 onInput?: (value: string) => void;
22export class CodeEditor {
23 #textarea: HTMLTextAreaElement;
24 #overlay: HTMLElement;
25 #external: ReadonlySet<string>;
27 constructor(opts: CodeEditorOptions) {
28 this.#textarea = opts.textarea;
29 this.#overlay = opts.overlay;
30 this.#external = opts.external ?? new Set();
32 this.#textarea.addEventListener('input', () => {
33 this.#repaint();
34 opts.onInput?.(this.#textarea.value);
35 });
36 // Keep the colours under the characters while scrolling.
37 this.#textarea.addEventListener('scroll', () => this.#syncScroll());
38 // Tab should indent rather than leave the editor.
39 this.#textarea.addEventListener('keydown', (e) => this.#onKeyDown(e));
40 this.#repaint();
41 }
43 get value(): string {
44 return this.#textarea.value;
45 }
47 set value(next: string) {
48 this.#textarea.value = next;
49 this.#repaint();
50 }
52 focus(): void {
53 this.#textarea.focus();
54 }
56 /** Select a character range, scrolling it into view. */
57 select(start: number, end: number): void {
58 this.#textarea.focus();
59 this.#textarea.setSelectionRange(start, end);
60 // setSelectionRange does not always scroll; nudge the line into view.
61 const line = this.#textarea.value.slice(0, start).split('\n').length - 1;
62 const lineHeight = this.#textarea.scrollHeight / Math.max(1, this.#lineCount());
63 const target = line * lineHeight - this.#textarea.clientHeight / 2;
64 this.#textarea.scrollTop = Math.max(0, target);
65 this.#syncScroll();
66 }
68 #lineCount(): number {
69 return this.#textarea.value.split('\n').length + 1; // +1 for the trailing line
70 }
72 #onKeyDown(e: KeyboardEvent): void {
73 if (e.key !== 'Tab' || e.ctrlKey || e.metaKey || e.altKey) return;
74 e.preventDefault();
75 const el = this.#textarea;
76 const { selectionStart: s, selectionEnd: t, value } = el;
77 el.value = `${value.slice(0, s)} ${value.slice(t)}`;
78 el.selectionStart = el.selectionEnd = s + 2;
79 // Let the input listener repaint and notify, as for any other edit.
80 el.dispatchEvent(new Event('input'));
81 }
83 #repaint(): void {
84 this.#overlay.innerHTML = highlightMatlab(this.#textarea.value, this.#external);
85 this.#syncScroll();
86 }
88 #syncScroll(): void {
89 this.#overlay.scrollTop = this.#textarea.scrollTop;
90 this.#overlay.scrollLeft = this.#textarea.scrollLeft;
91 }
moveopenescclose