diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 3c9449a96..bb36edab4 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -244,6 +244,7 @@ export enum BulkActionType { } export enum ContentReader { + Epub = 'EPUB', Pdf = 'PDF', Web = 'WEB' } diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 82d0ef913..420960b75 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -203,6 +203,7 @@ enum BulkActionType { } enum ContentReader { + EPUB PDF WEB } diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index bc9f6603c..c8596909b 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -98,10 +98,12 @@ import { } from '../../utils/parser' import { parseSearchQuery, SortBy, SortOrder } from '../../utils/search' import { + contentReaderForPageType, getStorageFileDetails, makeStorageFilePublic, } from '../../utils/uploads' import { WithDataSourcesContext } from '../types' +import { pageTypeForContentType } from '../upload_files' enum ArticleFormat { Markdown = 'markdown', @@ -258,7 +260,7 @@ export const createArticleResolver = authorized< uploadFileHash = uploadFileDetails.md5Hash userArticleUrl = uploadFileDetails.fileUrl canonicalUrl = uploadFile.url - pageType = PageType.File + pageType = pageTypeForContentType(uploadFile.contentType) title = titleForFilePath(uploadFile.url) } else if ( source !== 'puppeteer-parse' && @@ -950,8 +952,7 @@ export const searchResolver = authorized< ...r, image: r.image && createImageProxyUrl(r.image, 260, 260), isArchived: !!r.archivedAt, - contentReader: - r.pageType === PageType.File ? ContentReader.Pdf : ContentReader.Web, + contentReader: contentReaderForPageType(r.pageType), originalArticleUrl: r.url, publishedAt: validatedDate(r.publishedAt), ownedByViewer: r.userId === claims.uid, @@ -1054,10 +1055,7 @@ export const updatesSinceResolver = authorized< ...p, image: p.image && createImageProxyUrl(p.image, 260, 260), isArchived: !!p.archivedAt, - contentReader: - p.pageType === PageType.File - ? ContentReader.Pdf - : ContentReader.Web, + contentReader: contentReaderForPageType(p.pageType), } as SearchItem, cursor: endCursor, itemID: p.id, diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index f5624512c..c7ba7a305 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -20,6 +20,7 @@ import { import { userDataToUser, validatedDate, wordsCount } from '../utils/helpers' import { createImageProxyUrl } from '../utils/imageproxy' import { + contentReaderForPageType, generateDownloadSignedUrl, generateUploadFilePathName, } from '../utils/uploads' @@ -375,7 +376,8 @@ export const functionResolvers = { Article: { async url(article: Article, _: unknown, ctx: WithDataSourcesContext) { if ( - article.pageType == PageType.File && + (article.pageType == PageType.File || + article.pageType == PageType.Book) && ctx.claims && article.uploadFileId ) { @@ -468,9 +470,7 @@ export const functionResolvers = { return !!page?.archivedAt || false }, contentReader(article: { pageType: PageType }) { - return article.pageType === PageType.File - ? ContentReader.Pdf - : ContentReader.Web + return contentReaderForPageType(article.pageType) }, highlights( article: { id: string; userId?: string; highlights?: Highlight[] }, @@ -551,7 +551,11 @@ export const functionResolvers = { }, SearchItem: { async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) { - if (item.pageType == PageType.File && ctx.claims && item.uploadFileId) { + if ( + (item.pageType == PageType.File || item.pageType == PageType.Book) && + ctx.claims && + item.uploadFileId + ) { const upload = await ctx.models.uploadFile.get(item.uploadFileId) if (!upload || !upload.fileName) { return undefined diff --git a/packages/api/src/resolvers/upload_files/index.ts b/packages/api/src/resolvers/upload_files/index.ts index d6c9684e5..bdc82c9dc 100644 --- a/packages/api/src/resolvers/upload_files/index.ts +++ b/packages/api/src/resolvers/upload_files/index.ts @@ -27,6 +27,13 @@ const isFileUrl = (url: string): boolean => { return parsedUrl.protocol == 'file:' } +export const pageTypeForContentType = (contentType: string): PageType => { + if (contentType == 'application/epub+zip') { + return PageType.Book + } + return PageType.File +} + export const uploadFileRequestResolver: ResolverFn< UploadFileRequestResult, unknown, @@ -145,7 +152,7 @@ export const uploadFileRequestResolver: ResolverFn< title: title, hash: uploadFilePathName, content: '', - pageType: PageType.File, + pageType: pageTypeForContentType(input.contentType), uploadFileId: uploadFileData.id, slug: generateSlug(uploadFilePathName), createdAt: new Date(), diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index afcd9c787..898633232 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -36,6 +36,7 @@ const schema = gql` enum ContentReader { WEB PDF + EPUB } input SortParams { diff --git a/packages/api/src/services/speech.ts b/packages/api/src/services/speech.ts index c23dbb784..34da9b489 100644 --- a/packages/api/src/services/speech.ts +++ b/packages/api/src/services/speech.ts @@ -1,4 +1,6 @@ import { Page, PageType } from '../elastic/types' +import { ContentReader } from '../generated/graphql' +import { contentReaderForPageType } from '../utils/uploads' import { FeatureName, isOptedIn } from './features' /* @@ -8,7 +10,10 @@ export const shouldSynthesize = async ( userId: string, page: Page ): Promise => { - if (page.pageType === PageType.File || !page.content) { + if ( + contentReaderForPageType(page.pageType) !== ContentReader.Web || + !page.content + ) { // we don't synthesize files for now return false } diff --git a/packages/api/src/utils/uploads.ts b/packages/api/src/utils/uploads.ts index 4c73c8780..738704662 100644 --- a/packages/api/src/utils/uploads.ts +++ b/packages/api/src/utils/uploads.ts @@ -2,6 +2,19 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { File, GetSignedUrlConfig, Storage } from '@google-cloud/storage' import { env } from '../env' +import { ContentReader, PageType } from '../generated/graphql' + +export const contentReaderForPageType = (pageType: PageType) => { + console.log('getting content reader: ', pageType) + switch (pageType) { + case PageType.Book: + return ContentReader.Epub + case PageType.File: + return ContentReader.Pdf + default: + return ContentReader.Web + } +} /* On GAE/Prod, we shall rely on default app engine service account credentials. * Two changes needed: 1) add default service account to our uploads GCS Bucket @@ -39,6 +52,7 @@ export const generateUploadSignedUrl = async ( expires: Date.now() + 15 * 60 * 1000, // 15 minutes contentType: contentType, } + console.log('signed url for: ', options) // Get a v4 signed URL for uploading file const [url] = await storage @@ -60,6 +74,7 @@ export const generateDownloadSignedUrl = async ( .bucket(bucketName) .file(filePathName) .getSignedUrl(options) + console.log('generating download signed url', url) return url } diff --git a/packages/web/components/templates/UploadModal.tsx b/packages/web/components/templates/UploadModal.tsx index f21507adf..296ba0dbe 100644 --- a/packages/web/components/templates/UploadModal.tsx +++ b/packages/web/components/templates/UploadModal.tsx @@ -126,6 +126,7 @@ export function UploadModal(props: UploadModalProps): JSX.Element { ;(async () => { for (const file of addedFiles) { try { + console.log('using content type: ', file.file.type) const request = await uploadFileRequestMutation({ // This will tell the backend not to save the URL // and give it the local filename as the title. @@ -145,7 +146,7 @@ export function UploadModal(props: UploadModalProps): JSX.Element { data: file.file, withCredentials: false, headers: { - 'Content-Type': 'application/pdf', + 'Content-Type': file.file.type, }, onUploadProgress: (p) => { if (!p.total) { diff --git a/packages/web/components/templates/article/EpubContainer.tsx b/packages/web/components/templates/article/EpubContainer.tsx new file mode 100644 index 000000000..6d8d19278 --- /dev/null +++ b/packages/web/components/templates/article/EpubContainer.tsx @@ -0,0 +1,363 @@ +import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery' +import { Box, VStack } from '../../elements/LayoutPrimitives' +import { v4 as uuidv4 } from 'uuid' +import { nanoid } from 'nanoid' +import { useState, useEffect, useRef, useMemo } from 'react' +import { currentTheme, getTheme, isDarkTheme } from '../../../lib/themeUpdater' +import PSPDFKit from 'pspdfkit' +import { Instance, HighlightAnnotation, List, Annotation, Rect } from 'pspdfkit' +import type { Highlight } from '../../../lib/networking/fragments/highlightFragment' +import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation' +import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation' +import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation' +import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation' +import { useCanShareNative } from '../../../lib/hooks/useCanShareNative' +import { pspdfKitKey } from '../../../lib/appConfig' +import { NotebookModal } from './NotebookModal' +import { HighlightNoteModal } from './HighlightNoteModal' +import { showErrorToast } from '../../../lib/toastHelpers' +import { HEADER_HEIGHT, MOBILE_HEADER_HEIGHT } from '../homeFeed/HeaderSpacer' +import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery' +import Epub, { EpubCFI } from 'epubjs' +import { Rendition, Contents } from 'epubjs/types' +import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts' +import { theme } from '../../tokens/stitches.config' + +export type EpubContainerProps = { + viewer: UserBasicData + article: ArticleAttributes + showHighlightsModal: boolean + setShowHighlightsModal: React.Dispatch> +} + +type EpubPatch = { + cfi: string + base: string +} + +export default function EpubContainer(props: EpubContainerProps): JSX.Element { + const epubRef = useRef(null) + const renditionRef = useRef(undefined) + const [shareTarget, setShareTarget] = useState( + undefined + ) + const [touchStart, setTouchStart] = useState(0) + const [notebookKey, setNotebookKey] = useState(uuidv4()) + const [noteTarget, setNoteTarget] = useState(undefined) + const [noteTargetPageIndex, setNoteTargetPageIndex] = useState< + number | undefined + >(undefined) + const highlightsRef = useRef([]) + + const book = useMemo(() => { + console.log('loading book: ', props.article.url) + const book = Epub(props.article.url, { + openAs: 'epub', + }) + book.loaded.navigation.then((nav) => { + console.log('navigated to: ', nav) + }) + console.log('loaded book: ', book) + return book + }, [props.article]) + + useEffect(() => { + if (!epubRef.current || !book || !book.loaded) { + return + } + + if (renditionRef.current) { + console.log('book already rendered') + return + } + + const epubOptions = {} + const node = epubRef.current + renditionRef.current = book.renderTo(node, { + width: '100%', + height: '100%', + }) + + renditionRef.current?.on('touchstart', (event: TouchEvent) => { + setTouchStart(event.changedTouches[0].screenX) + }) + + renditionRef.current?.on('touchend', (event: TouchEvent) => { + console.log('touchend: ', event) + + const _touchEnd = event.changedTouches[0].screenX + if (touchStart < _touchEnd) { + renditionRef.current?.next() + } + if (touchStart > _touchEnd) { + renditionRef.current?.prev() + } + }) + + renditionRef.current?.on('keydown', (ev: KeyboardEvent) => { + if (ev.key == 'ArrowRight') { + renditionRef.current?.next() + } else if (ev.key == 'ArrowLeft') { + renditionRef.current?.prev() + } + }) + + const themeId = currentTheme() + if (themeId) { + const readerTheme = getTheme(themeId) + renditionRef.current.themes.override( + 'color', + readerTheme.colors.readerFont.value, + true + ) + renditionRef.current.themes.override( + 'background', + readerTheme.colors.readerBg.value, + true + ) + renditionRef.current.themes.override( + 'backgroundColor', + readerTheme.colors.readerBg.value, + true + ) + renditionRef.current.themes.default({ + 'a:hover': { + color: 'unset !important', + }, + }) + } + renditionRef.current.display() + + // } + }, [book, epubRef, renditionRef]) + + /* + useEffect(() => { + async function setRenderSelection(cfirange: string, contents: Contents) { + if (!renditionRef.current) { + return + } + + console.log('contents of path: ', contents.content.innerHTML) + + const ranges = await Promise.all( + highlightsRef.current + .map((highlight) => highlight.patch) + .map((patch) => JSON.parse(patch) as EpubPatch) + .map((patch) => book.getRange(patch.cfi)) + ) + + // console.log('cfis: ', cfis) + + // const ranges = cfis.map((cfi) => cfi.toRange(document)) + console.log( + 'ranges: ', + ranges, + ranges.map((range) => range.getBoundingClientRect()) + ) + // setSelections( + // selections.concat({ + // text: renditionRef.current.getRange(cfiRange).toString(), + // cfiRange, + // }) + // ) + + function rectsOverlap(l1: DOMRect, l2: DOMRect): boolean { + console.log('checking overlap: ', l1, l2) + return ( + l1.left < l2.right && + l1.right > l2.left && + l1.top > l2.bottom && + l1.bottom < l2.top + ) + } + + const highlightCfi = await book.getRange(cfirange) + console.log( + 'highlightCfi', + highlightCfi, + highlightCfi.getBoundingClientRect() + ) + const selectionRange = contents.window.getSelection()?.getRangeAt(0) + + console.log('selectionRange: ', selectionRange) + + let overlap = false + if (selectionRange) { + const highlightRect = selectionRange.getClientRects() + + ranges.forEach((range) => { + const rects = range.getClientRects() + console.log(' existing rects: ', rects) + console.log(' highlightRects: ', highlightRect) + + for (let ei = 0; ei < rects.length; ei++) { + const check = rects.item(ei) + if (!check) { + continue + } + for (let ni = 0; ni < rects.length; ni++) { + const newRect = highlightRect.item(ni) + if (!newRect) { + continue + } + if (rectsOverlap(newRect, check)) { + overlap = true + } + } + } + }) + } + + console.log('found an overlap: ', overlap) + + const highlightId = uuidv4() + + const highlight = await createHighlightMutation({ + id: highlightId, + shortId: nanoid(8), + type: 'HIGHLIGHT', + patch: JSON.stringify({ + base: renditionRef.current.epubcfi, + cfi: cfirange, + }), + articleId: props.article.id, + }) + + if (!highlight) { + showErrorToast('Unable to create highlight') + return + } + + highlightsRef.current.push(highlight) + + renditionRef.current.annotations.highlight( + cfirange, + { + omnivoreHighlight: highlight, + }, + undefined, + 'hl', + { + fill: theme.colors.highlightBackground.toString(), + 'fill-opacity': '0.3', + 'mix-blend-mode': 'multiply', + } + ) + contents.window.getSelection()?.removeAllRanges() + } + + renditionRef.current?.on('selected', setRenderSelection) + return () => { + renditionRef.current?.off('selected', setRenderSelection) + } + }, [renditionRef]) + */ + + useEffect(() => { + const keyDown = (ev: KeyboardEvent) => { + console.log('keydown: ', ev.key) + if (ev.key == 'ArrowRight') { + renditionRef.current?.next() + } else if (ev.key == 'ArrowLeft') { + renditionRef.current?.prev() + } + } + + document.addEventListener('keydown', keyDown) + return () => { + document.removeEventListener('keydown', keyDown) + } + }) + + return ( + + + {/* EPUB CONTAINER +
*/} +
+ {noteTarget && ( + { + const savedHighlight = highlightsRef.current.find( + (other: Highlight) => { + return other.id == highlight.id + } + ) + + if (savedHighlight) { + savedHighlight.annotation = highlight.annotation + } + }} + onOpenChange={() => { + setNoteTarget(undefined) + }} + /> + )} + {props.showHighlightsModal && ( + { + console.log( + 'closed PDF notebook: ', + updatedHighlights, + deletedAnnotations + ) + deletedAnnotations.forEach((highlight) => { + const event = new CustomEvent('deleteHighlightbyId', { + detail: highlight.id, + }) + document.dispatchEvent(event) + }) + props.setShowHighlightsModal(false) + }} + viewHighlightInReader={(highlightId) => { + const event = new CustomEvent('scrollToHighlightId', { + detail: highlightId, + }) + document.dispatchEvent(event) + props.setShowHighlightsModal(false) + }} + /> + )} +
+ ) +} diff --git a/packages/web/lib/networking/fragments/articleFragment.ts b/packages/web/lib/networking/fragments/articleFragment.ts index 3511cfbaf..47e093d50 100644 --- a/packages/web/lib/networking/fragments/articleFragment.ts +++ b/packages/web/lib/networking/fragments/articleFragment.ts @@ -23,7 +23,7 @@ export const articleFragment = gql` } ` -export type ContentReader = 'WEB' | 'PDF' +export type ContentReader = 'WEB' | 'PDF' | 'EPUB' export enum State { SUCCEEDED = 'SUCCEEDED', diff --git a/packages/web/lib/themeUpdater.tsx b/packages/web/lib/themeUpdater.tsx index 54e32559c..6829d0147 100644 --- a/packages/web/lib/themeUpdater.tsx +++ b/packages/web/lib/themeUpdater.tsx @@ -4,6 +4,7 @@ import { sepiaTheme, apolloTheme, blackTheme, + theme, } from '../components/tokens/stitches.config' const themeKey = 'theme' @@ -24,8 +25,8 @@ export function updateTheme(themeId: string): void { updateThemeLocally(themeId) } -function getTheme(themeId: string) { - switch (currentTheme()) { +export function getTheme(themeId: string) { + switch (themeId) { case ThemeId.Dark: return darkTheme case ThemeId.Sepia: @@ -35,7 +36,7 @@ function getTheme(themeId: string) { case ThemeId.Black: return blackTheme } - return ThemeId.Light + return theme } export function updateThemeLocally(themeId: string): void { diff --git a/packages/web/package.json b/packages/web/package.json index 8d06e53ac..92e83aef6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -39,6 +39,7 @@ "dayjs": "^1.11.7", "diff-match-patch": "^1.0.5", "downshift": "^6.1.9", + "epubjs": "^0.3.93", "graphql-request": "^3.6.1", "kbar": "^0.1.0-beta.35", "markdown-it": "^13.0.1", diff --git a/packages/web/pages/[username]/[slug]/index.tsx b/packages/web/pages/[username]/[slug]/index.tsx index 19c8740e2..133702236 100644 --- a/packages/web/pages/[username]/[slug]/index.tsx +++ b/packages/web/pages/[username]/[slug]/index.tsx @@ -38,12 +38,18 @@ import { ReaderHeader } from '../../../components/templates/reader/ReaderHeader' import { EditArticleModal } from '../../../components/templates/homeFeed/EditItemModals' import { VerticalArticleActionsMenu } from '../../../components/templates/article/VerticalArticleActions' import { PdfHeaderSpacer } from '../../../components/templates/article/PdfHeaderSpacer' +import { EpubContainerProps } from '../../../components/templates/article/EpubContainer' const PdfArticleContainerNoSSR = dynamic( () => import('./../../../components/templates/article/PdfArticleContainer'), { ssr: false } ) +const EpubContainerNoSSR = dynamic( + () => import('./../../../components/templates/article/EpubContainer'), + { ssr: false } +) + export default function Home(): JSX.Element { const router = useRouter() const { cache, mutate } = useSWRConfig() @@ -403,14 +409,15 @@ export default function Home(): JSX.Element { /> ) : null} - {article && viewerData?.me && article.contentReader == 'PDF' ? ( + {article && viewerData?.me && article.contentReader == 'PDF' && ( - ) : ( + )} + {article && viewerData?.me && article.contentReader == 'WEB' && ( )} + {article && viewerData?.me && article.contentReader == 'EPUB' && ( + + {article && viewerData?.me ? ( + + ) : ( + + )} + + )} + {article && readerSettings.showSetLabelsModal && (