diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 0000000..031726c --- /dev/null +++ b/src/hooks/usePagination.ts @@ -0,0 +1,244 @@ +import React, { useEffect, useState } from "react"; + +type TextNode = Text & { nodeValue: string }; +type PositionElement = [number, TextNode]; +type IdPositions = Record; +type UsePaginationReturnTuple = [ + ready: boolean, + goToPage: (pageNum: number) => void, + currentPage: number +]; + +const isTextNode = (el: Node): el is TextNode => el.nodeType === Node.TEXT_NODE; +const isElementNode = (el: Node): el is HTMLElement => + el.nodeType === Node.ELEMENT_NODE; + +export const usePagination = ( + contentEl: React.RefObject, + pageContainerEl: React.RefObject, + pageEl: React.RefObject, + bookContent?: string +): UsePaginationReturnTuple => { + const [ready, setReady] = useState(false); + const [positions, setPositions] = useState([]); + const [idPositions, setIdPositions] = useState({}); + const [pages, setPages] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + + const computeStartPositionsOfElements = (root: HTMLDivElement) => { + const positionToElement: PositionElement[] = []; + const idPositions: IdPositions = {}; + + const recursive = (currentPosition: number, element: Node): number => { + if (isTextNode(element)) { + positionToElement.push([currentPosition, element]); + return currentPosition + element.nodeValue.length; + } else if (isElementNode(element)) { + if (element.id && element.id != null) + idPositions[element.id] = currentPosition; + + const children = element.childNodes; + + const newCurrentPosition = Array.from(children).reduce( + recursive, + currentPosition + ); + + return newCurrentPosition; + } else { + return currentPosition; + } + }; + + recursive(0, root); + + setPositions(positionToElement); + setIdPositions(idPositions); + }; + + const findPages = (page: HTMLElement, pageContainer: HTMLElement) => { + const pages = []; + pages.push(0); + let jump = 100; + + while (pages[pages.length - 1] < getMaxPosition()) { + if (pages.length > 2) + jump = pages[pages.length - 1] - pages[pages.length - 2]; + + const endPosition = findPage( + pages[pages.length - 1], + jump, + page, + pageContainer + ); + pages.push(endPosition); + } + + clearPage(page); + + setPages(pages); + setCurrentPage(0); + console.log(pages); + console.log("end"); + }; + + const findPage = ( + startPosition: number, + initialJump: number, + page: HTMLElement, + pageContainer: HTMLElement + ) => { + let previousEndPosition = getMaxPosition(); + let endPosition = findNextSpaceForPosition(startPosition + initialJump); + + copyTextToPage(startPosition, endPosition, page); + while (!scrollNecessary(pageContainer) && endPosition < getMaxPosition()) { + previousEndPosition = endPosition; + endPosition = findNextSpaceForPosition(endPosition + 1); + copyTextToPage(startPosition, endPosition, page); + } + + while (scrollNecessary(pageContainer) && endPosition > startPosition) { + previousEndPosition = endPosition; + endPosition = findPreviousSpaceForPosition(endPosition - 1); + copyTextToPage(startPosition, endPosition, page); + } + + if (endPosition === startPosition) return previousEndPosition; + return endPosition; + }; + + const findNextSpaceForPosition = (startPosition: number) => { + let i = 0; + while (i < positions.length - 1 && positions[i + 1][0] < startPosition) i++; + + const el = positions[i][1]; + let d = startPosition - positions[i][0]; + const str = el.nodeValue || ""; + while (d < str.length && str.charAt(d) != " ") d++; + + if (d >= str.length) { + if (i == positions.length - 1) return getMaxPosition(); + else return positions[i + 1][0]; + } else { + return positions[i][0] + d; + } + }; + + const findPreviousSpaceForPosition = (startPosition: number) => { + let i = 0; + while (i < positions.length - 1 && positions[i + 1][0] < startPosition) i++; + + const el = positions[i][1]; + let d = startPosition - positions[i][0]; + const str = el.nodeValue || ""; + while (d > 0 && str.charAt(d) != " ") d--; + + return positions[i][0] + d; + }; + + const scrollNecessary = (pageContainer: HTMLElement) => + pageContainer.scrollHeight > pageContainer.clientHeight || + pageContainer.scrollWidth > pageContainer.clientWidth; + + const copyTextToPage = (from: number, to: number, page: HTMLElement) => { + const range = document.createRange(); + + const startEl = getElementForPosition(from); + const startElement = startEl[1]; + const locationInStartEl = from - startEl[0]; + range.setStart(startElement, locationInStartEl); + + const endEl = getElementForPosition(to); + const endElement = endEl[1]; + const locationInEndEl = to - endEl[0]; + range.setEnd(endElement, locationInEndEl); + + page.innerHTML = ""; + page.appendChild(range.cloneContents()); + }; + + const clearPage = (page: HTMLElement) => { + page.innerHTML = ""; + }; + + const getPageForId = (id: string) => getPageForPosition(getPositionForId(id)); + + const getMaxPosition = () => { + const [pos, el] = positions[positions.length - 1]; + return pos + el.nodeValue.length || 0; + }; + + const getPositionForId = (id: string) => { + if (id in idPositions) return idPositions[id]; + return 0; + }; + + const getPageForPosition = (pos: number) => { + for (const [pageNum, pagePos] of pages.entries()) + if (pagePos > pos) return pageNum - 1; + + return pages.length - 2; + }; + + const getElementForPosition = (pos: number) => { + for (const [i, [currPos, _]] of positions.entries()) + if (currPos > pos) return positions[i - 1]; + + return positions[positions.length - 1]; + }; + + const jumpToLocation = (page: HTMLElement) => { + const url = new URL(window.location.href); + + const positionStr = url.searchParams.get("position"); + + if (url.href.lastIndexOf("#") > 0) { + const id = url.href.substring( + url.href.lastIndexOf("#") + 1, + url.href.length + ); + displayPage(getPageForId(id), page); + } else if (positionStr) { + const pos = parseInt(positionStr); + displayPage(getPageForPosition(pos), page); + } else { + displayPage(0, page); + } + }; + + const displayPage = (pageNum: number, page: HTMLElement) => { + console.log(pageNum, pages.length); + if (pageNum >= 0 && pageNum < pages.length - 1) { + setCurrentPage(pageNum); + const startPosition = pages[pageNum]; + copyTextToPage(startPosition, pages[pageNum + 1], page); + setReady(true); + } + }; + + useEffect(() => { + if (contentEl.current && bookContent) { + contentEl.current.innerHTML = bookContent; + computeStartPositionsOfElements(contentEl.current); + } + }, [bookContent]); + + useEffect(() => { + if (positions.length && pageEl.current && pageContainerEl.current) + findPages(pageEl.current, pageContainerEl.current); + }, [positions, idPositions]); + + useEffect(() => { + if (pageEl.current && pages.length && !ready) + jumpToLocation(pageEl.current); + }, [pages]); + + const makeDisplayPage = (page: React.RefObject) => { + if (page.current) + return (pageNum: number) => displayPage(pageNum, page.current!); + else return (pageNum: number) => {}; + }; + + return [ready, makeDisplayPage(pageEl), currentPage]; +}; diff --git a/src/pages/BookView/BookView.module.css b/src/pages/BookView/BookView.module.css new file mode 100644 index 0000000..c5a66a1 --- /dev/null +++ b/src/pages/BookView/BookView.module.css @@ -0,0 +1,32 @@ +.content { + display: none; +} + +.pageContainer { + display: block; + position: fixed; + width: 80vw; + left: 10vw; + height: 100vh; + top: 0; + /* overflow: hidden; */ + padding: 0; + margin: 0; +} + +img { + max-height: 98vh; + max-width: 80vw; +} + +.loadingIndicator { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + background-color: white; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/pages/BookView/index.tsx b/src/pages/BookView/index.tsx index 4c1aa08..e160706 100644 --- a/src/pages/BookView/index.tsx +++ b/src/pages/BookView/index.tsx @@ -1,8 +1,43 @@ -import React from "react"; -import { useRoute } from "wouter"; +import React, { useContext, useEffect, useRef, useState } from "react"; +import { Redirect, useRoute } from "wouter"; + +import styles from "./BookView.module.css"; + +import { BookListContext } from "~/context"; +import { usePagination } from "~/hooks/usePagination"; export const BookView = () => { const [match, params] = useRoute("/:hash"); + const [books] = useContext(BookListContext); - return
; + const contentRef = useRef(null); + const pageContainerRef = useRef(null); + const pageRef = useRef(null); + + const [ready, goToPage, currentPage] = usePagination( + contentRef, + pageContainerRef, + pageRef, + params?.hash ? books[params.hash]?.content : undefined + ); + + if (params?.hash && params.hash in books) + return ( + <> + {!ready && ( +
+

Loading

+
+ )} +
+
+
goToPage(currentPage + 1)} ref={pageRef} /> +
+ + ); + return ; }; diff --git a/tsconfig.json b/tsconfig.json index a273641..4c356d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "strict": true, "esModuleInterop": true, "skipLibCheck": true,