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() {
|
||||
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 = `
|
||||
<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}
|
||||
<span>${info.lemma || rawWord}</span>
|
||||
</div>
|
||||
<div class="hover-tt-en" id="hover-tt-en-text">${info.english}</div>
|
||||
${info.plural ? `<div class="hover-tt-note">Plural: ${info.plural}</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" style="color:#a1a1aa;font-size:0.74rem;margin-top:0.15rem;">Plural: ${info.plural}</div>` : ""}
|
||||
`;
|
||||
|
||||
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) ===
|
||||
|
||||
Reference in new issue
Block a user