Reading view feature added
This commit is contained in:
parent
f08592e0f8
commit
cc8f30a8ce
1479
package-lock.json
generated
1479
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -22,7 +22,9 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-icons": "^5.3.0"
|
||||
"react-icons": "^5.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8",
|
||||
|
||||
42
src/components/Popover.jsx
Normal file
42
src/components/Popover.jsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTextSelection } from '../hooks/useTextSelection';
|
||||
|
||||
function Portal(props) {
|
||||
const mountNode = props.mount || (typeof document !== 'undefined' ? document.body : null);
|
||||
|
||||
if (!mountNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(props.children, mountNode);
|
||||
}
|
||||
|
||||
export function Popover(props) {
|
||||
const { children, selectionRef: target } = props;
|
||||
const textSelectionProps = useTextSelection(target);
|
||||
|
||||
// If there's no selection or it's collapsed, don't render anything
|
||||
if (textSelectionProps.isCollapsed || !textSelectionProps.clientRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the position of the selected text
|
||||
const { clientRect } = textSelectionProps;
|
||||
const popoverStyle = {
|
||||
position: 'absolute',
|
||||
left: `${clientRect.left + window.scrollX}px`,
|
||||
top: `${clientRect.top + window.scrollY + 40}px`,
|
||||
zIndex: 9999
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal mount={props.mount}>
|
||||
<div style={popoverStyle}>
|
||||
{typeof children === 'function'
|
||||
? children(textSelectionProps)
|
||||
: children}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@ -13,7 +13,7 @@ export default function Toggle({className}) {
|
||||
<center className={className}>
|
||||
<div className={styles.switch}>
|
||||
<input id="language-toggle" className={`${styles.checkToggle} ${styles.checkToggleRoundFlat}`} type="checkbox" onChange={onLanguageChange} />
|
||||
<label for="language-toggle"></label>
|
||||
<label htmlFor="language-toggle"></label>
|
||||
<span className={styles.on}>EN</span>
|
||||
<span className={styles.off}>AR</span>
|
||||
</div>
|
||||
|
||||
159
src/hooks/useTextSelection.ts
Normal file
159
src/hooks/useTextSelection.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
|
||||
type ClientRect = Record<keyof Omit<DOMRect, "toJSON">, number>;
|
||||
|
||||
function roundValues(_rect: ClientRect) {
|
||||
const rect = {
|
||||
..._rect
|
||||
}
|
||||
for (const key of Object.keys(rect)) {
|
||||
// @ts-ignore
|
||||
rect[key] = Math.round(rect[key])
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
function shallowDiff(prev: any, next: any) {
|
||||
if (prev != null && next != null) {
|
||||
for (const key of Object.keys(next)) {
|
||||
if (prev[key] != next[key]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if (prev != next) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Verse = {
|
||||
surahNo?: number,
|
||||
verseNo: number,
|
||||
verseText: string
|
||||
};
|
||||
|
||||
type TextSelectionState = {
|
||||
clientRect?: ClientRect,
|
||||
isCollapsed?: boolean,
|
||||
textContent?: string,
|
||||
selectedVerses?: Verse[],
|
||||
clearSelection?: () => void
|
||||
}
|
||||
|
||||
const defaultState: TextSelectionState = {}
|
||||
|
||||
/**
|
||||
* useTextSelection(ref)
|
||||
*
|
||||
* @description
|
||||
* hook to get information about the current text selection
|
||||
*
|
||||
*/
|
||||
import { RefObject } from 'react';
|
||||
|
||||
export function useTextSelection(target?: HTMLElement | RefObject<HTMLElement>) {
|
||||
|
||||
const [{
|
||||
clientRect,
|
||||
isCollapsed,
|
||||
textContent,
|
||||
selectedVerses
|
||||
}, setState] = useState<TextSelectionState>(defaultState)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(defaultState)
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
//@ts-ignore
|
||||
var sel = window.getSelection ? window.getSelection() : document.selection;
|
||||
if (sel) {
|
||||
if (sel.removeAllRanges) {
|
||||
sel.removeAllRanges();
|
||||
} else if (sel.empty) {
|
||||
sel.empty();
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handler = useCallback(() => {
|
||||
let newRect: ClientRect
|
||||
const selection = window.getSelection()
|
||||
let newState: TextSelectionState = { }
|
||||
|
||||
if (selection == null || !selection.rangeCount) {
|
||||
setState(newState)
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
let targetElement: HTMLElement | null | undefined = null;
|
||||
if (target) {
|
||||
if ('current' in target) {
|
||||
targetElement = target.current;
|
||||
} else {
|
||||
targetElement = target;
|
||||
}
|
||||
}
|
||||
if (targetElement != null && !targetElement.contains(range.commonAncestorContainer)) {
|
||||
setState(newState)
|
||||
return
|
||||
}
|
||||
|
||||
if (range == null) {
|
||||
setState(newState)
|
||||
return
|
||||
}
|
||||
|
||||
const contents = range.cloneContents();
|
||||
|
||||
if (contents.textContent != null) {
|
||||
newState.textContent = contents.textContent;
|
||||
newState.selectedVerses = Array.from(contents.querySelectorAll('span[verseno]')).map((span) => {
|
||||
const surahNo = parseInt(span.getAttribute('surahno') || '0');
|
||||
const verseNo = parseInt(span.getAttribute('verseno') || '0');
|
||||
const verseText = span.textContent || '';
|
||||
return { surahNo, verseNo, verseText };
|
||||
});
|
||||
}
|
||||
|
||||
const rects = range.getClientRects()
|
||||
|
||||
if (rects.length === 0 && range.commonAncestorContainer != null) {
|
||||
const el = range.commonAncestorContainer as HTMLElement
|
||||
newRect = roundValues(el.getBoundingClientRect().toJSON())
|
||||
} else {
|
||||
if (rects.length < 1) return
|
||||
newRect = roundValues(rects[0].toJSON())
|
||||
}
|
||||
if (shallowDiff(clientRect, newRect)) {
|
||||
newState.clientRect = newRect
|
||||
}
|
||||
|
||||
newState.isCollapsed = targetElement !== null ? range.collapsed : true;
|
||||
|
||||
setState(newState)
|
||||
}, [target])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.addEventListener('selectionchange', handler)
|
||||
document.addEventListener('keydown', handler)
|
||||
document.addEventListener('keyup', handler)
|
||||
window.addEventListener('resize', handler)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handler)
|
||||
document.removeEventListener('keydown', handler)
|
||||
document.removeEventListener('keyup', handler)
|
||||
window.removeEventListener('resize', handler)
|
||||
}
|
||||
}, [target])
|
||||
|
||||
return {
|
||||
clientRect,
|
||||
isCollapsed,
|
||||
textContent,
|
||||
selectedVerses,
|
||||
clearSelection
|
||||
}
|
||||
}
|
||||
@ -2,29 +2,35 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FaCopy, FaShareAlt } from 'react-icons/fa';
|
||||
import SeoHead from '../../components/SeoHead';
|
||||
import styles from '../../styles/Surah.module.css';
|
||||
import convertToArabicNumerals, { getNumberConversion } from '../../utils/convertToArabicNumerals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getSurahName, getSurahRevealationPlace } from '@/utils/surahUtil';
|
||||
import { useLanguage } from '@/utils/LanguageProvider';
|
||||
import SelectableParagraph from '@/components/SelectableParagraph';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Button, Modal, Switch } from 'antd';
|
||||
import axios from 'axios';
|
||||
import { Popover } from '@/components/Popover';
|
||||
import { useTextSelection } from '@/hooks/useTextSelection';
|
||||
import { FaBook, FaBookOpen, FaCopy, FaShareAlt } from 'react-icons/fa';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
const { t } = useTranslation();
|
||||
const { language } = useLanguage();
|
||||
const inputFocusRef = React.useRef(null);
|
||||
const refVerseContainer = React.useRef(null);
|
||||
const selectedText = useTextSelection(refVerseContainer);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
const [isReadingMode, setIsReadingMode] = React.useState(true);
|
||||
const [selectedVerse, setSelectedVerse] = React.useState({
|
||||
surahNo: surah ? surah.number : null,
|
||||
verseNo: null,
|
||||
text: "",
|
||||
detail: null
|
||||
verses: [],
|
||||
tafsir: null
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
|
||||
if (!surah) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
@ -59,29 +65,27 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onDetailClick = async (detail) => {
|
||||
// setSelectedVerse(detail);
|
||||
const handleAction = async () => {
|
||||
selectedText.clearSelection();
|
||||
setIsModalOpen(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/tafsir', {
|
||||
verse_key: `${detail.surahNo}:${detail.verseNo}`,
|
||||
verse_text: detail.selection
|
||||
});
|
||||
console.log('Tafsir response:', res.data);
|
||||
const verseDetail = {
|
||||
surahNo: detail.surahNo,
|
||||
verseNo: detail.verseNo,
|
||||
text: detail.selection,
|
||||
detail: res.data
|
||||
};
|
||||
|
||||
setSelectedVerse(verseDetail);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error during action:', error);
|
||||
return;
|
||||
}
|
||||
const res = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/tafsir', {
|
||||
verses: selectedText.selectedVerses
|
||||
});
|
||||
if (res.data){
|
||||
const verseDetail = {
|
||||
verses: selectedText.selectedVerses,
|
||||
tafsir: res.data.tafsir
|
||||
};
|
||||
setSelectedVerse(verseDetail);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error during action:', error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -119,6 +123,9 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span><Switch style={{backgroundColor: isReadingMode ? '#4DB6AC' : '#999999'}} checkedChildren={<span className={styles.switch}><FaBookOpen /></span>} unCheckedChildren={<span className={styles.switch}><FaBook /></span>} defaultChecked onChange={() => setIsReadingMode(!isReadingMode)} /></span>
|
||||
|
||||
<span className={styles.navButtonWrapper}>
|
||||
{nextSurah && (
|
||||
<Link href={`/quran/${nextSurah.number}`} className={styles.navButton}>
|
||||
@ -128,11 +135,43 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.surahText}>
|
||||
{prefaceText && (
|
||||
{prefaceText && (
|
||||
<p className={styles.bismillah}>{prefaceText}</p>
|
||||
)}
|
||||
)}
|
||||
{isReadingMode ?
|
||||
<div className={styles.surahContainer} ref={refVerseContainer} suppressContentEditableWarning>
|
||||
|
||||
{surah.verses.map((verse, index) => {
|
||||
// const formattedText = `
|
||||
// "${verse.text.ar}"
|
||||
|
||||
// 🌐 ترجمة:
|
||||
|
||||
// "${verse.text.en}"
|
||||
|
||||
// 🔖 — ${surah.name.ar}:${index + 1}`;
|
||||
|
||||
let willPageChange = false;
|
||||
const nextVerse = surah.verses[index + 1];
|
||||
if (nextVerse && nextVerse.page !== verse.page) {
|
||||
willPageChange = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`vp_${index}`}>
|
||||
<span key={`verse_${index}`} className={styles.verseText} surahno={surah.number} verseno={index + 1}>
|
||||
{verse.text.ar}
|
||||
</span>
|
||||
<span key={`stop_${index}`} className={styles.ayahNumber} title={index + 1} aria-label={index + 1}>
|
||||
{convertToArabicNumerals(index + 1)}
|
||||
</span>
|
||||
{willPageChange && <div className={styles.pageSeperator}>{verse.page}</div>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
:
|
||||
<div className={styles.surahText} suppressContentEditableWarning>
|
||||
{surah.verses.map((verse, index) => {
|
||||
const formattedText = `
|
||||
"${verse.text.ar}"
|
||||
@ -146,11 +185,12 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
return (
|
||||
<div key={index} className={styles.verseContainer}>
|
||||
<div className={styles.verseBox}>
|
||||
<SelectableParagraph surahNo={surah.number} verseNo={index + 1} key={`p_${index}`}
|
||||
{/* <SelectableParagraph surahNo={surah.number} verseNo={index + 1} key={`p_${index}`}
|
||||
index={index}
|
||||
onDetailClick={onDetailClick}
|
||||
content={<p className={styles.verseText} title={verse.text.ar} aria-label={verse.text.ar.split(" ").join("_")}>{verse.text.ar}</p>}>
|
||||
</SelectableParagraph>
|
||||
</SelectableParagraph> */}
|
||||
<p className={styles.verseText} title={verse.text.ar} aria-label={verse.text.ar.split(" ").join("_")}>{verse.text.ar}</p>
|
||||
<p className={styles.verseTextEn} title={verse.text.en} aria-label={verse.text.en.split(" ").join("_")}>{verse.text.en}</p>
|
||||
</div>
|
||||
<span className={styles.VerseNumber} title={index + 1} aria-label={index + 1}>
|
||||
@ -168,8 +208,24 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</div> }
|
||||
|
||||
<Popover selectionRef={refVerseContainer}>
|
||||
<div style={{
|
||||
background: 'grey',
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
transition: '300ms',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<p style={{ margin: '0', padding: '.5em' }}>
|
||||
<Button size="middle" onClick={() => handleAction()}>{t('surah.meta-tafsir')}</Button>
|
||||
</p>
|
||||
</div>
|
||||
</Popover>
|
||||
</main>
|
||||
<Modal
|
||||
title={<p>{`${getSurahName(surah, language)}`}</p>}
|
||||
@ -182,13 +238,23 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
|
||||
loading={loading}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
maskClosable={false}
|
||||
>
|
||||
<div>
|
||||
<h1 className={styles.title}>{t('surah.title')} {getSurahName(surah, language)}</h1>
|
||||
<div>
|
||||
<p className={styles.details}><b>{t('surah.meta-verse')}</b>: <span>{selectedVerse.verseNo && getNumberConversion(selectedVerse.verseNo, language)}</span></p>
|
||||
<p className={styles.details}><b>{t('surah.meta-selected-verse')}</b>: <span>{selectedVerse.text && selectedVerse.text}</span></p>
|
||||
<p className={styles.details}><b>{t('surah.meta-tafsir')}</b>: <span>{selectedVerse.detail && selectedVerse.detail.tafsir}</span></p>
|
||||
<div className={styles.detailsParent}>
|
||||
<div className={styles.details}><b>{t('surah.meta-selected-verse')}</b>:
|
||||
<span>
|
||||
{selectedVerse.verses && selectedVerse.verses.map((verse, index) => (
|
||||
<div key={`vn_${index}`} className={styles.detailsSelectedVerses}>{`${verse.surahNo}:${verse.verseNo} - ${verse.verseText}`}</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.details}><b>{t('surah.meta-tafsir')}</b>:
|
||||
<span>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{selectedVerse.tafsir && selectedVerse.tafsir}</Markdown>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@ -26,11 +26,22 @@
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.details {
|
||||
overflow-y: hidden;
|
||||
.detailsParent{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.detailsSelectedVerses {
|
||||
font-family: "hafs";
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.details {
|
||||
scroll-behavior: smooth;
|
||||
max-height: 200px;
|
||||
|
||||
background-color: var(--box-color);
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
@ -70,12 +81,41 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.surahContainer {
|
||||
width: 60%;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ayahNumber {
|
||||
font-family: "hafs";
|
||||
font-size: 29px;
|
||||
margin: 0 10px;
|
||||
color: var(--secondary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pageSeperator{
|
||||
background-color: var(--secondary-color);;
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-white);
|
||||
}
|
||||
|
||||
/* حاوية الآية */
|
||||
.surahText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.switch{
|
||||
display: flex;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verseContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -109,6 +149,7 @@
|
||||
.verseText {
|
||||
font-family: "hafs";
|
||||
font-size: 25px;
|
||||
word-break: auto-phrase;
|
||||
}
|
||||
.verseTextEn {
|
||||
font-family: "hafs";
|
||||
@ -150,6 +191,7 @@
|
||||
.navButtonWrapper {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
min-width: 230px
|
||||
}
|
||||
|
||||
.navButton {
|
||||
@ -208,4 +250,10 @@
|
||||
|
||||
.actions button:focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.surahContainer {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user