Files
readest/apps/readest-app/src/app/reader/components/ReviewModeController.tsx
T
2026-07-09 14:15:36 +08:00

308 lines
9.4 KiB
TypeScript

import DOMPurify from 'dompurify';
import { useEffect, useRef } from 'react';
import type { BookDoc } from '@/libs/document';
import type { ReviewRow } from '@/services/reviewEditorService';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
const STYLE_ID = 'readest-inline-review-style';
const ROW_ATTR = 'data-readest-review-row-id';
const ROLE_ATTR = 'data-readest-review-role';
const ORIGINAL_HTML_ATTR = 'data-readest-review-original-html';
const reviewHtmlPurifyOptions = {
ALLOWED_TAGS: [
'ruby',
'rb',
'rt',
'rp',
'span',
'mark',
'br',
'em',
'strong',
'b',
'i',
'u',
's',
'sub',
'sup',
],
ALLOWED_ATTR: ['class', 'title'],
ALLOW_DATA_ATTR: false,
};
const sanitizeReviewHtml = (html: string) =>
DOMPurify.sanitize(html || '', reviewHtmlPurifyOptions);
const emptyReviewRows: ReviewRow[] = [];
const normalizeFile = (value?: string) =>
(() => {
const raw = (value || '').split('#')[0] || '';
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
})()
.replaceAll('\\', '/')
.replace(/^\/+/, '')
.toLowerCase();
const sameFile = (rowFile: string, sectionHref?: string) => {
const row = normalizeFile(rowFile);
const href = normalizeFile(sectionHref);
if (!row || !href) return false;
return row === href || row.endsWith(`/${href}`) || href.endsWith(`/${row}`);
};
const sectionHrefAt = (bookDoc: BookDoc, index: number | undefined) => {
const section = bookDoc.sections[index ?? -1];
return section?.href || section?.id;
};
const clearReviewMarks = (doc: Document) => {
doc.getElementById(STYLE_ID)?.remove();
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
const originalHtml = element.getAttribute(ORIGINAL_HTML_ATTR);
if (originalHtml !== null) {
element.innerHTML = originalHtml;
}
element.removeAttribute(ROW_ATTR);
element.removeAttribute(ROLE_ATTR);
element.removeAttribute(ORIGINAL_HTML_ATTR);
element.classList.remove(
'readest-review-source',
'readest-review-target',
'readest-review-active',
);
});
};
const ensureReviewStyle = (doc: Document) => {
if (doc.getElementById(STYLE_ID)) return;
const style = doc.createElement('style');
style.id = STYLE_ID;
style.textContent = `
[${ROW_ATTR}] {
cursor: pointer;
user-select: text;
outline-offset: 0.18em;
transition: background-color 120ms ease, outline-color 120ms ease;
}
.readest-review-source {
background: rgba(127, 127, 127, 0.12);
outline: 1px dashed rgba(127, 127, 127, 0.64);
}
.readest-review-target {
background: rgba(22, 163, 74, 0.14);
outline: 1px solid rgba(22, 163, 74, 0.72);
}
.readest-review-active {
background: rgba(37, 99, 235, 0.18) !important;
outline: 2px solid currentColor !important;
}
`;
doc.head.appendChild(style);
};
const closestReviewTarget = (target: EventTarget | null): HTMLElement | null => {
const element = target as Element | null;
if (!element || typeof element.closest !== 'function') return null;
if (element.closest('a, button, input, textarea, select, video, audio')) return null;
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
};
const closestReviewTargetFromNode = (node: Node | null): HTMLElement | null => {
const element =
node?.nodeType === Node.ELEMENT_NODE
? (node as Element)
: (node?.parentElement as Element | null);
if (!element || typeof element.closest !== 'function') return null;
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
};
const selectedReviewTarget = (doc: Document): HTMLElement | null => {
const selection = doc.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return (
closestReviewTargetFromNode(range.commonAncestorContainer) ||
closestReviewTargetFromNode(range.startContainer) ||
closestReviewTargetFromNode(range.endContainer)
);
};
const markParagraph = (
element: Element | undefined,
row: ReviewRow,
role: 'source' | 'target',
active: boolean,
) => {
const htmlElement = element as HTMLElement | undefined;
if (!htmlElement || typeof htmlElement.setAttribute !== 'function') return;
htmlElement.setAttribute(ROW_ATTR, row.id);
htmlElement.setAttribute(ROLE_ATTR, role);
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
htmlElement.classList.toggle('readest-review-active', active);
if (role === 'target' && row.current_html) {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
}
htmlElement.innerHTML = sanitizeReviewHtml(row.current_html);
}
};
const applyActiveReviewMark = (doc: Document, selectedRowId: string) => {
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
element.classList.toggle(
'readest-review-active',
element.getAttribute(ROW_ATTR) === selectedRowId,
);
});
};
const applyReviewMarks = (
doc: Document,
sectionHref: string | undefined,
rows: ReviewRow[],
selectedRowId: string,
) => {
clearReviewMarks(doc);
const sectionRows = rows.filter((row) => sameFile(row.file, sectionHref));
if (!sectionRows.length) return;
ensureReviewStyle(doc);
const paragraphs = Array.from(doc.querySelectorAll('p'));
for (const row of sectionRows) {
const active = row.id === selectedRowId;
markParagraph(paragraphs[Number(row.ja_p_index)], row, 'source', active);
markParagraph(paragraphs[Number(row.cn_p_index)], row, 'target', active);
}
};
export const __testing = {
applyReviewMarks,
applyActiveReviewMark,
clearReviewMarks,
sectionHrefAt,
};
const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
bookKey,
bookDoc,
}) => {
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
const selectedRowId = useReviewModeStore((state) => state.books[bookKey]?.selectedRowId || '');
const selectRow = useReviewModeStore((state) => state.selectRow);
const selectedRowIdRef = useRef(selectedRowId);
useEffect(() => {
selectedRowIdRef.current = selectedRowId;
}, [selectedRowId]);
useEffect(() => {
if (!view) return;
const cleanupLoadedDocs = () => {
for (const { doc } of view.renderer.getContents()) {
clearReviewMarks(doc);
}
};
if (!enabled || !rows.length) {
cleanupLoadedDocs();
return;
}
const applyToLoadedDocs = () => {
for (const { doc, index } of view.renderer.getContents()) {
applyReviewMarks(doc, sectionHrefAt(bookDoc, index), rows, selectedRowIdRef.current);
}
};
const handleClick = (event: Event) => {
const target = closestReviewTarget(event.target);
if (!target) return;
const rowId = target.getAttribute(ROW_ATTR);
if (!rowId) return;
event.preventDefault();
event.stopPropagation();
if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation();
}
selectRow(bookKey, rowId);
};
const handleSelectionEnd = (event: Event) => {
const doc = event.currentTarget as Document | null;
if (!doc?.getSelection) return;
window.setTimeout(() => {
const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR);
if (rowId) selectRow(bookKey, rowId);
}, 0);
};
const listenedDocs = new Set<Document>();
const addDocListeners = (doc: Document) => {
if (listenedDocs.has(doc)) return;
listenedDocs.add(doc);
doc.addEventListener('click', handleClick, true);
doc.addEventListener('mouseup', handleSelectionEnd, true);
doc.addEventListener('touchend', handleSelectionEnd, true);
doc.addEventListener('keyup', handleSelectionEnd, true);
};
const removeDocListeners = (doc: Document) => {
listenedDocs.delete(doc);
doc.removeEventListener('click', handleClick, true);
doc.removeEventListener('mouseup', handleSelectionEnd, true);
doc.removeEventListener('touchend', handleSelectionEnd, true);
doc.removeEventListener('keyup', handleSelectionEnd, true);
};
const handleLoad = (event: Event) => {
const detail = (event as CustomEvent<{ doc?: Document; index?: number }>).detail;
if (!detail?.doc) return;
applyReviewMarks(
detail.doc,
sectionHrefAt(bookDoc, detail.index),
rows,
selectedRowIdRef.current,
);
addDocListeners(detail.doc);
};
for (const { doc } of view.renderer.getContents()) {
addDocListeners(doc);
}
applyToLoadedDocs();
view.addEventListener('load', handleLoad);
return () => {
view.removeEventListener('load', handleLoad);
for (const doc of listenedDocs) {
removeDocListeners(doc);
clearReviewMarks(doc);
}
};
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => {
if (!view || !enabled || !rows.length) return;
for (const { doc } of view.renderer.getContents()) {
applyActiveReviewMark(doc, selectedRowId);
}
}, [bookKey, enabled, rows.length, selectedRowId, view]);
return null;
};
export default ReviewModeController;