From ec0375505654c8119f75b0b81dde6c52735a8f60 Mon Sep 17 00:00:00 2001 From: summerscar Date: Wed, 1 Jan 2025 15:44:23 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ebook):=20support=20adjust=20f?= =?UTF-8?q?ontsize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/[slug]/_components/ebook.tsx | 131 +++++++++++++----- app/actions/annotate-actions.ts | 9 +- app/components/annotation-panel.tsx | 10 +- app/components/high-lighted-text.tsx | 19 +-- app/styles/_ebook.css | 11 +- keystone/schema.ts | 2 +- schema.graphql | 8 +- schema.prisma | 28 ++-- 8 files changed, 145 insertions(+), 73 deletions(-) diff --git a/app/(home)/article/[slug]/_components/ebook.tsx b/app/(home)/article/[slug]/_components/ebook.tsx index da7c4c1..f2eddb1 100644 --- a/app/(home)/article/[slug]/_components/ebook.tsx +++ b/app/(home)/article/[slug]/_components/ebook.tsx @@ -5,7 +5,7 @@ import FullscreenIcon from "@/assets/svg/full-screen.svg"; import NextIcon from "@/assets/svg/next.svg"; import PrevIcon from "@/assets/svg/prev.svg"; import { timeOut } from "@/utils/time-out"; -import { useFullscreen, useMemoizedFn } from "ahooks"; +import { useClickAway, useFullscreen, useMemoizedFn } from "ahooks"; import clsx from "clsx"; import type { Book, Contents, Location, NavItem, Rendition } from "epubjs"; import type Section from "epubjs/types/section"; @@ -13,6 +13,8 @@ import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +const debug = false; + const EBookRender = dynamic( () => import("./ebook-render").then((mod) => mod.EBookRender), { @@ -42,6 +44,7 @@ const EBook = ({ const containerRef = useRef(null); const [scrollLeft, setScrollLeft] = useState(0); const wrapperRef = useRef(null); + const tocWrapperRef = useRef(null); const initPromiseRef = useRef | null>(null); const ebookRenderContainerRef = useRef(null); const [isFullScreen, { enterFullscreen, exitFullscreen }] = useFullscreen( @@ -64,10 +67,13 @@ const EBook = ({ ); const onRender = useMemoizedFn((contents: Contents) => { - contents.document.querySelectorAll("p").forEach((p, index) => { - p.setAttribute("data-paragraph-index", `${index}`); - }); + contents.document + .querySelectorAll("p:not([data-paragraph-index])") + .forEach((p, index) => { + p.setAttribute("data-paragraph-index", `${index}`); + }); (async () => { + await timeOut(16); await Promise.all( [...contents.document.querySelectorAll("img")].map(async (img) => { await timeOut(16); @@ -82,7 +88,13 @@ const EBook = ({ "style", contents.document.body.getAttribute("style") || "", ); - wrapper.classList.add("overflow-hidden"); + const scrollLeft = + containerRef.current?.querySelector(".epub-container")?.scrollLeft || 0; + + Object.assign(wrapper.style, { + overflowY: "visible", + transform: `translateX(-${scrollLeft}px)`, + }); [...clonedBody.childNodes].forEach((node) => { wrapper.appendChild(node); }); @@ -106,6 +118,7 @@ const EBook = ({ spread: "always", allowScriptedContent: true, }); + setBook(newBook); setRendition(newRendition); window.book = newBook; @@ -141,15 +154,26 @@ const EBook = ({ }); }, [bookURL, onRender]); + const updateScrollLeft = () => { + const scrollLeft = + containerRef.current?.querySelector(".epub-container")?.scrollLeft; + setScrollLeft(scrollLeft || 0); + }; + const onRelocated = useMemoizedFn((location: Location) => { setCurrentLocation(location); setTimeout(() => { - const scrollLeft = - containerRef.current?.querySelector(".epub-container")?.scrollLeft; - setScrollLeft(scrollLeft || 0); - }); + updateScrollLeft(); + }, 16); }); + useEffect(() => { + if (!ebookRenderContainerRef.current?.firstChild) return; + ( + ebookRenderContainerRef.current.firstChild as HTMLDivElement + ).style.transform = `translateX(-${scrollLeft}px)`; + }, [scrollLeft]); + const handleSectionChange = useMemoizedFn((location: Location) => { const idref = spines.find( (item) => item.href === location.start.href, @@ -174,14 +198,6 @@ const EBook = ({ } }, [rendition, onRelocated]); - useEffect(() => { - if (!ebookRenderContainerRef.current?.firstChild) return; - (ebookRenderContainerRef.current.firstChild as HTMLDivElement).scrollTo( - scrollLeft, - 0, - ); - }, [scrollLeft]); - const handleNextPage = () => { if (rendition) { rendition.next(); @@ -200,11 +216,23 @@ const EBook = ({ } }; + const changeFontSize = (fontSize: string) => () => { + rendition?.themes.fontSize(fontSize); + onThemeChange(); + }; + const onThemeChange = () => { + if (!rendition) return; + const contents = rendition.getContents() as unknown as Contents[]; + onRender(contents[0]); + }; + const clean = useMemoizedFn(() => { rendition?.destroy(); book?.destroy(); }); + useClickAway(() => setShowTOC(false), tocWrapperRef); + useEffect(() => clean, [clean]); return ( @@ -222,7 +250,9 @@ const EBook = ({
{clonedDoms && ( @@ -246,22 +276,51 @@ const EBook = ({
)} {showTOC && tableOfContents.length > 0 && ( -
-

- {bookTitle} - -

- - +
+
+

+ {bookTitle} + +

+
+ + + +
+
+
+ +
)} )}
- + {currentLocation ? `${currentLocation.start.displayed.page} / ${currentLocation.start.displayed.total}` @@ -292,7 +351,7 @@ const EBook = ({
- + {spines.findIndex((s) => s.href === currentLocation?.start.href) + 1}{" "} / {spines.length} diff --git a/app/actions/annotate-actions.ts b/app/actions/annotate-actions.ts index 146abc6..34787bc 100644 --- a/app/actions/annotate-actions.ts +++ b/app/actions/annotate-actions.ts @@ -7,7 +7,10 @@ import type { AnnotationUpdateInput, } from ".keystone/types"; -const listAnnotationAction = async (bookId: string, chapterId: string) => { +const listAnnotationAction = async ({ + articleId, + chapterId, +}: { articleId?: string; chapterId?: string }) => { const session = await auth(); const userId = session?.user?.id; if (!userId) { @@ -15,9 +18,9 @@ const listAnnotationAction = async (bookId: string, chapterId: string) => { } const res = (await KSwithSession(session).db.Annotation.findMany({ where: { - bookId: { id: { equals: bookId } }, createdBy: { id: { equals: userId } }, - chapterId: { equals: chapterId }, + ...(articleId ? { articleId: { id: { equals: articleId } } } : null), + ...(chapterId ? { chapterId: { equals: chapterId } } : null), }, })) as AnnotationItem[]; diff --git a/app/components/annotation-panel.tsx b/app/components/annotation-panel.tsx index fba1996..583585b 100644 --- a/app/components/annotation-panel.tsx +++ b/app/components/annotation-panel.tsx @@ -60,7 +60,7 @@ const Annotation = ({ }: { annotation?: AnnotationItem; range?: Range }) => { const [value, setValue] = useState(annotation?.content || ""); // TODO: 作为组件参数 - const [bookId] = useState(() => location.pathname.split("/").pop()); + const [articleId] = useState(() => location.pathname.split("/").pop()); const searchParams = useSearchParams(); const chapterId = searchParams.get("ep") || searchParams.get("section") || "0"; @@ -116,7 +116,7 @@ const Annotation = ({ const newAnnotation = { type: "NOTE", - bookId: { connect: { id: bookId } }, + articleId: { connect: { id: articleId } }, chapterId, content: value, text: rangeText, @@ -138,7 +138,7 @@ const Annotation = ({ .then(() => {}) .finally(async () => { unmountPromise.current = null; - await refreshSWRUserAnnotationItems(bookId!, chapterId); + await refreshSWRUserAnnotationItems({ chapterId, articleId }); isDev && console.log("[create annotation][success]", newAnnotation); cancel(); }); @@ -148,7 +148,7 @@ const Annotation = ({ const remove = async (annotation: AnnotationItem) => { const cancel = createLoadingToast("Removing…"); await removeAnnotationAction(annotation.id); - await refreshSWRUserAnnotationItems(bookId!, chapterId); + await refreshSWRUserAnnotationItems({ chapterId, articleId }); cancel(); isDev && console.log("[remove annotation][success]", annotation); }; @@ -157,7 +157,7 @@ const Annotation = ({ if (value === annotation.content) return; const cancel = createLoadingToast("Updating…"); await updateAnnotationAction(annotation.id, { content: value }); - await refreshSWRUserAnnotationItems(bookId!, chapterId); + await refreshSWRUserAnnotationItems({ chapterId, articleId }); cancel(); isDev && console.log("[update annotation][success]"); }; diff --git a/app/components/high-lighted-text.tsx b/app/components/high-lighted-text.tsx index 051cdd0..6995967 100644 --- a/app/components/high-lighted-text.tsx +++ b/app/components/high-lighted-text.tsx @@ -11,7 +11,10 @@ import useSWRImmutable from "swr/immutable"; const SWR_DICT_KEY = "user-dict-items"; const SWR_ANNOTATION_KEY = "user-annotation-items"; -const getAnnotationRevalidateKey = (articleId: string, chapterId?: string) => +const getAnnotationRevalidateKey = ({ + articleId, + chapterId, +}: { articleId?: string; chapterId?: string }) => [SWR_ANNOTATION_KEY, articleId, chapterId].filter(Boolean).join("|"); let increment = 0; @@ -32,9 +35,9 @@ const useUserDictItems = () => { const useUserAnnotationItems = (articleId: string, chapterId = "0") => { const { isLogin } = useUser(); const { data = emptyArray } = useSWRImmutable( - isLogin ? getAnnotationRevalidateKey(articleId, chapterId) : null, + isLogin ? getAnnotationRevalidateKey({ articleId, chapterId }) : null, async () => { - return await listAnnotationAction(articleId, chapterId); + return await listAnnotationAction({ articleId, chapterId }); }, ); @@ -45,11 +48,11 @@ export const refreshSWRUserDictItems = async () => { await mutate(SWR_DICT_KEY); }; -export const refreshSWRUserAnnotationItems = async ( - articleId: string, - chapterId?: string, -) => { - await mutate(getAnnotationRevalidateKey(articleId, chapterId)); +export const refreshSWRUserAnnotationItems = async ({ + articleId, + chapterId, +}: { articleId?: string; chapterId?: string }) => { + await mutate(getAnnotationRevalidateKey({ articleId, chapterId })); }; const useHighlightedText = ( diff --git a/app/styles/_ebook.css b/app/styles/_ebook.css index d5f6a56..da5595e 100644 --- a/app/styles/_ebook.css +++ b/app/styles/_ebook.css @@ -44,11 +44,18 @@ font-size: 0.8em; color: #6f6f6f; } - + img { + display: inline; + } + div.sgc-2 { + text-align: center; + } p.sgc-3 { font-weight: bold; } - + p.sgc-5 { + text-align: center; + } h2.sgc-4 { text-align: center; } diff --git a/keystone/schema.ts b/keystone/schema.ts index c5eeac8..695c05f 100644 --- a/keystone/schema.ts +++ b/keystone/schema.ts @@ -306,7 +306,7 @@ export const lists = { validation: { isRequired: true }, label: "类型", }), - bookId: relationship({ ref: "Article", many: false }), + articleId: relationship({ ref: "Article", many: false }), chapterId: text({ validation: { isRequired: true } }), text: text({ validation: { isRequired: true } }), content: text({ validation: { isRequired: false } }), diff --git a/schema.graphql b/schema.graphql index e7c1f73..3b9d41d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -719,7 +719,7 @@ input ArticleCreateInput { type Annotation { id: ID! type: AnnotationTypeType - bookId: Article + articleId: Article chapterId: String text: String content: String @@ -745,7 +745,7 @@ input AnnotationWhereInput { NOT: [AnnotationWhereInput!] id: IDFilter type: AnnotationTypeTypeNullableFilter - bookId: ArticleWhereInput + articleId: ArticleWhereInput chapterId: StringFilter text: StringFilter content: StringFilter @@ -775,7 +775,7 @@ input AnnotationOrderByInput { input AnnotationUpdateInput { type: AnnotationTypeType - bookId: ArticleRelateToOneForUpdateInput + articleId: ArticleRelateToOneForUpdateInput chapterId: String text: String content: String @@ -799,7 +799,7 @@ input AnnotationUpdateArgs { input AnnotationCreateInput { type: AnnotationTypeType - bookId: ArticleRelateToOneForCreateInput + articleId: ArticleRelateToOneForCreateInput chapterId: String text: String content: String diff --git a/schema.prisma b/schema.prisma index 8fcf940..5a7b092 100644 --- a/schema.prisma +++ b/schema.prisma @@ -107,17 +107,17 @@ model DictItemFavorite { } model Article { - id String @id @default(cuid()) - title String @default("") - type ArticleTypeType - poster String @default("") - description String @default("") - subtitles Json? @default("[{\"title\":\"MOVIE S01E01\",\"subtitles\":{\"ko\":{\"label\":\"Korean\",\"filename\":\"\"},\"zh-Hans\":{\"label\":\"Chinese\",\"filename\":\"\"},\"en\":{\"label\":\"English\",\"filename\":\"\"},\"ja\":{\"label\":\"Japanese\",\"filename\":\"\"}}}]") - content String @default("") - createdAt DateTime? @default(now()) - createdBy User? @relation("Article_createdBy", fields: [createdById], references: [id]) - createdById String? @map("createdBy") - from_Annotation_bookId Annotation[] @relation("Annotation_bookId") + id String @id @default(cuid()) + title String @default("") + type ArticleTypeType + poster String @default("") + description String @default("") + subtitles Json? @default("[{\"title\":\"MOVIE S01E01\",\"subtitles\":{\"ko\":{\"label\":\"Korean\",\"filename\":\"\"},\"zh-Hans\":{\"label\":\"Chinese\",\"filename\":\"\"},\"en\":{\"label\":\"English\",\"filename\":\"\"},\"ja\":{\"label\":\"Japanese\",\"filename\":\"\"}}}]") + content String @default("") + createdAt DateTime? @default(now()) + createdBy User? @relation("Article_createdBy", fields: [createdById], references: [id]) + createdById String? @map("createdBy") + from_Annotation_articleId Annotation[] @relation("Annotation_articleId") @@index([createdById]) } @@ -125,8 +125,8 @@ model Article { model Annotation { id String @id @default(cuid()) type AnnotationTypeType - bookId Article? @relation("Annotation_bookId", fields: [bookIdId], references: [id]) - bookIdId String? @map("bookId") + articleId Article? @relation("Annotation_articleId", fields: [articleIdId], references: [id]) + articleIdId String? @map("articleId") chapterId String @default("") text String @default("") content String @default("") @@ -137,7 +137,7 @@ model Annotation { updatedAt DateTime? @default(now()) @updatedAt range Json? @default("{\"start\":{\"paragraphIndex\":0,\"offset\":0},\"end\":{\"paragraphIndex\":0,\"offset\":0}}") - @@index([bookIdId]) + @@index([articleIdId]) @@index([createdById]) }