diff --git a/src/app/displayable.ts b/src/app/displayable.ts new file mode 100644 index 0000000..4f8a7ef --- /dev/null +++ b/src/app/displayable.ts @@ -0,0 +1,78 @@ +export type KeyHandler = (e: KeyboardEvent) => void; + +function canonicalizeEventKey(e: KeyboardEvent) { + const mods = [] as string[]; + if (e.ctrlKey) mods.push("Ctrl"); + if (e.altKey) mods.push("Alt"); + if (e.shiftKey) mods.push("Shift"); + if (e.metaKey) mods.push("Meta"); + let k = e.key; + if (k.length === 1) k = k.toLowerCase(); + mods.push(k); + return mods.join("-"); +} + +export abstract class Displayable { + protected deleteFn?: () => void; + private shortcuts = new Map(); + + constructor() { + // Attempt to install handlers shortly after construction. If `dom` is not + // available yet, retry a few times. + setTimeout(() => this.installHandlers(0), 0); + + // Add general shortcuts + this.addShortcut("Ctrl-w", () => this.close()); + this.addShortcut("Alt--", () => this.changeWidth(-100)); + this.addShortcut("Alt-=", () => this.changeWidth(100)); + } + + setDeleteFunction(fn: () => void) { + this.deleteFn = fn; + } + + addShortcut(k: string, handler: KeyHandler) { + this.shortcuts.set(k, handler); + } + + private handleKeyEvent(e: KeyboardEvent) { + const k = canonicalizeEventKey(e); + const h = this.shortcuts.get(k); + if (h) { + if (e.type == "keydown") h(e); + e.preventDefault(); + } + } + + changeWidth(increment: number) { + const w = parseInt(window.getComputedStyle(this.dom).width, 10); + this.dom.style.width = w + increment + "px"; + return true; + } + + private installHandlers(attempt: number) { + try { + const root = this.dom; + if (!root) throw new Error("no dom"); + + const keyHandler = (e: KeyboardEvent) => this.handleKeyEvent(e); + root.addEventListener("keydown", keyHandler, { capture: true }); + root.addEventListener("keyup", keyHandler, { capture: true }); + + root.addEventListener("focusin", () => { + this.dom.scrollIntoView({ behavior: "smooth" }); + }); + } catch (err) { + if (attempt < 5) { + setTimeout(() => this.installHandlers(attempt + 1), 50); + } else { + console.error("Failed to install key handlers:", err); + } + } + } + + abstract focus(): void; + abstract title(): string; + abstract close(): boolean; + abstract get dom(): HTMLElement; +} diff --git a/src/app/editor.ts b/src/app/editor.ts index 012e135..4428fd7 100644 --- a/src/app/editor.ts +++ b/src/app/editor.ts @@ -33,9 +33,9 @@ import { languages } from "@codemirror/language-data"; import { autocompletion, closeBrackets } from "@codemirror/autocomplete"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; import van from "vanjs-core"; +import { Displayable } from "./displayable"; import { OpenFile } from "./filestate"; -import { Displayable } from "./editorgrid"; const fixedHeightEditor = EditorView.theme({ "&": { @@ -74,11 +74,9 @@ function fileStatusPanel(view: EditorView) { "padding: 2px 10px; font-size: 12px; color: white; background: #900;"; return { top: true, dom }; } - -export class Editor implements Displayable { +export class Editor extends Displayable { view: EditorView; file: OpenFile; - deleteFn?: () => void; private wordWrapCompartment = new Compartment(); private languageCompartment = new Compartment(); @@ -91,6 +89,7 @@ export class Editor implements Displayable { } constructor(file: OpenFile) { + super(); this.file = file; const kmap = keymap.of([ ...defaultKeymap, @@ -105,7 +104,6 @@ export class Editor implements Displayable { return true; }, }, - { key: "Mod-w", run: () => this.close() }, { key: "Alt-z", run: () => { @@ -116,8 +114,6 @@ export class Editor implements Displayable { return true; }, }, - { key: "Alt--", run: () => this.changeWidth(-100) }, - { key: "Alt-=", run: () => this.changeWidth(100) }, ]); this.view = new EditorView({ doc: file.rootState.val.doc, @@ -149,9 +145,6 @@ export class Editor implements Displayable { // lintKeymap, ], }); - this.view.dom.addEventListener("focusin", () => - this.view.dom.scrollIntoView({ behavior: "smooth" }), - ); van.derive(() => { LanguageDescription.matchFilename(languages, file.filePath.val) @@ -185,12 +178,6 @@ export class Editor implements Displayable { return this.file.filePath.val + (this.file.isDirty() ? "*" : ""); } - changeWidth(increment: number) { - const w = parseInt(window.getComputedStyle(this.view.dom).width, 10); - this.view.dom.style.width = w + increment + "px"; - return true; - } - close() { if (this.deleteFn) { this.file.removeEditor(this, this.deleteFn); @@ -205,8 +192,4 @@ export class Editor implements Displayable { effects: compartment.reconfigure(on ? [] : extension), }); } - - setDeleteFunction(fn: () => void) { - this.deleteFn = fn; - } } diff --git a/src/app/editorgrid.ts b/src/app/editorgrid.ts index eeadc20..2ee262e 100644 --- a/src/app/editorgrid.ts +++ b/src/app/editorgrid.ts @@ -6,14 +6,7 @@ import { OpenFile } from "./filestate"; import * as u from "./utils"; import { Editor } from "./editor"; import { Terminal } from "./terminal"; - -export interface Displayable { - setDeleteFunction(del: () => void): void; - title(): string; - close(): void; - focus(): void; - dom: HTMLElement; -} +import { Displayable } from "./displayable"; const EditorWrapper = ( editor: State, diff --git a/src/app/terminal.ts b/src/app/terminal.ts index 6507879..562d653 100644 --- a/src/app/terminal.ts +++ b/src/app/terminal.ts @@ -1,13 +1,12 @@ -import { Displayable } from "./editorgrid"; +import { Displayable } from "./displayable"; import * as xterm from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import van, { State } from "vanjs-core"; const v = van.tags; -export class Terminal implements Displayable { +export class Terminal extends Displayable { term: xterm.Terminal; currentTitle: State = van.state("Terminal"); - del: () => void; dom: HTMLElement; private terminalId: string | null = null; private fitAddon: FitAddon; @@ -15,15 +14,12 @@ export class Terminal implements Displayable { private unsubTerminalData?: () => void; private unsubTerminalExit?: () => void; - setDeleteFunction(del: () => void): void { - this.del = del; - } - title(): string { return this.currentTitle.val; } constructor() { + super(); this.term = new xterm.Terminal({ // cursorBlink: true, // fontSize: 14, @@ -33,8 +29,9 @@ export class Terminal implements Displayable { this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); - this.dom = v.div({ class: "h-full w-2xl resize-x overflow-x-hidden scroll-m-[100px]" }); - this.dom.addEventListener("focusin", () => this.focus()); + this.dom = v.div({ + class: "h-full w-2xl resize-x overflow-x-hidden scroll-m-[100px]", + }); const loaded = van.state(false); @@ -124,6 +121,7 @@ export class Terminal implements Displayable { if (this.unsubTerminalData) this.unsubTerminalData(); if (this.unsubTerminalExit) this.unsubTerminalExit(); this.term.dispose(); - this.del(); + if (this.deleteFn) this.deleteFn(); + return true; } }