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(); 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;