Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 243 additions & 12 deletions src/frontend/js/modules/text/components/text_reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -69,6 +74,9 @@ export interface TextReaderData {
let saveWidthTimer: ReturnType<typeof setTimeout> | null = null;
let saveTextSizeTimer: ReturnType<typeof setTimeout> | null = null;

/** Keyboard focus position saved before the edit modal opens, restored on close. */
let _kbSavedPosition = -1;

/**
* Debounced save of a setting (300ms).
*/
Expand Down Expand Up @@ -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<HTMLElement>('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 {
Expand All @@ -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<HTMLElement>('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<HTMLElement>('.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<HTMLElement>('.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 {
Expand Down