Files
readest/apps/readest-app/src/app/reader/utils/wordlensRuby.ts
T
Huang Xin c2ac207945 refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.

- User-facing strings → "Word Lens" across all 34 locales; brand translated
  for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
  wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
  data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
  pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
  sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
  one-line gloss packs aren't pretty-printed on commit.

Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
  wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:09:29 +02:00

129 lines
3.9 KiB
TypeScript

import type { GlossOccurrence } from '@/services/wordlens/types';
const GLOSS_CLASS = 'ww-gloss';
interface Segment {
node: Text;
/** Offset of this node's text within the concatenated model string. */
start: number;
}
export interface SectionTextModel {
text: string;
locate(offset: number): { node: Text; offset: number };
}
const isEligible = (node: Text): boolean => {
let p: Node | null = node.parentNode;
while (p) {
if (p.nodeType === Node.ELEMENT_NODE) {
const tag = (p as Element).tagName.toLowerCase();
if (tag === 'rt' || tag === 'rp' || tag === 'ruby' || tag === 'script' || tag === 'style') {
return false;
}
}
p = p.parentNode;
}
return true;
};
/** Concatenate eligible text nodes and remember each node's slice offset. */
export const buildSectionTextModel = (doc: Document): SectionTextModel => {
const root = doc.body ?? doc.documentElement;
const segments: Segment[] = [];
let text = '';
if (root) {
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (n) =>
isEligible(n as Text) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
});
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const t = n as Text;
const data = t.data;
if (!data) continue;
segments.push({ node: t, start: text.length });
text += data;
}
}
const locate = (offset: number) => {
let lo = 0;
let hi = segments.length - 1;
let seg = segments[0]!;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const s = segments[mid]!;
if (offset < s.start) hi = mid - 1;
else {
seg = s;
lo = mid + 1;
}
}
return { node: seg.node, offset: offset - seg.start };
};
return { text, locate };
};
const occurrenceRange = (
doc: Document,
model: SectionTextModel,
occ: GlossOccurrence,
): Range | null => {
const s = model.locate(occ.start);
const e = model.locate(occ.end);
if (s.node !== e.node) return null;
const range = doc.createRange();
range.setStart(s.node, s.offset);
range.setEnd(e.node, e.offset);
return range;
};
/** Wrap each occurrence as <ruby class=ww-gloss cfi-skip>word<rt cfi-inert>gloss</rt></ruby>. */
export const applyGlosses = (
doc: Document,
model: SectionTextModel,
occurrences: GlossOccurrence[],
): void => {
const sorted = [...occurrences].sort((a, b) => b.start - a.start);
for (const occ of sorted) {
const range = occurrenceRange(doc, model, occ);
if (!range) continue;
const ruby = doc.createElement('ruby');
ruby.className = GLOSS_CLASS;
ruby.setAttribute('cfi-skip', '');
const rt = doc.createElement('rt');
rt.setAttribute('cfi-inert', '');
rt.textContent = occ.gloss;
try {
const word = range.extractContents();
ruby.appendChild(word);
ruby.appendChild(rt);
range.insertNode(ruby);
} catch {
// Range became invalid (concurrent mutation); skip this one.
}
}
};
/** Unwrap every injected gloss, restoring the original text. */
export const clearGlosses = (doc: Document): void => {
const rubies = doc.querySelectorAll(`ruby.${GLOSS_CLASS}`);
rubies.forEach((ruby) => {
ruby.querySelectorAll('rt').forEach((rt) => rt.remove());
const parent = ruby.parentNode;
if (!parent) return;
while (ruby.firstChild) parent.insertBefore(ruby.firstChild, ruby);
parent.removeChild(ruby);
});
(doc.body ?? doc.documentElement)?.normalize();
};
/** Given a tap target, return the base word if it is inside a gloss, else null. */
export const findGlossWord = (target: HTMLElement | null): string | null => {
const ruby = target?.closest(`ruby.${GLOSS_CLASS}`);
if (!ruby) return null;
const clone = ruby.cloneNode(true) as Element;
clone.querySelectorAll('rt').forEach((rt) => rt.remove());
const word = clone.textContent?.trim() ?? '';
return word || null;
};