diff --git a/client/src/comps/App.ts b/client/src/comps/App.ts index 779a7b7..8094476 100644 --- a/client/src/comps/App.ts +++ b/client/src/comps/App.ts @@ -6,6 +6,43 @@ import Form from "./forms/Form" import View from "./views/View" import Modal from "./Modal" +/** + * Keyboard- and layout-independent key codes that trigger autofocus + * + * Keys starting with "Key" or "Digit" don't need to be listed explictly. + * AltGraph is layout-dependent and must not be listed. + * Numpad number keys and NumpadDecimal are dependent on Numpad key state and must not be listed. + */ +const autofocusCodes = [ + // Row 1 (top) + "Backquote", + "Minus", + "Equal", + "Backspace", + // Row 2 + "BracketLeft", + "BracketRight", + "Enter", + // Row 3 + "CapsLock", + "Semicolon", + "Quote", + "Backslash", + // Row 4 + "ShiftLeft", + "IntlBackslash", + "Period", + "Comma", + "Slash", + // Row 5 + "Space", + // Numpad + "NumpadDivide", + "NumpadMultiply", + "NumpadSubtract", + "NumpadAdd", +] + export default class App extends Component { readonly roomList: RoomList private currentView?: View | Form @@ -27,6 +64,30 @@ export default class App extends Component { }) void initMatrixClient() + + document.addEventListener("keydown", event => { + const code = event.code // keyboard-/layout-independent key code + const key = event.key // key (respects layout, modifiers, and numpad key) + // console.log("keydown event fired on", event.target, "for key", code, key) + // console.log("modifiers:", ["ctrl", "shift", "alt", "meta"].filter(mod => event[mod + "Key"]).join(", ")) + + if (event.altKey || event.ctrlKey || event.metaKey) { + // console.log("modifier detected, skip") + return + } + + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || (event.target instanceof HTMLDivElement && event.target.hasAttribute("contenteditable"))) { + // console.log("is input, skip") + return + } + + if (!code.startsWith("Key") && !code.startsWith("Digit") && !autofocusCodes.includes(code) && key != "AltGraph" && isNaN(Number(key)) && key != ",") { + // console.log("key doesn't trigger autofocus, skip") + return + } + + this.autofocus(true) + }) } updateChannelList() { @@ -40,6 +101,8 @@ export default class App extends Component { void view.reset(...args) this.element.appendChild(view.element) this.currentView = view + + this.autofocus() } clearView() { @@ -63,6 +126,8 @@ export default class App extends Component { const element = modal.element as HTMLDialogElement document.body.appendChild(element) element.showModal() + + modal.form.defaultTextInput?.focus() } closeModal(modal: Modal) { @@ -86,4 +151,15 @@ export default class App extends Component { getCurrentModal(): Modal | undefined { return this.modals[this.modals.length - 1] } + + autofocus(textOnly: boolean = false) { + const modal = this.getCurrentModal() + const getter = textOnly ? "defaultInput" : "defaultTextInput" + + if (modal) { + modal.form[getter]?.focus() + } else { + this.currentView?.[getter]?.focus() + } + } } diff --git a/client/src/comps/ChatInput.ts b/client/src/comps/ChatInput.ts index 2e6a1be..4a819ea 100644 --- a/client/src/comps/ChatInput.ts +++ b/client/src/comps/ChatInput.ts @@ -8,6 +8,7 @@ import RoomView from "./views/RoomView" // Credits to DeepSeek-R1, wow (edited though) export default class ChatInput extends Component { readonly emojiPicker: EmojiPicker + readonly input: HTMLDivElement constructor(view: PostView | RoomView) { super("div", { id: "chat-input-container" }) @@ -23,16 +24,16 @@ export default class ChatInput extends Component { fileUploadLabel.appendChild(fileInput) // Create chat input - const chatInput = document.createElement("div") - chatInput.className = "chat-input" - chatInput.setAttribute("contenteditable", "true") - chatInput.setAttribute("placeholder", "Message #channel") - chatInput.addEventListener("keypress", e => { + this.input = document.createElement("div") + this.input.className = "chat-input" + this.input.setAttribute("contenteditable", "true") + this.input.setAttribute("placeholder", "Message #channel") + this.input.addEventListener("keypress", e => { if (e.code == "Enter" && !e.shiftKey) { e.preventDefault() - const content = chatInput.innerText.trim() + const content = this.input.innerText.trim() if (content == "") return - chatInput.innerHTML = "" + this.input.innerHTML = "" async function createMessage() { // const forum_id = view.getCurrentForumId() @@ -57,13 +58,13 @@ export default class ChatInput extends Component { }) } - console.log("Send MSG:", chatInput.innerText) + console.log("Send MSG:", this.input.innerText) void createMessage() } }) - chatInput.addEventListener("input", () => { + this.input.addEventListener("input", () => { // this fixes weird browser behavior - if (chatInput.innerHTML == "
") chatInput.innerHTML = "" + if (this.input.innerHTML == "
") this.input.innerHTML = "" }) // Create emoji button @@ -77,13 +78,13 @@ export default class ChatInput extends Component { "chat-input-emoji-picker", "chat-input-container", emoji => { - chatInput.textContent += emoji.native + this.input.textContent += emoji.native } ) // Div-engers, Assemble! this.element.appendChild(fileUploadLabel) - this.element.appendChild(chatInput) + this.element.appendChild(this.input) this.element.appendChild(emojiButton) this.element.appendChild(this.emojiPicker.element) } diff --git a/client/src/comps/forms/Form.ts b/client/src/comps/forms/Form.ts index 5352e99..554106c 100644 --- a/client/src/comps/forms/Form.ts +++ b/client/src/comps/forms/Form.ts @@ -48,4 +48,28 @@ export default abstract class Form< const modal = new Modal(this) app.openModal(modal) } + + get defaultTextInput(): HTMLElement | undefined { + const form = this.element as HTMLFormElement + return Array.from(form.elements).find(isTextInput) + } + + get defaultInput(): HTMLElement | undefined { + const form = this.element as HTMLFormElement + return Array.from(form.elements).find(isFormInput) + } } + +function isFormInput(formControl: Element): formControl is HTMLElement { + if (formControl instanceof HTMLFieldSetElement) return false + if (formControl instanceof HTMLOutputElement) return false + return true +} + +function isTextInput(formControl: Element): formControl is HTMLElement { + if (formControl instanceof HTMLTextAreaElement) return true + if (formControl instanceof HTMLInputElement) { + return ["text", "number", "email", "password", "search", "tel", "url"].includes(formControl.type) + } + return false +} \ No newline at end of file diff --git a/client/src/comps/views/PostView.ts b/client/src/comps/views/PostView.ts index 8c19a6a..4c7a4a2 100644 --- a/client/src/comps/views/PostView.ts +++ b/client/src/comps/views/PostView.ts @@ -57,4 +57,8 @@ export default class PostView extends View<[string]> { getCurrentForumId() { return this.currentForumId } + + get defaultTextInput() { + return this.chatInput.input + } } diff --git a/client/src/comps/views/RoomView.ts b/client/src/comps/views/RoomView.ts index df4408e..e3c12c2 100644 --- a/client/src/comps/views/RoomView.ts +++ b/client/src/comps/views/RoomView.ts @@ -90,4 +90,8 @@ export default class RoomView extends View { getCurrentRoom() { return this.currentRoom } + + get defaultTextInput() { + return this.chatInput.input + } } diff --git a/client/src/comps/views/View.ts b/client/src/comps/views/View.ts index 55fa35f..c165245 100644 --- a/client/src/comps/views/View.ts +++ b/client/src/comps/views/View.ts @@ -23,4 +23,10 @@ export default abstract class View< } abstract reset(...args: ResetArgs): void | Promise + + abstract defaultTextInput: HTMLElement | undefined + + get defaultInput() { + return this.defaultTextInput + } }