forked from akai/readest
Add support of popover footnotes
This commit is contained in:
@@ -16,6 +16,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
|
||||
@@ -12,6 +12,7 @@ import Ribbon from './Ribbon';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import Annotator from './annotator/Annotator';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import FootnotePopup from './FootnotePopup';
|
||||
|
||||
interface BooksGridProps {
|
||||
bookKeys: string[];
|
||||
@@ -60,6 +61,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
|
||||
/>
|
||||
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
|
||||
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
|
||||
{viewSettings.scrolled ? null : (
|
||||
<>
|
||||
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FoliateView } from './FoliateViewer';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
interface FootnotePopupProps {
|
||||
bookKey: string;
|
||||
bookDoc: BookDoc;
|
||||
}
|
||||
|
||||
const popupWidth = 360;
|
||||
const popupHeight = 88;
|
||||
const popupPadding = 10;
|
||||
|
||||
const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const footnoteRef = useRef<HTMLDivElement>(null);
|
||||
const footnoteViewRef = useRef<FoliateView | null>(null);
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position | null>();
|
||||
const [popupPosition, setPopupPosition] = useState<Position | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { themeCode } = useTheme();
|
||||
const view = getView(bookKey);
|
||||
const footnoteHandler = new FootnoteHandler();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
const { view } = detail;
|
||||
footnoteViewRef.current = view;
|
||||
footnoteRef.current?.replaceChildren(view);
|
||||
const { renderer } = view;
|
||||
renderer.setAttribute('flow', 'scrolled');
|
||||
renderer.setAttribute('margin', '0px');
|
||||
renderer.setAttribute('gap', '5%');
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
};
|
||||
|
||||
const handleRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log('render footnote', detail);
|
||||
setShowPopup(true);
|
||||
};
|
||||
|
||||
footnoteHandler.addEventListener('before-render', handleBeforeRender);
|
||||
footnoteHandler.addEventListener('render', handleRender);
|
||||
return () => {
|
||||
footnoteHandler.removeEventListener('before-render', handleBeforeRender);
|
||||
footnoteHandler.removeEventListener('render', handleRender);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
const docLinkHandler = async (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc link click', detail);
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const triangPos = getPosition(detail.a, rect);
|
||||
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
|
||||
setTrianglePosition(triangPos);
|
||||
setPopupPosition(popupPos);
|
||||
|
||||
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
|
||||
console.warn(err);
|
||||
const detail = (event as CustomEvent).detail;
|
||||
view?.goTo(detail.href);
|
||||
});
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
const view = footnoteRef.current?.querySelector('foliate-view') as FoliateView;
|
||||
view?.close();
|
||||
view?.remove();
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
closePopup();
|
||||
setPopupPosition(null);
|
||||
setTrianglePosition(null);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
useFoliateEvents(view, {
|
||||
onLinkClick: docLinkHandler,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (footnoteViewRef.current) {
|
||||
footnoteRef.current?.replaceChildren(footnoteViewRef.current);
|
||||
}
|
||||
}, [footnoteRef]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showPopup && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={handleDismissPopup}
|
||||
onContextMenu={handleDismissPopup}
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={showPopup ? popupPosition! : undefined}
|
||||
trianglePosition={showPopup ? trianglePosition! : undefined}
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<div
|
||||
className=''
|
||||
ref={footnoteRef}
|
||||
style={{
|
||||
width: `${popupWidth}px`,
|
||||
height: `${popupHeight}px`,
|
||||
}}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FootnotePopup;
|
||||
@@ -120,8 +120,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='bg-neutral select-text'
|
||||
triangleClassName='text-neutral'
|
||||
className='select-text'
|
||||
>
|
||||
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
|
||||
@@ -107,8 +107,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='bg-neutral select-text overflow-y-auto'
|
||||
triangleClassName='text-neutral'
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<main className='p-2 font-sans'></main>
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
|
||||
@@ -163,8 +163,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='bg-neutral select-text overflow-y-auto'
|
||||
triangleClassName='text-neutral'
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<main className='p-4 font-sans' />
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
if (Date.now() - lastClickTime >= doubleClickThreshold) {
|
||||
let element: HTMLElement | null = event.target as HTMLElement;
|
||||
while (element) {
|
||||
if (['a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
|
||||
@@ -12,8 +12,8 @@ const Popup = ({
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
position?: Position;
|
||||
trianglePosition?: Position;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
triangleClassName?: string;
|
||||
@@ -21,24 +21,24 @@ const Popup = ({
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
className={`triangle absolute z-10 ${triangleClassName}`}
|
||||
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
|
||||
style={{
|
||||
left: `${trianglePosition.point.x}px`,
|
||||
top: `${trianglePosition.point.y}px`,
|
||||
left: `${trianglePosition ? trianglePosition.point.x : -999}px`,
|
||||
top: `${trianglePosition ? trianglePosition.point.y : -999}px`,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: trianglePosition.dir === 'up' ? 'none' : `6px solid`,
|
||||
borderTop: trianglePosition.dir === 'up' ? `6px solid` : 'none',
|
||||
borderBottom: trianglePosition && trianglePosition.dir === 'up' ? 'none' : `6px solid`,
|
||||
borderTop: trianglePosition && trianglePosition.dir === 'up' ? `6px solid` : 'none',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute rounded-lg font-sans shadow-lg ${className}`}
|
||||
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${position.point.x}px`,
|
||||
top: `${position.point.y}px`,
|
||||
left: `${position ? position.point.x : -999}px`,
|
||||
top: `${position ? position.point.y : -999}px`,
|
||||
...additionalStyle,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -42,9 +42,15 @@ const frameRect = (frame: Frame, rect: Rect, sx = 1, sy = 1) => {
|
||||
const pointIsInView = ({ x, y }: Point) =>
|
||||
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight;
|
||||
|
||||
const getIframeElement = (range: Range): HTMLIFrameElement | null => {
|
||||
let node: Node | null = range.commonAncestorContainer;
|
||||
|
||||
const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | null => {
|
||||
let node: Node | null;
|
||||
if (nodeElement && typeof nodeElement === 'object' && 'tagName' in nodeElement) {
|
||||
node = nodeElement as Element;
|
||||
} else if (nodeElement && typeof nodeElement === 'object' && 'collapse' in nodeElement) {
|
||||
node = nodeElement.commonAncestorContainer;
|
||||
} else {
|
||||
node = nodeElement;
|
||||
}
|
||||
while (node) {
|
||||
if (node.nodeType === Node.DOCUMENT_NODE) {
|
||||
const doc = node as Document;
|
||||
@@ -58,7 +64,7 @@ const getIframeElement = (range: Range): HTMLIFrameElement | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPosition = (target: Range, rect: Rect) => {
|
||||
export const getPosition = (target: Range | Element, rect: Rect) => {
|
||||
// TODO: vertical text
|
||||
const frameElement = getIframeElement(target);
|
||||
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
|
||||
|
||||
Reference in New Issue
Block a user