308 lines
9.4 KiB
TypeScript
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;
|