diff --git a/src/frontend/js/modules/text/components/text_reader.ts b/src/frontend/js/modules/text/components/text_reader.ts index 56626e485..db0211510 100644 --- a/src/frontend/js/modules/text/components/text_reader.ts +++ b/src/frontend/js/modules/text/components/text_reader.ts @@ -14,6 +14,11 @@ 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'; /** * Text reader Alpine.js component interface. @@ -69,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). */ @@ -191,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 { @@ -218,18 +238,229 @@ 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); - // 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 { + const target = e.target as HTMLElement; + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return; + if (this.store.isEditModalOpen) return; + + const keyCode = e.keyCode || e.which; + + // 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; + }; + + // 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: first press closes popover (mark stays), second press clears mark + if (keyCode === 27) { + 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: next unknown word after current position, wrapping to start + if (keyCode === 13) { + 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 }); + } + e.preventDefault(); + return; + } + + // 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 + focusWord(words[0]); + scrollTo(words[0], { offset: -150 }); + e.preventDefault(); + return; + } + + if (keyCode === 35) { // END + focusWord(words[words.length - 1]); + scrollTo(words[words.length - 1], { offset: -150 }); + e.preventDefault(); + return; + } + + 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; + } + } + } + if (prev) { focusWord(prev); scrollTo(prev, { offset: -150 }); } + e.preventDefault(); + return; + } + + 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 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] ?? ''; + 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); + // 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 + 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; + } + } + + 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; + } - // NOTE: Keyboard navigation planned - arrow keys for word navigation, number keys for quick status + 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; + } + + 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; + } + + if (keyCode === 84) { // T: open translator + const link = this.store.dictLinks.translator?.replace(/^\*/, ''); + if (link) openDictionaryPopup(createTheDictUrl(link, text)); + e.preventDefault(); + return; + } + + 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)); + e.preventDefault(); + return; + } + + if (keyCode === 71) { // G: open translator and edit form + const link = this.store.dictLinks.translator?.replace(/^\*/, ''); + if (link) openDictionaryPopup(createTheDictUrl(link, text)); + this._openEditForm(position, wordId ?? undefined); + e.preventDefault(); + return; + } + + 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(); + } catch { + // word_form_store not available on this page + } }, toggleShowAll(): void {