diff --git a/app.js b/app.js index 129ab53..4f6cef7 100644 --- a/app.js +++ b/app.js @@ -196,27 +196,116 @@ class WortKraftApp { } setupHoverListeners() { - document.addEventListener("mouseover", e => { - if (!this.hoverTranslateEnabled) return; - const tokenEl = e.target.closest(".word-token"); - if (!tokenEl) return; - const word = tokenEl.dataset.word || tokenEl.textContent.trim(); - if (!word) return; - this.showHoverTooltipForElement(tokenEl, word); - }); + let lastHoveredWord = null; - document.addEventListener("mouseout", e => { - const tokenEl = e.target.closest(".word-token"); - if (!tokenEl) return; - this.hideHoverTooltip(); + document.addEventListener("mousemove", e => { + if (!this.hoverTranslateEnabled) return; + + // 1. Check if directly over an explicit .word-token span + const tokenEl = e.target.closest?.(".word-token"); + if (tokenEl) { + const word = tokenEl.dataset.word || tokenEl.textContent.trim(); + if (word) { + this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true); + lastHoveredWord = word; + return; + } + } + + // 2. Universal German word detector under cursor (works on headings, cards, buttons, chips, etc.) + const hit = this.getWordAtPoint(e.clientX, e.clientY); + if (hit && hit.word) { + const info = this.lookupWordInfo(hit.word, null); + // Show tooltip if word is in our German dictionaries + if (!info.needsLiveTranslate) { + this.showHoverTooltipForRect(hit.rect, hit.word, false); + lastHoveredWord = hit.word; + return; + } + } + + if (lastHoveredWord) { + lastHoveredWord = null; + this.hideHoverTooltip(); + } }); window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true }); } - showHoverTooltipForElement(el, rawWord) { - const tooltip = document.getElementById("hover-translate-tooltip"); - if (!tooltip) return; + getWordAtPoint(x, y) { + try { + let textNode = null; + let offset = 0; + + if (document.caretRangeFromPoint) { + const range = document.caretRangeFromPoint(x, y); + if (!range) return null; + textNode = range.startContainer; + offset = range.startOffset; + } else if (document.caretPositionFromPoint) { + const pos = document.caretPositionFromPoint(x, y); + if (!pos) return null; + textNode = pos.offsetNode; + offset = pos.offset; + } + + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null; + const parentEl = textNode.parentElement; + if (!parentEl || parentEl.closest("#hover-translate-tooltip, input, textarea, select, script, style")) { + return null; + } + + const text = textNode.textContent || ""; + if (!text.trim()) return null; + + const isGermanChar = ch => /[A-Za-zÄÖÜäöüß]/.test(ch); + let idx = offset; + if (idx >= text.length || !isGermanChar(text[idx])) { + if (idx > 0 && isGermanChar(text[idx - 1])) { + idx = idx - 1; + } else { + return null; + } + } + + let start = idx; + let end = idx + 1; + while (start > 0 && isGermanChar(text[start - 1])) start--; + while (end < text.length && isGermanChar(text[end])) end++; + + const word = text.slice(start, end); + if (word.length < 2) return null; + + const wordRange = document.createRange(); + wordRange.setStart(textNode, start); + wordRange.setEnd(textNode, end); + const rect = wordRange.getBoundingClientRect(); + + // Verify mouse pointer is actually inside/near the word's bounding box + if ( + x < rect.left - 6 || + x > rect.right + 6 || + y < rect.top - 6 || + y > rect.bottom + 6 + ) { + return null; + } + + return { word, rect }; + } catch (_e) { + return null; + } + } + + showHoverTooltipForRect(rect, rawWord, allowLiveFetch = true) { + let tooltip = document.getElementById("hover-translate-tooltip"); + if (!tooltip) { + tooltip = document.createElement("div"); + tooltip.id = "hover-translate-tooltip"; + tooltip.className = "hover-translate-tooltip"; + document.body.appendChild(tooltip); + } const story = this.currentView === "reader" ? this.getAllStories().find(s => s.id === this.activeStoryId) @@ -236,27 +325,43 @@ class WortKraftApp { : ""; tooltip.innerHTML = ` -
+
${articleHtml} ${info.lemma || rawWord}
-
${info.english}
- ${info.plural ? `
Plural: ${info.plural}
` : ""} +
${info.english}
+ ${info.plural ? `
Plural: ${info.plural}
` : ""} `; - const rect = el.getBoundingClientRect(); const left = Math.min( window.innerWidth - 160, Math.max(160, rect.left + rect.width / 2) ); const top = Math.max(55, rect.top); - tooltip.style.left = `${left}px`; - tooltip.style.top = `${top}px`; + // Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache + Object.assign(tooltip.style, { + position: "fixed", + zIndex: "9999", + background: "#18181b", + color: "#ffffff", + padding: "0.55rem 0.85rem", + borderRadius: "10px", + boxShadow: "0 10px 25px rgba(0,0,0,0.3)", + pointerEvents: "none", + left: `${left}px`, + top: `${top}px`, + transform: "translate(-50%, -100%) translateY(-8px)", + opacity: "1", + display: "block", + maxWidth: "310px", + fontSize: "0.84rem", + lineHeight: "1.35", + border: "1px solid rgba(255,255,255,0.16)" + }); tooltip.classList.add("visible"); - // If word is from a custom imported text and not in our local dictionary yet, fetch translation live - if (info.needsLiveTranslate) { + if (allowLiveFetch && info.needsLiveTranslate) { this.fetchLiveWordTranslation(rawWord); } } @@ -284,7 +389,11 @@ class WortKraftApp { hideHoverTooltip() { const tooltip = document.getElementById("hover-translate-tooltip"); - if (tooltip) tooltip.classList.remove("visible"); + if (tooltip) { + tooltip.classList.remove("visible"); + tooltip.style.opacity = "0"; + tooltip.style.display = "none"; + } } // === AUDIO PRONUNCIATION (Server Native German Neural TTS + Browser Fallback) === diff --git a/data/hover_dict.js b/data/hover_dict.js index 9b1b97b..d30b532 100644 --- a/data/hover_dict.js +++ b/data/hover_dict.js @@ -179,5 +179,62 @@ window.HOVER_DICT = { "direkt": "directly / right", "café": "café (das Café)", "fall": "case (auf jeden Fall = definitely)", "bekommt": "gets / receives", "per": "by / via", "mail": "email (die E-Mail)", "selbst": "even (selbst wenn = even if) / oneself", "so": "so / this way", "doch": "yet / however / after all", "system": "system (das System)", "guten": "good (Guten Morgen/Tag)", "leid": "sorry (tut mir leid = I am sorry)", "klar": "clear / all right (Alles klar!)", - "sonst": "otherwise / else", "hoffentlich": "hopefully", "hallo": "hello", "herzlich": "warmly / cordially", "willkommen": "welcome (Herzlich willkommen!)" + "sonst": "otherwise / else", "hoffentlich": "hopefully", "hallo": "hello", "herzlich": "warmly / cordially", "willkommen": "welcome (Herzlich willkommen!)", + + // UI, Headings, Grammar Terms & Study Prompts + "wortkraft": "WortKraft (Word Power / Vocabulary Strength)", + "übersicht": "Overview / Dashboard (die Übersicht)", + "wortschatz": "Vocabulary (der Wortschatz — literally 'word-treasure')", + "trainer": "Trainer / Practice Coach (der Trainer)", + "leseecke": "Reading Corner (die Leseecke)", + "sprachpraxis": "Speaking & Writing Practice (die Sprachpraxis)", + "wörterbuch": "Dictionary / Vocabulary Bank (das Wörterbuch)", + "übersetzer": "Translator (der Übersetzer)", + "an": "ON / at / to", "aus": "OFF / out of / from", + "passiven": "passive", "aktiven": "active", "aktive": "active", "aktiver": "active", + "wiedererkennen": "recognition / recognizing again (das Wiedererkennen)", + "fällig": "due (for review)", "fällige": "due", + "wiederholung": "review / repetition (die Wiederholung)", "wiederholungen": "reviews / repetitions", + "bereit": "ready", "abruf": "recall / retrieval (der Abruf)", + "gemeistert": "mastered", "training": "training (das Training)", + "artikel": "article (der/die/das — der Artikel)", + "trefferquote": "accuracy / hit rate (die Trefferquote)", + "eingabe": "input / entry (die Eingabe)", + "sätze": "sentences (die Sätze)", "satz": "sentence (der Satz)", + "geschrieben": "written", "produziert": "produced", + "täglicher": "daily", "schritte": "steps (die Schritte)", "schritt": "step (der Schritt)", + "lückensatz": "cloze / fill-in-the-blank sentence (der Lückensatz)", + "lückensätze": "fill-in-the-blank sentences", + "ergänze": "complete / fill in (imperative)", "fehlende": "missing", "fehlendes": "missing", + "wort": "word (das Wort)", "worte": "words", + "wortart": "part of speech (die Wortart)", + "nomen": "noun (das Nomen)", "verben": "verbs (die Verben)", "ausdrücke": "expressions (die Ausdrücke)", + "präposition": "preposition (die Präposition)", "konnektoren": "connectors (die Konnektoren)", + "maskulin": "masculine (der)", "feminin": "feminine (die)", "neutrum": "neuter (das)", "plural": "plural (die)", + "karteikarte": "flashcard (die Karteikarte)", "wortbau": "word structure / morphology (der Wortbau)", + "eselsbrücke": "mnemonic / memory trick (die Eselsbrücke)", + "kollokationen": "collocations / word pairings", "formen": "forms", + "alltagstipp": "everyday usage tip (der Alltagstipp)", + "alltag": "everyday life (der Alltag)", "alltagssituation": "everyday situation", "alltagssituationen": "everyday situations", + "geschichte": "story (die Geschichte)", "geschichten": "stories (die Geschichten)", + "textauswahl": "text selection (die Textauswahl)", "eigener": "own / custom", "text": "text (der Text)", + "zielwörter": "target words (die Zielwörter)", "zielwörtern": "target words (Dative)", + "fokus": "focus (der Fokus)", "tages": "of the day (des Tages)", + "mischen": "to shuffle / mix", "jetzt": "now", "trainieren": "to train / practice", + "prüfen": "to check / verify", "lösung": "solution / answer (die Lösung)", "zeigen": "to show", + "nochmal": "again / once more", "leicht": "easy / light", + "umdrehen": "to flip / turn over", "vorlesen": "to read aloud", "aussprache": "pronunciation (die Aussprache)", + "übersetzung": "translation (die Übersetzung)", "ausblenden": "to hide", "anzeigen": "to show / display", + "szenarien": "scenarios (die Szenarien)", "rollenspiele": "roleplays (die Rollenspiele)", "rollenspiel": "roleplay (das Rollenspiel)", + "aufgabe": "task / prompt (die Aufgabe)", "senden": "to send", + "starthilfe": "starter hint / jump-start (die Starthilfe)", "mustersatz": "sample sentence (der Mustersatz)", "einfügen": "to insert", + "dialog": "dialogue (der Dialog)", "starten": "to start", "üben": "to practice", + "archiv": "archive / bank (das Archiv)", "kategorien": "categories", + "bedeutung": "meaning (die Bedeutung)", "beispielsatz": "example sentence (der Beispielsatz)", + "speichern": "to save", "gespeichert": "saved", "abbrechen": "to cancel", "hinzufügen": "to add", + "einstellungen": "settings (die Einstellungen)", "fortschritt": "progress (der Fortschritt)", + "exportieren": "to export", "lernstand": "learning progress", "zurücksetzen": "to reset", + "verständnis": "understanding / comprehension (das Verständnis)", + "klicke": "click (imperative)", "wähle": "choose / select (imperative)", "versuche": "try (imperative)", + "fahre": "move / hover / drive", "maus": "mouse (die Maus)", "beliebiges": "any", "stammform": "base form / lemma" }; diff --git a/index.html b/index.html index eaeabb2..f60ef4a 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ - + @@ -408,10 +408,10 @@
- - - - - + + + + + diff --git a/server/index.js b/server/index.js index 02f1249..c85282a 100644 --- a/server/index.js +++ b/server/index.js @@ -185,8 +185,16 @@ app.get('/api/translate', async (req, res) => { res.json({ word, english: null }); }); -// Serve static frontend files from project root +// Serve static frontend files from project root with no-cache headers so updates apply immediately const PUBLIC_DIR = path.resolve(__dirname, '..'); +app.use((req, res, next) => { + if (!req.path.startsWith('/api/tts')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } + next(); +}); app.use('/data', express.static(path.join(PUBLIC_DIR, 'data'))); app.get('/styles.css', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'styles.css'))); app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js')));