Files
SubMiner/vendor/hachidori/extension/capture-content.js
T
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00

722 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: GPL-3.0-or-later
(function () {
"use strict";
const CAPTURE_TARGET = "hachidori-capture";
const CONTENT_TARGET = "hachidori-capture-content";
const MAX_TEXT_LENGTH = 4096;
const MAX_LINES = 1000;
const TYPEWRITER_GAP_MS = 750;
const TYPEWRITER_GROWTH_LIMIT = 12;
const INLINE_DISPLAY_PATTERN = /^(?:inline|ruby|contents)/u;
const OMIT_TEXT_SELECTOR = [
"button", "input", "select", "textarea", "[contenteditable]",
"rt", "rp", "script", "style", "noscript", "hachidori-host",
].join(",");
const videoIds = new WeakMap();
const trackIds = new WeakMap();
const cueIds = new WeakMap();
let nextVideoId = 0;
let nextTrackId = 0;
let nextCueId = 0;
let nextRequestId = 0;
let nextLineId = 0;
let linked = false;
let linkedDocumentId = null;
let options = null;
let documentEpoch = crypto.randomUUID();
let selectedVideoCleanup = null;
let trackedElement = null;
let trackedEpoch = "";
let trackedLines = [];
let trackedObserver = null;
let trackedVisibilityObserver = null;
let trackedMountObserver = null;
let trackedQueued = false;
let pickerCleanup = null;
let rootPin = null;
let rootPinTail = Promise.resolve();
const now = () => performance.timeOrigin + performance.now();
const normalize = value => typeof value === "string"
? value.normalize("NFC").replace(/\s+/gu, " ").trim() : "";
async function send(type, fields = {}) {
const reply = await chrome.runtime.sendMessage({
target: CAPTURE_TARGET,
type,
requestId: `capture-content-${++nextRequestId}`,
...fields,
});
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
return reply;
}
async function identify(captureSessionId) {
return send("hd_capture_content_identify", { captureSessionId });
}
function report(message, fields = {}) {
void send("hd_capture_page_status", { message, ...fields }).catch(() => {});
}
function videoId(video) {
let id = videoIds.get(video);
if (!id) {
id = `video-${++nextVideoId}`;
videoIds.set(video, id);
}
return id;
}
function videoTracks(video) {
const tracks = [];
try {
for (const track of video.textTracks ?? []) {
if (track.mode !== "disabled") tracks.push(track);
}
} catch {
return [];
}
return tracks;
}
function activeTrackCues(track) {
try {
return [...(track.activeCues ?? [])];
} catch {
return [];
}
}
function trackId(track) {
let id = trackIds.get(track);
if (!id) {
id = `track-${++nextTrackId}`;
trackIds.set(track, id);
}
return id;
}
function videos() {
return [...document.querySelectorAll("video")].map((video, index) => ({
id: videoId(video),
label: String(video.getAttribute("aria-label") || video.title
|| `Video ${index + 1} (${video.videoWidth || "?"}×${video.videoHeight || "?"})`).slice(0, 200),
trackCount: videoTracks(video).length,
}));
}
function cueId(cue) {
if (cue.id) return String(cue.id).slice(0, 160);
let id = cueIds.get(cue);
if (!id) {
id = `cue-${++nextCueId}`;
cueIds.set(cue, id);
}
return id;
}
function emitBegin(record) {
void send("hd_capture_text_begin", { record }).catch(() => {});
}
function emitClose(identity, endMs = now()) {
void send("hd_capture_text_close", { identity, endMs }).catch(() => {});
}
function attachVideo(video) {
selectedVideoCleanup?.();
selectedVideoCleanup = null;
if (!video) return;
const attachmentEpoch = crypto.randomUUID();
let epochCounter = 0;
let sourceEpoch = "";
let interrupted = true;
const active = new Map();
const cleanups = [];
const trackCleanups = new Map();
function identity(id) {
return { sourceKind: "cue", sourceEpoch, occurrenceId: id };
}
function closeAll(at = now()) {
for (const id of active.keys()) emitClose(identity(id), at);
active.clear();
}
function resetEpoch(at = now()) {
closeAll(at);
sourceEpoch = `video:${videoId(video)}:${attachmentEpoch}:${++epochCounter}`;
}
function sync(onsetKnown) {
const at = now();
if (video.paused || video.seeking || video.ended || !video.isConnected) {
closeAll(at);
return;
}
const next = new Map();
for (const track of videoTracks(video)) {
for (const cue of activeTrackCues(track)) {
const text = String(cue.text ?? "");
if (!normalize(text) || text.length > MAX_TEXT_LENGTH) continue;
const id = `${trackId(track)}:${cueId(cue)}`;
next.set(id, text);
if (active.get(id) !== text) {
emitBegin({
sourceKind: "cue",
sourceEpoch,
occurrenceId: id,
text,
startMs: at,
onsetKnown,
});
}
}
}
for (const id of active.keys()) if (!next.has(id)) emitClose(identity(id), at);
active.clear();
for (const entry of next) active.set(...entry);
}
function listen(target, type, listener) {
target.addEventListener(type, listener);
cleanups.push(() => target.removeEventListener(type, listener));
}
function bindTracks() {
const available = new Set(videoTracks(video));
for (const [track, cleanup] of trackCleanups) {
if (available.has(track)) continue;
cleanup();
trackCleanups.delete(track);
}
for (const track of available) {
if (trackCleanups.has(track)) continue;
const listener = () => sync(true);
track.addEventListener("cuechange", listener);
trackCleanups.set(track, () => track.removeEventListener("cuechange", listener));
}
}
function interrupt() {
closeAll(now());
interrupted = true;
}
function resume() {
if (video.paused || video.seeking || video.ended || !video.isConnected) return;
if (interrupted) resetEpoch();
interrupted = false;
sync(false);
}
function resetAndSync() {
resetEpoch();
bindTracks();
interrupted = video.paused || video.seeking || video.ended;
if (!interrupted) sync(false);
}
resetEpoch();
bindTracks();
listen(video, "play", resume);
listen(video, "pause", interrupt);
listen(video, "seeking", interrupt);
listen(video, "seeked", resetAndSync);
listen(video, "ended", interrupt);
listen(video, "emptied", () => { resetEpoch(); interrupted = true; });
listen(video, "loadedmetadata", resetAndSync);
if (video.textTracks?.addEventListener) {
listen(video.textTracks, "addtrack", resetAndSync);
listen(video.textTracks, "removetrack", resetAndSync);
listen(video.textTracks, "change", resetAndSync);
}
resume();
selectedVideoCleanup = () => {
closeAll();
for (const cleanup of trackCleanups.values()) cleanup();
trackCleanups.clear();
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
};
}
function renderedElement(element, requireLayout = false, style = null) {
if (!element?.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true"
|| element.closest("hachidori-host")) return false;
style ??= getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"
&& Number(style.opacity) !== 0 && (!requireLayout || element.getClientRects().length > 0);
}
function visible(element) {
if (!renderedElement(element, true)) return false;
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
if (!renderedElement(ancestor)) return false;
}
return true;
}
function validTrackedElement(element) {
return element instanceof Element && !["HTML", "BODY"].includes(element.tagName)
&& !element.closest("input, textarea, select, button, [contenteditable], hachidori-host");
}
function domRangeFor(boundaries, previous) {
if (previous?.startContainer === boundaries.startContainer && previous.startOffset === boundaries.startOffset
&& previous.endContainer === boundaries.endContainer && previous.endOffset === boundaries.endOffset) {
return previous;
}
const range = document.createRange();
range.setStart(boundaries.startContainer, boundaries.startOffset);
range.setEnd(boundaries.endContainer, boundaries.endOffset);
return range;
}
function extractLines(element) {
if (!visible(element)) return [];
const lines = [];
let text = "";
let range = null;
const lineBreak = () => {
const normalized = normalize(text);
if (normalized && lines.length < MAX_LINES) {
lines.push({ text: normalized.slice(0, MAX_TEXT_LENGTH),
range: domRangeFor(range, trackedLines[lines.length]?.range) });
}
text = "";
range = null;
};
function appendPart(node, value, offset) {
range ??= { startContainer: node, startOffset: offset };
range.endContainer = node;
range.endOffset = offset + value.length;
text += value;
}
function appendText(node) {
const value = node.nodeValue || "";
if (!/[\r\n]/u.test(value)) {
appendPart(node, value, 0);
return;
}
for (const part of value.matchAll(/[^\r\n]+|[\r\n]+/gu)) {
if (/^[\r\n]/u.test(part[0])) {
lineBreak();
continue;
}
appendPart(node, part[0], part.index);
}
}
function visit(node, root = false) {
if (node.nodeType === Node.TEXT_NODE) {
appendText(node);
return;
}
if (!(node instanceof Element) || (!root && node.matches(OMIT_TEXT_SELECTOR))) return;
const style = getComputedStyle(node);
if (!renderedElement(node, false, style)) return;
if (node.tagName === "BR") {
lineBreak();
return;
}
const block = !root && !INLINE_DISPLAY_PATTERN.test(style.display);
if (block) lineBreak();
for (const child of node.childNodes) visit(child);
if (block) lineBreak();
}
visit(element, true);
lineBreak();
return lines;
}
function domIdentity(line) {
return { sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id };
}
function closeTrackedLines(at = now()) {
for (const line of trackedLines) emitClose(domIdentity(line), at);
trackedLines = [];
}
function isTypewriterContinuation(previous, text, at) {
if (!previous || !text || text === previous.text || !text.startsWith(previous.text)) return false;
return Array.from(text.slice(previous.text.length)).length <= TYPEWRITER_GROWTH_LIMIT
&& at - previous.updatedMs <= TYPEWRITER_GAP_MS;
}
function lineIndexes(values) {
const indexesByText = new Map();
for (const [index, text] of values.entries()) {
const indexes = indexesByText.get(text) ?? [];
indexes.push(index);
indexesByText.set(text, indexes);
}
return indexesByText;
}
function retainedLinesFor(values) {
const previousByText = lineIndexes(trackedLines.map(line => line.text));
const nextByText = lineIndexes(values);
const retained = new Map();
const usedPrevious = new Set();
for (const [text, previousIndexes] of previousByText) {
const nextIndexes = nextByText.get(text);
if (previousIndexes.length !== 1 || nextIndexes?.length !== 1) continue;
retained.set(nextIndexes[0], trackedLines[previousIndexes[0]]);
usedPrevious.add(previousIndexes[0]);
}
return { previousByText, nextByText, retained, usedPrevious };
}
function refreshUnchangedLineRanges(extracted) {
if (extracted.length !== trackedLines.length
|| !extracted.every((line, index) => line.text === trackedLines[index].text)) return false;
for (const [index, line] of trackedLines.entries()) line.range = extracted[index].range;
return true;
}
function reconcileTracked(initial = false) {
trackedQueued = false;
if (!trackedElement) return;
if (!trackedElement.isConnected) {
clearTrackedArea(false);
report("The tracked text area was replaced. Select it again or use the next lookup to relearn it.",
{ tracked: false });
return;
}
const at = now();
const extracted = extractLines(trackedElement);
if (refreshUnchangedLineRanges(extracted)) return;
const values = extracted.map(line => line.text);
const { previousByText, nextByText, retained, usedPrevious } = retainedLinesFor(values);
const previousLastIndex = trackedLines.length - 1;
const nextLastIndex = values.length - 1;
const previousLast = trackedLines[previousLastIndex];
const nextLast = values[nextLastIndex];
let typewriter = null;
if (isTypewriterContinuation(previousLast, nextLast, at)
&& previousByText.get(previousLast.text)?.length === 1
&& nextByText.get(nextLast)?.length === 1
&& !usedPrevious.has(previousLastIndex) && !retained.has(nextLastIndex)) {
typewriter = { index: nextLastIndex, line: previousLast };
usedPrevious.add(previousLastIndex);
}
for (let index = 0; index < trackedLines.length; index += 1) {
if (!usedPrevious.has(index)) emitClose(domIdentity(trackedLines[index]), at);
}
const next = [];
for (let index = 0; index < values.length; index += 1) {
const text = values[index];
const range = extracted[index].range;
const previous = retained.get(index);
if (previous) {
next.push({ ...previous, range });
continue;
}
if (typewriter?.index === index) {
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: typewriter.line.id,
text, startMs: at, onsetKnown: !initial });
next.push({ ...typewriter.line, text, range, updatedMs: at });
continue;
}
const line = { id: `line-${++nextLineId}`, text, range, updatedMs: at };
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id,
text, startMs: at, onsetKnown: !initial && !previousByText.has(text) });
next.push(line);
}
trackedLines = next;
}
function queueTrackedReconcile() {
if (trackedQueued) return;
trackedQueued = true;
queueMicrotask(() => reconcileTracked(false));
}
function clearTrackedArea(reportChange = true) {
trackedObserver?.disconnect();
trackedObserver = null;
trackedVisibilityObserver?.disconnect();
trackedVisibilityObserver = null;
trackedMountObserver?.disconnect();
trackedMountObserver = null;
closeTrackedLines();
trackedElement = null;
trackedEpoch = "";
if (reportChange) report("Tracked text area cleared.", { tracked: false });
}
function trackArea(element, manual = false) {
if (!validTrackedElement(element)) throw new Error("Choose a bounded, non-editable text area.");
clearTrackedArea(false);
trackedElement = element;
trackedEpoch = `dom:${documentEpoch}:${crypto.randomUUID()}`;
reconcileTracked(true);
trackedObserver = new MutationObserver(queueTrackedReconcile);
trackedObserver.observe(element, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
});
trackedVisibilityObserver = new MutationObserver(queueTrackedReconcile);
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
trackedVisibilityObserver.observe(ancestor, {
attributes: true,
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
});
}
trackedMountObserver = new MutationObserver(queueTrackedReconcile);
for (let node = element; node.parentElement; node = node.parentElement) {
trackedMountObserver.observe(node.parentElement, { childList: true });
}
report(manual ? "Text area selected." : "Text area learned from the first lookup.", { tracked: true });
}
function learnArea(candidate) {
if (trackedElement || !linked || !options?.mediaCapture.page.domText
|| !options.mediaCapture.page.autoLearnArea) return;
let element = candidate?.anchor instanceof Element ? candidate.anchor : candidate?.anchor?.parentElement;
if (!validTrackedElement(element)) return;
let selected = element;
for (let depth = 0; depth < 3; depth += 1) {
const parent = selected.parentElement;
if (!validTrackedElement(parent)) break;
const textLength = (parent.textContent || "").length;
if (textLength > MAX_TEXT_LENGTH || parent.querySelectorAll("*").length > 100) break;
selected = parent;
}
try { trackArea(selected, false); } catch { /* Conservative auto-learning may decline the page. */ }
}
function occurrenceFor(candidate) {
if (!trackedElement || !candidate?.anchor || !trackedElement.contains(candidate.anchor)) return null;
// Keep the reader's DOM evidence local: a sentence or selection may occupy
// only part of a timed paragraph, and identical lines need their own identity.
const range = candidate.anchorRange ?? document.createRange();
if (!candidate.anchorRange) range.selectNodeContents(candidate.anchor);
const matches = trackedLines.filter(line => line.range?.comparePoint(range.startContainer, range.startOffset) === 0
&& line.range.comparePoint(range.endContainer, range.endOffset) === 0);
return matches.length === 1 ? { occurrenceId: matches[0].id, occurrenceSourceKind: "dom" } : null;
}
function blockPickerInput(event) {
event.preventDefault();
event.stopImmediatePropagation();
}
function picker() {
pickerCleanup?.();
let candidate = null;
const outline = document.createElement("div");
outline.setAttribute("aria-hidden", "true");
outline.style.cssText = [
"all: initial !important", "position: fixed !important", "pointer-events: none !important",
"z-index: 2147483647 !important", "border: 3px solid #36d399 !important",
"background: rgba(54,211,153,.12) !important", "box-sizing: border-box !important",
].join(";");
document.documentElement.append(outline);
function paint(element) {
candidate = validTrackedElement(element) ? element : null;
if (!candidate) {
outline.style.display = "none";
return;
}
const rect = candidate.getBoundingClientRect();
outline.style.display = "block";
outline.style.left = `${rect.left}px`;
outline.style.top = `${rect.top}px`;
outline.style.width = `${rect.width}px`;
outline.style.height = `${rect.height}px`;
}
function finish(element = null) {
pickerCleanup?.();
if (element) {
try { trackArea(element, true); }
catch (error) { report(error.message, { tracked: false }); }
} else {
report("Text area selection cancelled.", { tracked: Boolean(trackedElement) });
}
}
const listeners = [
["pointermove", event => {
blockPickerInput(event);
paint(document.elementFromPoint(event.clientX, event.clientY));
}, true],
["pointerdown", blockPickerInput, true],
["pointerup", blockPickerInput, true],
["mousedown", blockPickerInput, true],
["mouseup", blockPickerInput, true],
["auxclick", blockPickerInput, true],
["dblclick", blockPickerInput, true],
["contextmenu", blockPickerInput, true],
["click", event => { blockPickerInput(event); finish(candidate); }, true],
["keydown", event => {
blockPickerInput(event);
if (event.key === "Escape") finish();
else if (event.key === "ArrowUp" && candidate?.parentElement) {
paint(candidate.parentElement);
}
}, true],
["keypress", blockPickerInput, true],
["keyup", blockPickerInput, true],
];
for (const [type, listener, capture] of listeners) window.addEventListener(type, listener, capture);
pickerCleanup = () => {
for (const [type, listener, capture] of listeners) window.removeEventListener(type, listener, capture);
outline.remove();
pickerCleanup = null;
};
report("Choose a bounded text area. Arrow Up selects its parent; Escape cancels.", { picking: true });
}
async function link(mediaCapture, captureSessionId) {
const epoch = crypto.randomUUID();
documentEpoch = epoch;
linked = false;
const identity = await identify(captureSessionId);
if (documentEpoch !== epoch) throw new Error("The reading page link changed before its identity arrived.");
if (!mediaCapture || typeof mediaCapture !== "object") {
throw new Error("The capture service did not provide page timing settings.");
}
options = {
mediaCapture: {
...mediaCapture,
texthooker: { ...mediaCapture.texthooker },
page: { ...mediaCapture.page },
},
};
linked = true;
linkedDocumentId = identity.documentId;
const available = videos();
if (options.mediaCapture.timingMode !== "recent"
&& available.length === 1 && available[0].trackCount > 0
&& options.mediaCapture.page.nativeCues) {
attachVideo([...document.querySelectorAll("video")][0]);
} else {
attachVideo(null);
}
let message = "Reading page linked.";
if (available.length > 1) message = "Choose which video supplies native subtitle cues.";
else if (available.length === 0) message = "No native video cues found; page text and recent timing remain available.";
return { videos: available, message };
}
function lookupSnapshot(candidate, lookupTimeMs = now()) {
if (!linked || !options?.mediaCapture.enabled) return null;
const pageTiming = options.mediaCapture.timingMode !== "recent";
if (pageTiming) learnArea(candidate);
const occurrence = pageTiming
? occurrenceFor(candidate) ?? { occurrenceId: "", occurrenceSourceKind: "" }
: { occurrenceId: "", occurrenceSourceKind: "" };
return {
documentEpoch,
lookup: {
lookupText: String(candidate.sentence || candidate.query || "").slice(0, MAX_TEXT_LENGTH),
lookupTimeMs,
...occurrence,
},
};
}
async function pinLookup(snapshot) {
if (!snapshot || !linked || snapshot.documentEpoch !== documentEpoch) return null;
try {
return await send("hd_capture_pin", { lookup: snapshot.lookup });
} catch {
return null;
}
}
async function releaseToken(pin) {
if (pin?.token) {
try { await send("hd_capture_release", { token: pin.token }); } catch { /* Expired pins need no cleanup. */ }
}
}
function rootLookup(candidate) {
const snapshot = lookupSnapshot(candidate);
const operation = rootPinTail.then(async () => {
const previous = rootPin;
rootPin = null;
await releaseToken(previous);
const pin = await pinLookup(snapshot);
rootPin = pin;
return pin;
});
rootPinTail = operation.catch(() => {});
return operation;
}
function release(pin) {
const operation = rootPinTail.then(async () => {
const owned = pin === undefined ? rootPin : pin;
if (!owned?.token || rootPin?.token !== owned.token) return;
rootPin = null;
await releaseToken(owned);
});
rootPinTail = operation.catch(() => {});
return operation;
}
async function unlink() {
const epoch = crypto.randomUUID();
documentEpoch = epoch;
linked = false;
await release();
if (documentEpoch !== epoch) return { linked };
pickerCleanup?.();
selectedVideoCleanup?.();
clearTrackedArea(false);
linked = false;
linkedDocumentId = null;
options = null;
return { linked: false };
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.target !== CONTENT_TARGET) return false;
Promise.resolve().then(async () => {
switch (message.type) {
case "hd_capture_link": return link(message.mediaCapture, message.captureSessionId);
case "hd_capture_recover": return { linked, documentId: linkedDocumentId };
case "hd_capture_video_select": {
if (options?.mediaCapture.timingMode === "recent"
|| !options?.mediaCapture.page.nativeCues) {
throw new Error("Native cue timing is disabled for this capture session.");
}
const video = [...document.querySelectorAll("video")].find(item => videoId(item) === message.videoId);
if (!video) throw new Error("That video is no longer available.");
attachVideo(video);
return { selected: message.videoId };
}
case "hd_capture_track_area":
if (options?.mediaCapture.timingMode === "recent"
|| !options?.mediaCapture.page.domText) {
throw new Error("Enable webpage timing and watched page text in Media capture settings first.");
}
picker();
return { picking: true };
case "hd_capture_clear_area":
clearTrackedArea();
return { tracked: false };
case "hd_capture_unlink": return unlink();
default: throw new Error("Unknown capture command.");
}
}).then(result => sendResponse(result), error => sendResponse({ error: error.message || String(error) }));
return true;
});
window.addEventListener("pagehide", () => {
void unlink();
});
globalThis.HDCapture = { rootLookup, release };
}());