From a99070e46676f7ddeb98e7a07ea624a2d1005a25 Mon Sep 17 00:00:00 2001 From: Dylan Leifer-Ives Date: Sat, 16 May 2026 16:08:01 -0700 Subject: [PATCH 1/2] add keyboard support --- .../js/modules/text/components/text_reader.ts | 210 +++++++++++++++++- 1 file changed, 204 insertions(+), 6 deletions(-) diff --git a/src/frontend/js/modules/text/components/text_reader.ts b/src/frontend/js/modules/text/components/text_reader.ts index 56626e485..9d5b3432b 100644 --- a/src/frontend/js/modules/text/components/text_reader.ts +++ b/src/frontend/js/modules/text/components/text_reader.ts @@ -14,6 +14,12 @@ import { renderText, updateWordStatusInDOM, type RenderSettings } from '../pages import { setupMultiWordSelection } from '../pages/reading/text_multiword_selection'; import { TextsApi } from '@modules/text/api/texts_api'; import { SettingsApi } from '@modules/admin/api/settings_api'; +import { scrollTo } from '@shared/utils/hover_intent'; +import { openDictionaryPopup, createTheDictUrl } from '@modules/vocabulary/services/dictionary'; +import { speechDispatcher } from '@shared/utils/user_interactions'; +import { lwt_audio_controller } from '@/media/html5_audio_player'; +import { getWordFormStore } from '@modules/vocabulary/stores/word_form_store'; +import { getPositionFromId } from '@shared/utils/ajax_utilities'; /** * Text reader Alpine.js component interface. @@ -25,6 +31,7 @@ export interface TextReaderData { showTranslations: boolean; error: string | null; statusMessage: string | null; + markedPosition: number; // Computed properties readonly store: WordStoreState; @@ -93,6 +100,7 @@ export function textReaderData(): TextReaderData { showTranslations: true, error: null, statusMessage: null, + markedPosition: -1, readerWidth: 100, readerTextSize: 0, @@ -221,15 +229,205 @@ export function textReaderData(): TextReaderData { // Select the word (opens popover near the clicked element) this.store.selectWord(hex, position, wordEl); - // NOTE: TTS integration disabled - requires speechDispatcher import and TTS settings check - // speechDispatcher(wordEl.textContent || '', this.store.langId); }, - handleKeydown(): void { - // Only handle if popover/modal is not open - if (this.store.isPopoverOpen || this.store.isEditModalOpen) return; + handleKeydown(e: KeyboardEvent): void { + // Skip if the user is typing in an interactive element + const target = e.target as HTMLElement; + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return; + // Let the edit modal handle its own keys + if (this.store.isEditModalOpen) return; - // NOTE: Keyboard navigation planned - arrow keys for word navigation, number keys for quick status + const keyCode = e.keyCode || e.which; + + const clearMarked = (): void => { + document.querySelectorAll('.kwordmarked, .uwordmarked').forEach( + el => el.classList.remove('kwordmarked', 'uwordmarked') + ); + }; + + const knownWords = (): HTMLElement[] => Array.from( + document.querySelectorAll( + 'span.word:not(.hide):not(.status0), span.mword:not(.hide)' + ) + ); + + // ESC: reset all marks and close popover + if (keyCode === 27) { + clearMarked(); + this.markedPosition = -1; + this.store.closePopover(); + e.preventDefault(); + return; + } + + // RETURN: jump to next unknown word + if (keyCode === 13) { + document.querySelectorAll('.uwordmarked').forEach(el => el.classList.remove('uwordmarked')); + const unknown = document.querySelector('span.status0.word:not(.hide)'); + if (unknown) { + scrollTo(unknown, { offset: -150 }); + unknown.classList.add('uwordmarked'); + unknown.click(); + this.store.closePopover(); + } + e.preventDefault(); + return; + } + + // Navigation: HOME / END / LEFT / RIGHT / SPACE + const words = knownWords(); + if (words.length === 0) return; + + if (keyCode === 36) { // HOME: first known word + clearMarked(); + this.markedPosition = 0; + words[0].classList.add('kwordmarked'); + scrollTo(words[0], { offset: -150 }); + words[0].click(); + e.preventDefault(); + return; + } + + if (keyCode === 35) { // END: last known word + clearMarked(); + this.markedPosition = words.length - 1; + words[this.markedPosition].classList.add('kwordmarked'); + scrollTo(words[this.markedPosition], { offset: -150 }); + words[this.markedPosition].click(); + e.preventDefault(); + return; + } + + if (keyCode === 37) { // LEFT: previous known word + const marked = document.querySelector('.kwordmarked'); + const currid = marked ? getPositionFromId(marked.id) : Number.MAX_SAFE_INTEGER; + clearMarked(); + let newPos = words.length - 1; + for (let i = words.length - 1; i >= 0; i--) { + if (getPositionFromId(words[i].id) < currid) { newPos = i; break; } + } + this.markedPosition = newPos; + words[newPos].classList.add('kwordmarked'); + scrollTo(words[newPos], { offset: -150 }); + words[newPos].click(); + e.preventDefault(); + return; + } + + if (keyCode === 39 || keyCode === 32) { // RIGHT / SPACE: next known word + const marked = document.querySelector('.kwordmarked'); + const currid = marked ? getPositionFromId(marked.id) : -1; + clearMarked(); + let newPos = 0; + for (let i = 0; i < words.length; i++) { + if (getPositionFromId(words[i].id) > currid) { newPos = i; break; } + } + this.markedPosition = newPos; + words[newPos].classList.add('kwordmarked'); + scrollTo(words[newPos], { offset: -150 }); + words[newPos].click(); + e.preventDefault(); + return; + } + + // All remaining shortcuts operate on the currently marked or hovered word + const markedEl = document.querySelector('.kwordmarked, .uwordmarked'); + const curr = markedEl ?? document.querySelector('.hword:hover'); + if (!curr) return; + + const hex = curr.getAttribute('data_hex') ?? curr.className.match(/TERM([0-9A-Fa-f]+)/)?.[1] ?? ''; + const position = parseInt(curr.getAttribute('data_order') ?? curr.getAttribute('data_pos') ?? '0', 10); + const widAttr = curr.getAttribute('data_wid'); + const wordId = widAttr ? parseInt(widAttr, 10) : null; + const status = parseInt(curr.getAttribute('data_status') ?? '0', 10); + const text = curr.classList.contains('mwsty') + ? (curr.getAttribute('data_text') ?? curr.textContent ?? '') + : (curr.textContent ?? ''); + + // 1-5: set status (or open edit form for new words) + for (let i = 1; i <= 5; i++) { + if (keyCode === 48 + i || keyCode === 96 + i) { + if (status === 0) { + this._openEditForm(position); + } else { + void this.store.setStatus(hex, i); + } + e.preventDefault(); + return; + } + } + + // I: ignored (98) + if (keyCode === 73) { + if (status === 0) { + void this.store.createQuickWord(hex, position, 98); + } else { + void this.store.setStatus(hex, 98); + } + e.preventDefault(); + return; + } + + // W: well-known (99) + if (keyCode === 87) { + if (status === 0) { + void this.store.createQuickWord(hex, position, 99); + } else { + void this.store.setStatus(hex, 99); + } + e.preventDefault(); + return; + } + + // P: pronounce with TTS + if (keyCode === 80) { + speechDispatcher(text, this.store.langId); + e.preventDefault(); + return; + } + + // T: open translator popup with current word + if (keyCode === 84) { + const link = this.store.dictLinks.translator?.replace(/^\*/, ''); + if (link) openDictionaryPopup(createTheDictUrl(link, text)); + e.preventDefault(); + return; + } + + // A: seek audio to current word's position + if (keyCode === 65) { + const pos = parseInt(curr.getAttribute('data_pos') ?? '0', 10); + const totalEl = document.getElementById('totalcharcount'); + const total = parseInt(totalEl?.textContent ?? '0', 10); + if (total > 0) { + lwt_audio_controller.newPosition(Math.max(0, 100 * (pos - 5) / total)); + } + e.preventDefault(); + return; + } + + // G: open translator then fall through to open edit form + if (keyCode === 71) { + const link = this.store.dictLinks.translator?.replace(/^\*/, ''); + if (link) setTimeout(() => openDictionaryPopup(createTheDictUrl(link, text)), 10); + } + + // E / G: open edit form for current word + if (keyCode === 69 || keyCode === 71) { + this._openEditForm(position, wordId ?? undefined); + e.preventDefault(); + } + }, + + _openEditForm(position: number, wordId?: number): void { + try { + const formStore = getWordFormStore(); + void formStore.loadForEdit(this.store.textId, position, wordId); + this.store.openEditModal(); + } catch { + // word_form_store not available on this page + } }, toggleShowAll(): void { From 24a07ff8d09708ac748aaa8871353d4630d66421 Mon Sep 17 00:00:00 2001 From: Dylan Leifer-Ives Date: Sat, 16 May 2026 16:47:47 -0700 Subject: [PATCH 2/2] make keyboard support better --- .../js/modules/text/components/text_reader.ts | 247 ++++++++++-------- 1 file changed, 140 insertions(+), 107 deletions(-) diff --git a/src/frontend/js/modules/text/components/text_reader.ts b/src/frontend/js/modules/text/components/text_reader.ts index 9d5b3432b..db0211510 100644 --- a/src/frontend/js/modules/text/components/text_reader.ts +++ b/src/frontend/js/modules/text/components/text_reader.ts @@ -19,7 +19,6 @@ import { openDictionaryPopup, createTheDictUrl } from '@modules/vocabulary/servi import { speechDispatcher } from '@shared/utils/user_interactions'; import { lwt_audio_controller } from '@/media/html5_audio_player'; import { getWordFormStore } from '@modules/vocabulary/stores/word_form_store'; -import { getPositionFromId } from '@shared/utils/ajax_utilities'; /** * Text reader Alpine.js component interface. @@ -31,7 +30,6 @@ export interface TextReaderData { showTranslations: boolean; error: string | null; statusMessage: string | null; - markedPosition: number; // Computed properties readonly store: WordStoreState; @@ -76,6 +74,9 @@ export interface TextReaderData { let saveWidthTimer: ReturnType | null = null; let saveTextSizeTimer: ReturnType | null = null; +/** Keyboard focus position saved before the edit modal opens, restored on close. */ +let _kbSavedPosition = -1; + /** * Debounced save of a setting (300ms). */ @@ -100,7 +101,6 @@ export function textReaderData(): TextReaderData { showTranslations: true, error: null, statusMessage: null, - markedPosition: -1, readerWidth: 100, readerTextSize: 0, @@ -199,15 +199,27 @@ export function textReaderData(): TextReaderData { const container = document.getElementById('thetext'); if (!container) return; - // Use event delegation for word clicks container.addEventListener('click', (e) => this.handleWordClick(e)); - - // Keyboard navigation document.addEventListener('keydown', (e) => this.handleKeydown(e)); - - // Multi-word selection via native text selection - // When user selects multiple words, the multi-word modal opens setupMultiWordSelection(container); + + // Restore keyboard focus mark after the edit modal closes + Alpine.effect(() => { + const isOpen = this.store.isEditModalOpen; + if (!isOpen && _kbSavedPosition >= 0) { + const savedPos = _kbSavedPosition; + _kbSavedPosition = -1; + requestAnimationFrame(() => { + const el = Array.from( + document.querySelectorAll('span.word:not(.hide), span.mword:not(.hide)') + ).find(w => parseInt(w.getAttribute('data_order') ?? '-1', 10) === savedPos); + if (el) { + document.querySelectorAll('.kwordmarked').forEach(e => e.classList.remove('kwordmarked')); + el.classList.add('kwordmarked'); + } + }); + } + }); }, handleWordClick(event: MouseEvent): void { @@ -226,114 +238,139 @@ export function textReaderData(): TextReaderData { if (!hex) return; + // Keep keyboard mark in sync with clicks + document.querySelectorAll('.kwordmarked').forEach(el => el.classList.remove('kwordmarked')); + wordEl.classList.add('kwordmarked'); + // Select the word (opens popover near the clicked element) this.store.selectWord(hex, position, wordEl); }, handleKeydown(e: KeyboardEvent): void { - // Skip if the user is typing in an interactive element const target = e.target as HTMLElement; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return; - // Let the edit modal handle its own keys if (this.store.isEditModalOpen) return; const keyCode = e.keyCode || e.which; - const clearMarked = (): void => { - document.querySelectorAll('.kwordmarked, .uwordmarked').forEach( - el => el.classList.remove('kwordmarked', 'uwordmarked') - ); + // All visible word elements (every status, including unknown) + const allWords = (): HTMLElement[] => Array.from( + document.querySelectorAll('span.word:not(.hide), span.mword:not(.hide)') + ); + + // The kwordmarked element is the single source of truth for keyboard focus + const currentEl = (): HTMLElement | null => + document.querySelector('.kwordmarked'); + + const currentPos = (): number => { + const el = currentEl(); + return el ? parseInt(el.getAttribute('data_order') ?? '-1', 10) : -1; }; - const knownWords = (): HTMLElement[] => Array.from( - document.querySelectorAll( - 'span.word:not(.hide):not(.status0), span.mword:not(.hide)' - ) - ); + // Move keyboard focus to a word. If the popover is already open, reposition it too. + const focusWord = (el: HTMLElement): void => { + document.querySelectorAll('.kwordmarked').forEach(e => e.classList.remove('kwordmarked')); + el.classList.add('kwordmarked'); + if (this.store.isPopoverOpen) { + const hex = el.getAttribute('data_hex') ?? el.className.match(/TERM([0-9A-Fa-f]+)/)?.[1] ?? ''; + const pos = parseInt(el.getAttribute('data_order') ?? el.getAttribute('data_pos') ?? '0', 10); + this.store.selectWord(hex, pos, el); + } + }; - // ESC: reset all marks and close popover + // ESC: first press closes popover (mark stays), second press clears mark if (keyCode === 27) { - clearMarked(); - this.markedPosition = -1; - this.store.closePopover(); + if (this.store.isPopoverOpen) { + this.store.isPopoverOpen = false; + } else { + document.querySelectorAll('.kwordmarked').forEach(el => el.classList.remove('kwordmarked')); + this.store.closePopover(); + } e.preventDefault(); return; } - // RETURN: jump to next unknown word + // RETURN: next unknown word after current position, wrapping to start if (keyCode === 13) { - document.querySelectorAll('.uwordmarked').forEach(el => el.classList.remove('uwordmarked')); - const unknown = document.querySelector('span.status0.word:not(.hide)'); + const pos = currentPos(); + const words = allWords(); + const unknown = words.find( + el => el.getAttribute('data_status') === '0' && + parseInt(el.getAttribute('data_order') ?? '0', 10) > pos + ) ?? words.find(el => el.getAttribute('data_status') === '0'); if (unknown) { + focusWord(unknown); scrollTo(unknown, { offset: -150 }); - unknown.classList.add('uwordmarked'); - unknown.click(); - this.store.closePopover(); } e.preventDefault(); return; } - // Navigation: HOME / END / LEFT / RIGHT / SPACE - const words = knownWords(); + // SPACE: toggle popover for the focused word (focus is preserved either way) + if (keyCode === 32) { + if (this.store.isPopoverOpen) { + this.store.isPopoverOpen = false; + } else { + const curr = currentEl(); + if (curr) { + const hex = curr.getAttribute('data_hex') ?? curr.className.match(/TERM([0-9A-Fa-f]+)/)?.[1] ?? ''; + const pos = parseInt(curr.getAttribute('data_order') ?? '0', 10); + this.store.selectWord(hex, pos, curr); + } + } + e.preventDefault(); + return; + } + + // HOME / END / LEFT / RIGHT: move focus + const words = allWords(); if (words.length === 0) return; - if (keyCode === 36) { // HOME: first known word - clearMarked(); - this.markedPosition = 0; - words[0].classList.add('kwordmarked'); + if (keyCode === 36) { // HOME + focusWord(words[0]); scrollTo(words[0], { offset: -150 }); - words[0].click(); e.preventDefault(); return; } - if (keyCode === 35) { // END: last known word - clearMarked(); - this.markedPosition = words.length - 1; - words[this.markedPosition].classList.add('kwordmarked'); - scrollTo(words[this.markedPosition], { offset: -150 }); - words[this.markedPosition].click(); + if (keyCode === 35) { // END + focusWord(words[words.length - 1]); + scrollTo(words[words.length - 1], { offset: -150 }); e.preventDefault(); return; } - if (keyCode === 37) { // LEFT: previous known word - const marked = document.querySelector('.kwordmarked'); - const currid = marked ? getPositionFromId(marked.id) : Number.MAX_SAFE_INTEGER; - clearMarked(); - let newPos = words.length - 1; - for (let i = words.length - 1; i >= 0; i--) { - if (getPositionFromId(words[i].id) < currid) { newPos = i; break; } + if (keyCode === 37) { // LEFT: previous word (or last if nothing focused) + const pos = currentPos(); + let prev: HTMLElement | null = null; + if (pos < 0) { + prev = words[words.length - 1]; + } else { + for (let i = words.length - 1; i >= 0; i--) { + if (parseInt(words[i].getAttribute('data_order') ?? '0', 10) < pos) { + prev = words[i]; + break; + } + } } - this.markedPosition = newPos; - words[newPos].classList.add('kwordmarked'); - scrollTo(words[newPos], { offset: -150 }); - words[newPos].click(); + if (prev) { focusWord(prev); scrollTo(prev, { offset: -150 }); } e.preventDefault(); return; } - if (keyCode === 39 || keyCode === 32) { // RIGHT / SPACE: next known word - const marked = document.querySelector('.kwordmarked'); - const currid = marked ? getPositionFromId(marked.id) : -1; - clearMarked(); - let newPos = 0; - for (let i = 0; i < words.length; i++) { - if (getPositionFromId(words[i].id) > currid) { newPos = i; break; } - } - this.markedPosition = newPos; - words[newPos].classList.add('kwordmarked'); - scrollTo(words[newPos], { offset: -150 }); - words[newPos].click(); + if (keyCode === 39) { // RIGHT: next word (or first if nothing focused) + const pos = currentPos(); + const next = words.find( + el => parseInt(el.getAttribute('data_order') ?? '0', 10) > pos + ) ?? (pos < 0 ? words[0] : null); + if (next) { focusWord(next); scrollTo(next, { offset: -150 }); } e.preventDefault(); return; } - // All remaining shortcuts operate on the currently marked or hovered word - const markedEl = document.querySelector('.kwordmarked, .uwordmarked'); - const curr = markedEl ?? document.querySelector('.hword:hover'); + // All remaining shortcuts act on the focused word + const curr = currentEl(); if (!curr) return; const hex = curr.getAttribute('data_hex') ?? curr.className.match(/TERM([0-9A-Fa-f]+)/)?.[1] ?? ''; @@ -341,87 +378,83 @@ export function textReaderData(): TextReaderData { const widAttr = curr.getAttribute('data_wid'); const wordId = widAttr ? parseInt(widAttr, 10) : null; const status = parseInt(curr.getAttribute('data_status') ?? '0', 10); - const text = curr.classList.contains('mwsty') - ? (curr.getAttribute('data_text') ?? curr.textContent ?? '') - : (curr.textContent ?? ''); + // Use store word text to avoid picking up annotation child-element text + const wordData = this.store.wordsByHex.get(hex)?.[0]; + const text = wordData?.text ?? curr.getAttribute('data_text') ?? curr.textContent?.trim() ?? ''; + const translation = wordData?.translation ?? ''; - // 1-5: set status (or open edit form for new words) + // 1-5: set status for (let i = 1; i <= 5; i++) { if (keyCode === 48 + i || keyCode === 96 + i) { - if (status === 0) { - this._openEditForm(position); - } else { - void this.store.setStatus(hex, i); - } + if (status === 0) this._openEditForm(position); + else void this.store.setStatus(hex, i); e.preventDefault(); return; } } - // I: ignored (98) - if (keyCode === 73) { - if (status === 0) { - void this.store.createQuickWord(hex, position, 98); - } else { - void this.store.setStatus(hex, 98); - } + if (keyCode === 73) { // I: ignored (98) + if (status === 0) void this.store.createQuickWord(hex, position, 98); + else void this.store.setStatus(hex, 98); e.preventDefault(); return; } - // W: well-known (99) - if (keyCode === 87) { - if (status === 0) { - void this.store.createQuickWord(hex, position, 99); - } else { - void this.store.setStatus(hex, 99); - } + if (keyCode === 87) { // W: well-known (99) + if (status === 0) void this.store.createQuickWord(hex, position, 99); + else void this.store.setStatus(hex, 99); e.preventDefault(); return; } - // P: pronounce with TTS - if (keyCode === 80) { - speechDispatcher(text, this.store.langId); + if (keyCode === 80) { // p: word only P (shift): word + translation + const toSpeak = e.shiftKey && translation + ? `${text}. ${translation}` + : text; + speechDispatcher(toSpeak, this.store.langId); e.preventDefault(); return; } - // T: open translator popup with current word - if (keyCode === 84) { + if (keyCode === 84) { // T: open translator const link = this.store.dictLinks.translator?.replace(/^\*/, ''); if (link) openDictionaryPopup(createTheDictUrl(link, text)); e.preventDefault(); return; } - // A: seek audio to current word's position - if (keyCode === 65) { + if (keyCode === 65) { // A: seek audio to word position const pos = parseInt(curr.getAttribute('data_pos') ?? '0', 10); const totalEl = document.getElementById('totalcharcount'); const total = parseInt(totalEl?.textContent ?? '0', 10); - if (total > 0) { - lwt_audio_controller.newPosition(Math.max(0, 100 * (pos - 5) / total)); - } + if (total > 0) lwt_audio_controller.newPosition(Math.max(0, 100 * (pos - 5) / total)); e.preventDefault(); return; } - // G: open translator then fall through to open edit form - if (keyCode === 71) { + if (keyCode === 71) { // G: open translator and edit form const link = this.store.dictLinks.translator?.replace(/^\*/, ''); - if (link) setTimeout(() => openDictionaryPopup(createTheDictUrl(link, text)), 10); + if (link) openDictionaryPopup(createTheDictUrl(link, text)); + this._openEditForm(position, wordId ?? undefined); + e.preventDefault(); + return; } - // E / G: open edit form for current word - if (keyCode === 69 || keyCode === 71) { + if (keyCode === 69) { // E: edit term this._openEditForm(position, wordId ?? undefined); e.preventDefault(); + return; } }, _openEditForm(position: number, wordId?: number): void { try { + // Save keyboard focus position so it can be restored when the modal closes + const curr = document.querySelector('.kwordmarked'); + _kbSavedPosition = curr + ? parseInt(curr.getAttribute('data_order') ?? '-1', 10) + : -1; + const formStore = getWordFormStore(); void formStore.loadForEdit(this.store.textId, position, wordId); this.store.openEditModal();