Add universal cursor hover-to-translate detector and cache-busting headers
This commit is contained in:
1 parent
e8aef17029
commit
e636350940
4 files changed
+206
-32
No files matched your search
@@ -196,27 +196,116 @@ class WortKraftApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupHoverListeners() {
|
setupHoverListeners() {
|
||||||
document.addEventListener("mouseover", e => {
|
let lastHoveredWord = null;
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("mouseout", e => {
|
document.addEventListener("mousemove", e => {
|
||||||
const tokenEl = e.target.closest(".word-token");
|
if (!this.hoverTranslateEnabled) return;
|
||||||
if (!tokenEl) return;
|
|
||||||
this.hideHoverTooltip();
|
// 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 });
|
window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
showHoverTooltipForElement(el, rawWord) {
|
getWordAtPoint(x, y) {
|
||||||
const tooltip = document.getElementById("hover-translate-tooltip");
|
try {
|
||||||
if (!tooltip) return;
|
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"
|
const story = this.currentView === "reader"
|
||||||
? this.getAllStories().find(s => s.id === this.activeStoryId)
|
? this.getAllStories().find(s => s.id === this.activeStoryId)
|
||||||
@@ -236,27 +325,43 @@ class WortKraftApp {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
tooltip.innerHTML = `
|
tooltip.innerHTML = `
|
||||||
<div class="hover-tt-top">
|
<div class="hover-tt-top" style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.18rem;font-weight:700;color:#f4f4f5;">
|
||||||
${articleHtml}
|
${articleHtml}
|
||||||
<span>${info.lemma || rawWord}</span>
|
<span>${info.lemma || rawWord}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hover-tt-en" id="hover-tt-en-text">${info.english}</div>
|
<div class="hover-tt-en" id="hover-tt-en-text" style="color:#fde68a;font-weight:700;font-size:0.92rem;">${info.english}</div>
|
||||||
${info.plural ? `<div class="hover-tt-note">Plural: ${info.plural}</div>` : ""}
|
${info.plural ? `<div class="hover-tt-note" style="color:#a1a1aa;font-size:0.74rem;margin-top:0.15rem;">Plural: ${info.plural}</div>` : ""}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const left = Math.min(
|
const left = Math.min(
|
||||||
window.innerWidth - 160,
|
window.innerWidth - 160,
|
||||||
Math.max(160, rect.left + rect.width / 2)
|
Math.max(160, rect.left + rect.width / 2)
|
||||||
);
|
);
|
||||||
const top = Math.max(55, rect.top);
|
const top = Math.max(55, rect.top);
|
||||||
|
|
||||||
tooltip.style.left = `${left}px`;
|
// Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache
|
||||||
tooltip.style.top = `${top}px`;
|
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");
|
tooltip.classList.add("visible");
|
||||||
|
|
||||||
// If word is from a custom imported text and not in our local dictionary yet, fetch translation live
|
if (allowLiveFetch && info.needsLiveTranslate) {
|
||||||
if (info.needsLiveTranslate) {
|
|
||||||
this.fetchLiveWordTranslation(rawWord);
|
this.fetchLiveWordTranslation(rawWord);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +389,11 @@ class WortKraftApp {
|
|||||||
|
|
||||||
hideHoverTooltip() {
|
hideHoverTooltip() {
|
||||||
const tooltip = document.getElementById("hover-translate-tooltip");
|
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) ===
|
// === AUDIO PRONUNCIATION (Server Native German Neural TTS + Browser Fallback) ===
|
||||||
|
|||||||
+58
-1
@@ -179,5 +179,62 @@ window.HOVER_DICT = {
|
|||||||
"direkt": "directly / right", "café": "café (das Café)", "fall": "case (auf jeden Fall = definitely)", "bekommt": "gets / receives",
|
"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",
|
"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!)",
|
"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"
|
||||||
};
|
};
|
||||||
+6
-6
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css?v=4" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -408,10 +408,10 @@
|
|||||||
<div id="toast" class="toast-notification"></div>
|
<div id="toast" class="toast-notification"></div>
|
||||||
|
|
||||||
<!-- SCRIPTS -->
|
<!-- SCRIPTS -->
|
||||||
<script src="data/vocabulary.js"></script>
|
<script src="data/vocabulary.js?v=4"></script>
|
||||||
<script src="data/stories.js"></script>
|
<script src="data/stories.js?v=4"></script>
|
||||||
<script src="data/scenarios.js"></script>
|
<script src="data/scenarios.js?v=4"></script>
|
||||||
<script src="data/hover_dict.js"></script>
|
<script src="data/hover_dict.js?v=4"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+9
-1
@@ -185,8 +185,16 @@ app.get('/api/translate', async (req, res) => {
|
|||||||
res.json({ word, english: null });
|
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, '..');
|
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.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('/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')));
|
app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js')));
|
||||||
|
|||||||
Reference in new issue
Block a user