提交 d9e41303 authored 作者: 王鹏飞's avatar 王鹏飞

chore: update

上级 1ebb03ec
import React, { useState, useEffect } from 'react'
import { Spin } from 'antd'
import { Button, Spin, Upload, message } from 'antd'
import { PaperClipOutlined } from '@ant-design/icons'
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { uploadFile } from '@/utils/oss'
......@@ -16,6 +17,26 @@ const EditorReader = (props) => {
const [html, setHtml] = useState(content)
const [loading, setLoading] = useState(loadLoading || false)
const uploadAttachment = async (file) => {
if (!editor) return
setLoading(true)
try {
const url = await uploadFile(file, { filenamePrefix: 'attachment' })
const link = document.createElement('a')
link.href = url
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.textContent = `📎 ${file.name}`
editor.restoreSelection()
editor.dangerouslyInsertHtml(`<p>${link.outerHTML}</p>`)
message.success('附件上传成功')
} catch {
message.error('附件上传失败')
} finally {
setLoading(false)
}
}
// 工具栏配置
const toolbarConfig = {
excludeKeys: [
......@@ -128,12 +149,21 @@ const EditorReader = (props) => {
return (
<div className='editor_customer_simple'>
<div className='editor-box' style={{ border: '1px solid #ccc', zIndex: 100 }}>
<Toolbar
editor={editor}
defaultConfig={toolbarConfig}
mode='default'
style={{ borderBottom: '1px solid #ccc' }}
/>
<div className='editor-simple-toolbar'>
<Toolbar editor={editor} defaultConfig={toolbarConfig} mode='default' />
<Upload
showUploadList={false}
disabled={!editor || loading}
beforeUpload={(file) => {
void uploadAttachment(file)
return false
}}
>
<Button type='text' icon={<PaperClipOutlined />} disabled={!editor || loading}>
附件
</Button>
</Upload>
</div>
<Spin spinning={loading}>
<Editor
defaultConfig={editorConfig}
......
.editor_customer_simple {
.editor-simple-toolbar {
display: flex;
align-items: center;
border-bottom: 1px solid #ccc;
.w-e-bar {
min-width: 0;
flex: 1;
}
.ant-upload-wrapper {
flex: none;
padding-right: 4px;
}
}
.w-e-scroll {
font-size: 18px;
line-height: 1.5;
......
......@@ -51,6 +51,35 @@ export interface ReaderComment {
replies: ReaderComment[]
}
export interface ReaderInfo {
content: string
title: string
name: string
link: string
}
export interface ReaderGalleryItem {
url: string
title: string
desc: string
}
export interface ReaderQuestionOption {
option: string
correct: boolean
isSelected: boolean
}
export interface ReaderQuestion {
id: string
style: number
titles: string
analysis: string
answers: string
answerContent: string
options: ReaderQuestionOption[]
}
type UnknownRecord = Record<string, unknown>
function record(value: unknown): UnknownRecord {
......@@ -76,7 +105,7 @@ function number(value: unknown): number {
}
function enabled(value: unknown): boolean {
return value === true || value === 1 || value === '1'
return value === true || value === 1 || value === '1' || value === 'true'
}
function parseJson(value: unknown): UnknownRecord {
......@@ -144,6 +173,40 @@ function toSectionLink(value: unknown): ReaderSectionLink | undefined {
return id ? { id, name: string(data.name) } : undefined
}
function toReaderInfo(value: unknown): ReaderInfo {
const data = record(value)
return {
content: string(data.content),
title: string(data.title),
name: string(data.name),
link: string(data.link),
}
}
function toQuestion(value: unknown, result = false): ReaderQuestion {
const data = record(value)
const style = number(data.question_style)
const options = list(parseJson(result ? data.answer_content : data.question_list)).map(
(value) => {
const option = record(value)
return {
option: string(option.option),
correct: enabled(option.correct),
isSelected: enabled(option.is_select),
}
},
)
return {
id: string(data.id),
style,
titles: string(data.titles),
analysis: string(data.analysis),
answers: string(data.answers),
answerContent: string(data.answer_content),
options,
}
}
export class ReaderChapterLockedError extends Error {
constructor() {
super('本章节尚未解锁')
......@@ -187,6 +250,64 @@ export async function getSectionContent(bookId: string, chapterId: string): Prom
}
}
function getReaderInfo(endpoint: string, bookId: string, chapterId: string, position: string) {
return post<unknown>(`/api/app/v1/book/Information/${endpoint}`, {
book_id: bookId,
chapter_id: chapterId,
position,
}).then(toReaderInfo)
}
export function getBubbleByPosition(bookId: string, chapterId: string, position: string) {
return getReaderInfo('getBubbleByPosition', bookId, chapterId, position)
}
export function getGalleryByPosition(bookId: string, chapterId: string, position: string) {
return getReaderInfo('getGalleryByPosition', bookId, chapterId, position)
}
export function getExtendByPosition(bookId: string, chapterId: string, position: string) {
return getReaderInfo('getExtendByPosition', bookId, chapterId, position)
}
export function checkChapterQuestion(bookId: string, chapterId: string, position: string) {
return post<unknown>('/api/app/v1/book/Information/checkChapterQuestion', {
book_id: bookId,
chapter_id: chapterId,
position,
}).then((data) => number(record(data).counts) > 0)
}
export function getChapterQuestions(bookId: string, chapterId: string, position: string) {
return post<unknown>('/api/app/v1/book/Information/getChapterQuestion', {
book_id: bookId,
chapter_id: chapterId,
position,
}).then((data) => list(data).map((item) => toQuestion(item)))
}
export function getChapterAnswers(bookId: string, chapterId: string, position: string) {
return post<unknown>('/api/app/v1/book/Information/getAnswers', {
book_id: bookId,
chapter_id: chapterId,
position,
}).then((data) => list(data).map((item) => toQuestion(item, true)))
}
export function submitChapterAnswers(
bookId: string,
chapterId: string,
position: string,
answers: Array<{ question_id: string; answer: unknown }>,
) {
return post<unknown>('/api/app/v1/book/Information/submitAnswers', {
book_id: bookId,
chapter_id: chapterId,
position,
answer_content: JSON.stringify(answers),
})
}
export function addProgress(bookId: string, chapterId: string, progress: number) {
return post<unknown>('/api/app/v1/book/Information/addProgress', {
book_id: bookId,
......@@ -275,13 +396,19 @@ export function getChapterCommentList(bookId: string, chapterId: string) {
}).then((data) => list(data).map(toComment))
}
export function addComment(bookId: string, chapterId: string, content: string) {
export function addComment(
bookId: string,
chapterId: string,
content: string,
quoteContent = '',
title = '',
) {
return post<unknown>('/api/app/v1/book/Information/addComment', {
book_id: bookId,
chapter_id: chapterId,
comment_id: '0',
quote_content: '',
title: '',
quote_content: quoteContent,
title,
content: JSON.stringify({ text: content, audio: [], image: [] }),
})
}
......
import { Button, Empty, Result, Skeleton } from 'antd'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { ReaderChapterLockedError, type ReaderNote, type ReaderSection } from '../api'
import { sanitizeReaderHtml } from '../content-html'
import { getReaderInteraction, openReaderLink, type ReaderInteraction } from '../reader-interaction'
import { calculateReadingProgress, getScrollTopForReadingProgress } from '../reading-progress'
import { ReaderInteractionLayer } from './ReaderInteractionLayer'
export interface ReaderTextQuote {
exact: string
positioning: string
}
export type ReaderTextAction =
| 'discussion'
| 'note'
| 'underline'
| 'highlight'
| 'encyclopedia'
| 'dictionary'
interface ReaderContentProps {
bookId: string
chapterId: string
chapterName: string
section?: ReaderSection
......@@ -22,191 +34,167 @@ interface ReaderContentProps {
hasNextChapter: boolean
onPreviousChapter: () => void
onNextChapter: () => void
onChapterOpen: (chapterId: string) => void
onUnlock: () => void
onTextQuoteSelected: (quote: ReaderTextQuote) => void
onTextQuoteAction: (action: ReaderTextAction, quote: ReaderTextQuote) => void
onAnnotationSelect: () => void
}
function sanitizeChapterHtml(content: string) {
const document = new DOMParser().parseFromString(content, 'text/html')
document
.querySelectorAll('script, iframe, object, embed, base, form, meta')
.forEach((element) => element.remove())
document.querySelectorAll('*').forEach((element) => {
for (const attribute of [...element.attributes]) {
const value = attribute.value.trim().toLowerCase()
if (
attribute.name.toLowerCase().startsWith('on') ||
(['href', 'src', 'xlink:href'].includes(attribute.name.toLowerCase()) &&
value.startsWith('javascript:'))
) {
element.removeAttribute(attribute.name)
function pointAt(root: Element, offset: number) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let passed = 0
let node = walker.nextNode()
while (node) {
const next = passed + (node.textContent?.length ?? 0)
if (offset <= next) return { node, offset: Math.max(0, offset - passed) }
passed = next
node = walker.nextNode()
}
}
function restoreAnnotations(chapter: HTMLElement, notes: ReaderNote[]) {
chapter.querySelectorAll('mark.reader-annotation').forEach((mark) => {
mark.replaceWith(...mark.childNodes)
})
chapter.normalize()
const latestAnnotations = new Map<
string,
{
notes: Map<number, ReaderNote>
latestNote: ReaderNote
positions: Array<{ sideIndex: number; startOffset: number; endOffset: number }>
}
>()
const orderedNotes = [...notes].sort((left, right) => Number(left.id) - Number(right.id))
for (const note of orderedNotes) {
try {
const positioning = JSON.parse(note.positioning) as {
info?: Array<{ sideIndex: number; startOffset: number; endOffset: number }>
}
const positions = (positioning.info ?? []).map((position) => ({
sideIndex: Number(position.sideIndex),
startOffset: Number(position.startOffset),
endOffset: Number(position.endOffset),
}))
if (!positions.length) continue
const key = JSON.stringify(positions)
const annotation = latestAnnotations.get(key)
if (annotation) {
annotation.notes.set(note.type, note)
annotation.latestNote = note
} else {
latestAnnotations.set(key, {
notes: new Map([[note.type, note]]),
latestNote: note,
positions,
})
return document.body.innerHTML
}
}
} catch {
// Ignore invalid or outdated positioning data.
}
}
for (const { notes: annotationNotes, latestNote, positions } of latestAnnotations.values()) {
try {
for (const position of positions) {
const paragraph = chapter.children[position.sideIndex]
if (!paragraph) continue
const start = pointAt(paragraph, position.startOffset || 0)
const end = pointAt(paragraph, position.endOffset || 0)
if (!start || !end) continue
function annotationPayload(notes: ReaderNote[]) {
return JSON.stringify(
notes.map((note) => ({
id: note.id,
type: note.type,
color: note.color,
positioning: note.positioning,
})),
const range = document.createRange()
range.setStart(start.node, start.offset)
range.setEnd(end.node, end.offset)
if (range.collapsed) continue
const mark = document.createElement('mark')
const variants = [...annotationNotes.values()].map((note) =>
note.type === 1 ? 'underline' : note.type === 2 ? 'highlight' : 'note',
)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
mark.className = `reader-annotation ${variants.map((variant) => `reader-annotation--${variant}`).join(' ')}`
mark.dataset.readerNoteId = String(latestNote.id)
for (const note of annotationNotes.values()) {
if (!note.color) continue
const variable =
note.type === 1
? '--annotation-underline-color'
: note.type === 2
? '--annotation-highlight-color'
: '--annotation-note-color'
mark.style.setProperty(variable, note.color)
}
range.surroundContents(mark)
}
} catch {
// Ignore invalid or outdated positioning data.
}
}
}
function makeDocument(content: string, notes: ReaderNote[]) {
const safeContent = sanitizeChapterHtml(content)
const annotations = annotationPayload(notes)
return `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><style>
:root { color: #2b2520; background: #fffdf8; font-family: Georgia, "Noto Serif SC", "Songti SC", serif; }
body { box-sizing: border-box; max-width: 780px; margin: 0 auto; padding: 48px 40px 96px; font-size: 18px; line-height: 2; overflow-wrap: anywhere; }
img, video { max-width: 100%; height: auto; } audio { max-width: 100%; } table { display: block; max-width: 100%; overflow-x: auto; } pre { overflow-x: auto; padding: 16px; background: #f5f0e7; } a { color: #a94137; }
</style><style>
mark.reader-annotation { padding: 0; border-radius: 2px; color: inherit; cursor: pointer; }
mark.reader-annotation--highlight { background: var(--annotation-color, #fff08a); }
mark.reader-annotation--underline { background: transparent; border-bottom: 2px solid var(--annotation-color, #aa1941); }
mark.reader-annotation--note { background: color-mix(in srgb, var(--annotation-color, #aa1941) 18%, transparent); border-bottom: 2px solid var(--annotation-color, #aa1941); }
</style></head><body><div id="chapter-item">${safeContent}</div><script>
(() => {
const chapter = document.getElementById('chapter-item')
const annotations = ${annotations}
const reportHeight = () => {
const height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)
parent.postMessage({ source: 'ebook-reader', type: 'resize', height }, '*')
}
addEventListener('message', event => {
if (event.data?.source !== 'ebook-reader-host' || event.data?.type !== 'restore') return
const ratio = Math.min(1, Math.max(0, Number(event.data.anchor) / 100 || 0))
scrollTo(0, Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) * ratio)
})
const reportSelection = () => {
const selection = getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return
const exact = selection.toString().trim()
if (!exact) return
const range = selection.getRangeAt(0)
const directChild = node => {
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
function getPositioning(chapter: HTMLElement, range: Range) {
const directChild = (node: Node) => {
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement
while (element && element.parentElement !== chapter) element = element.parentElement
return element?.parentElement === chapter ? element : null
}
const offsetInChild = (child, node, offset) => {
const offsetInChild = (child: Element, node: Node, offset: number) => {
const boundary = document.createRange()
boundary.selectNodeContents(child)
boundary.setEnd(node, offset)
return boundary.toString().length
}
const legacyPositioning = () => {
if (!chapter) return null
const startChild = directChild(range.startContainer)
const endChild = directChild(range.endContainer)
if (!startChild || !endChild) return null
const children = Array.from(chapter.children)
const children = [...chapter.children]
const startIndex = children.indexOf(startChild)
const endIndex = children.indexOf(endChild)
if (startIndex < 0 || endIndex < startIndex) return null
const startOffset = offsetInChild(startChild, range.startContainer, range.startOffset)
const endOffset = offsetInChild(endChild, range.endContainer, range.endOffset)
const info = []
for (let index = startIndex; index <= endIndex; index += 1) {
const child = children[index]
const segmentStart = index === startIndex ? startOffset : 0
const segmentEnd = index === endIndex ? endOffset : child.textContent.length
if (segmentEnd <= segmentStart) continue
info.push({
startOffset: segmentStart,
endOffset: segmentEnd,
parentTagName: child.tagName,
parentIdBook: 'chapter-item',
sideIndex: index,
const info = children.slice(startIndex, endIndex + 1).flatMap((child, index) => {
const sideIndex = startIndex + index
const segmentStart = sideIndex === startIndex ? startOffset : 0
const segmentEnd = sideIndex === endIndex ? endOffset : (child.textContent?.length ?? 0)
return segmentEnd > segmentStart
? [{ startOffset: segmentStart, endOffset: segmentEnd, sideIndex }]
: []
})
}
if (!info.length) return null
return { type: info.length === 1 ? 'line' : 'more', info }
}
const before = range.cloneRange()
before.selectNodeContents(document.body)
before.setEnd(range.startContainer, range.startOffset)
const after = range.cloneRange()
after.selectNodeContents(document.body)
after.setStart(range.endContainer, range.endOffset)
const legacyPosition = legacyPositioning()
const quote = {
exact,
positioning: JSON.stringify(legacyPosition ?? {
version: 1,
exact,
prefix: before.toString().slice(-160),
suffix: after.toString().slice(0, 160),
}),
}
parent.postMessage({ source: 'ebook-reader', type: 'selection', quote }, '*')
}
addEventListener('mouseup', reportSelection)
addEventListener('keyup', reportSelection)
const observer = new ResizeObserver(reportHeight)
observer.observe(document.body)
addEventListener('load', reportHeight)
const pointAt = (root, offset) => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let textNode
let passed = 0
while ((textNode = walker.nextNode())) {
const next = passed + textNode.data.length
if (offset <= next) return { node: textNode, offset: Math.max(0, offset - passed) }
passed = next
}
return null
}
const applyAnnotation = (note, position) => {
if (!chapter || !position || typeof position.sideIndex !== 'number') return
const paragraph = chapter.children[position.sideIndex]
if (!paragraph) return
const start = pointAt(paragraph, Number(position.startOffset) || 0)
const end = pointAt(paragraph, Number(position.endOffset) || 0)
if (!start || !end) return
const range = document.createRange()
try {
range.setStart(start.node, start.offset)
range.setEnd(end.node, end.offset)
if (range.collapsed) return
const mark = document.createElement('mark')
const variant = note.type === 1 ? 'underline' : note.type === 2 ? 'highlight' : 'note'
mark.className = 'reader-annotation reader-annotation--' + variant
mark.dataset.readerNoteId = String(note.id)
if (note.color) mark.style.setProperty('--annotation-color', note.color)
range.surroundContents(mark)
} catch {}
}
const restoreAnnotations = () => {
annotations.forEach(note => {
try {
const positioning = JSON.parse(note.positioning)
const positions = Array.isArray(positioning.info) ? positioning.info : []
positions.forEach(position => applyAnnotation(note, position))
} catch {}
})
}
chapter?.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target.closest('[data-reader-note-id]') : null
if (!target) return
parent.postMessage({ source: 'ebook-reader', type: 'annotation' }, '*')
})
restoreAnnotations()
})()
</script></body></html>`
return info.length ? { type: info.length === 1 ? 'line' : 'more', info } : null
}
const ReaderChapterHtml = memo(function ReaderChapterHtml({
contentRef,
html,
onClick,
onTextSelection,
}: {
contentRef: RefObject<HTMLDivElement | null>
html: string
onClick: (event: React.MouseEvent<HTMLDivElement>) => void
onTextSelection: () => void
}) {
return (
<div
ref={contentRef}
className='reader-content__chapter'
onMouseUp={onTextSelection}
onKeyUp={onTextSelection}
onClick={onClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
})
export function ReaderContent({
bookId,
chapterId,
chapterName,
section,
......@@ -220,37 +208,43 @@ export function ReaderContent({
hasNextChapter,
onPreviousChapter,
onNextChapter,
onChapterOpen,
onUnlock,
onTextQuoteSelected,
onTextQuoteAction,
onAnnotationSelect,
}: ReaderContentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const lastReport = useRef(0)
const restorePending = useRef(true)
const [iframeHeight, setIframeHeight] = useState(720)
const [iframeReady, setIframeReady] = useState(false)
const srcDoc = useMemo(
() => (section ? makeDocument(section.content, notes) : ''),
[notes, section],
)
const [interaction, setInteraction] = useState<ReaderInteraction | null>(null)
const [textSelection, setTextSelection] = useState<{
quote: ReaderTextQuote
rect: DOMRect
} | null>(null)
const safeContent = useMemo(() => (section ? sanitizeReaderHtml(section.content) : ''), [section])
useEffect(() => {
restorePending.current = true
lastReport.current = 0
setIframeHeight(720)
setIframeReady(false)
setInteraction(null)
setTextSelection(null)
}, [chapterId])
useEffect(() => {
const content = contentRef.current
if (content) restoreAnnotations(content, notes)
}, [notes, safeContent])
const reportGlobalScroll = useCallback(() => {
if (restorePending.current) return
const iframe = iframeRef.current
if (!iframe) return
const content = contentRef.current
if (!content) return
const headerHeight =
document.querySelector<HTMLElement>('.reader-view__header')?.offsetHeight ?? 0
const progress = calculateReadingProgress({
scrollTop: window.scrollY,
iframeTop: window.scrollY + iframe.getBoundingClientRect().top,
iframeHeight,
contentTop: window.scrollY + content.getBoundingClientRect().top,
contentHeight: content.offsetHeight,
viewportHeight: window.innerHeight,
headerHeight,
})
......@@ -258,13 +252,12 @@ export function ReaderContent({
if (now - lastReport.current < 1200) return
lastReport.current = now
onAnchorChange(String(progress), progress)
}, [iframeHeight, onAnchorChange])
}, [onAnchorChange])
useEffect(() => {
const onScroll = () => requestAnimationFrame(reportGlobalScroll)
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
onScroll()
return () => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
......@@ -272,62 +265,86 @@ export function ReaderContent({
}, [reportGlobalScroll])
useEffect(() => {
if (!restorePending.current || !iframeReady) return
const iframe = iframeRef.current
if (!iframe) return
const content = contentRef.current
if (!content || !restorePending.current) return
const frame = requestAnimationFrame(() => {
const headerHeight =
document.querySelector<HTMLElement>('.reader-view__header')?.offsetHeight ?? 0
window.scrollTo({
top: getScrollTopForReadingProgress({
progress: restoreAnchor,
iframeTop: window.scrollY + iframe.getBoundingClientRect().top,
iframeHeight,
contentTop: window.scrollY + content.getBoundingClientRect().top,
contentHeight: content.offsetHeight,
viewportHeight: window.innerHeight,
headerHeight,
}),
behavior: 'auto',
})
restorePending.current = false
window.requestAnimationFrame(reportGlobalScroll)
}, [iframeHeight, iframeReady, reportGlobalScroll, restoreAnchor])
requestAnimationFrame(reportGlobalScroll)
})
return () => cancelAnimationFrame(frame)
}, [chapterId, reportGlobalScroll, restoreAnchor, safeContent])
useEffect(() => {
const receiveMessage = (event: MessageEvent<unknown>) => {
if (
event.source !== iframeRef.current?.contentWindow ||
typeof event.data !== 'object' ||
event.data === null
)
return
const data = event.data as {
source?: string
type?: string
height?: number
quote?: { exact?: string; positioning?: string }
}
if (data.source !== 'ebook-reader') return
if (data.type === 'resize' && typeof data.height === 'number') {
setIframeHeight(Math.max(720, Math.ceil(data.height)))
setIframeReady(true)
return
}
if (data.type === 'annotation') {
onAnnotationSelect()
const handleTextSelection = useCallback(() => {
const content = contentRef.current
const selection = window.getSelection()
if (!content || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
setTextSelection(null)
return
}
if (data.type === 'selection' && typeof data.quote?.exact === 'string') {
const exact = data.quote.exact.trim()
const range = selection.getRangeAt(0)
if (!content.contains(range.commonAncestorContainer)) return
const exact = selection.toString().trim()
if (!exact) return
onTextQuoteSelected({
const positioning = getPositioning(content, range)
setTextSelection({
quote: {
exact,
positioning: data.quote.positioning || JSON.stringify({ version: 1, exact }),
positioning: JSON.stringify(positioning ?? { version: 1, exact }),
},
rect: range.getBoundingClientRect(),
})
}, [])
const handleContentClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target
if (!(target instanceof Element)) return
if (target.closest('[data-reader-note-id]')) {
onAnnotationSelect()
return
}
const nextInteraction = getReaderInteraction(target)
if (!nextInteraction) return
event.preventDefault()
if (nextInteraction.type === 'chapter') {
onChapterOpen(nextInteraction.chapterId)
} else if (nextInteraction.type === 'link') {
openReaderLink(nextInteraction.url)
} else {
setInteraction(nextInteraction)
}
window.addEventListener('message', receiveMessage)
return () => window.removeEventListener('message', receiveMessage)
}, [onAnnotationSelect, onTextQuoteSelected])
},
[onAnnotationSelect, onChapterOpen],
)
useEffect(() => {
const closeToolbar = () => setTextSelection(null)
const closeToolbarOutside = (event: MouseEvent) => {
if (event.target instanceof Element && !event.target.closest('.reader-selection-toolbar')) {
closeToolbar()
}
}
window.addEventListener('scroll', closeToolbar, { passive: true })
document.addEventListener('mousedown', closeToolbarOutside)
return () => {
window.removeEventListener('scroll', closeToolbar)
document.removeEventListener('mousedown', closeToolbarOutside)
}
}, [])
if (isLoading)
return (
......@@ -367,16 +384,52 @@ export function ReaderContent({
return (
<section className='reader-content' aria-label={chapterName || `章节 ${chapterId}`}>
<iframe
key={chapterId}
ref={iframeRef}
title={chapterName || '章节内容'}
sandbox='allow-scripts'
referrerPolicy='no-referrer'
scrolling='no'
srcDoc={srcDoc}
style={{ height: iframeHeight }}
<ReaderChapterHtml
contentRef={contentRef}
html={safeContent}
onClick={handleContentClick}
onTextSelection={handleTextSelection}
/>
<ReaderInteractionLayer
bookId={bookId}
chapterId={chapterId}
interaction={interaction}
onClose={() => setInteraction(null)}
/>
{textSelection ? (
<div
className='reader-selection-toolbar'
role='toolbar'
aria-label='选中文本操作'
style={{
left: Math.max(12, Math.min(textSelection.rect.left, window.innerWidth - 390)),
top: Math.max(76, textSelection.rect.top - 48),
}}
onMouseDown={(event) => event.preventDefault()}
>
{[
['discussion', '讨论'],
['note', '笔记'],
['underline', '划线'],
['highlight', '高亮'],
['encyclopedia', '百科'],
['dictionary', '字典'],
].map(([action, label]) => (
<Button
key={action}
size='small'
type='text'
onClick={() => {
onTextQuoteAction(action as ReaderTextAction, textSelection.quote)
setTextSelection(null)
window.getSelection()?.removeAllRanges()
}}
>
{label}
</Button>
))}
</div>
) : null}
<div className='reader-content__pager'>
<Button disabled={!hasPreviousChapter} onClick={onPreviousChapter}>
上一章
......
import { CloseOutlined } from '@ant-design/icons'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Card,
Checkbox,
Empty,
Image,
Input,
Modal,
Radio,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { useEffect, useMemo, useState } from 'react'
import {
checkChapterQuestion,
getBubbleByPosition,
getChapterAnswers,
getChapterQuestions,
getExtendByPosition,
getGalleryByPosition,
submitChapterAnswers,
type ReaderGalleryItem,
type ReaderQuestion,
} from '../api'
import { sanitizeReaderHtml } from '../content-html'
import { openReaderLink, type ReaderInteraction } from '../reader-interaction'
interface ReaderInteractionLayerProps {
bookId: string
chapterId: string
interaction: ReaderInteraction | null
onClose: () => void
}
type Answer = string | string[]
function Html({ children, inline = false }: { children: string; inline?: boolean }) {
const html = { __html: sanitizeReaderHtml(children) }
return inline ? <span dangerouslySetInnerHTML={html} /> : <div dangerouslySetInnerHTML={html} />
}
function parseGallery(content: string): ReaderGalleryItem[] {
try {
const value = JSON.parse(decodeURIComponent(content)) as unknown
if (!Array.isArray(value)) return []
return value.flatMap((item) => {
if (!item || typeof item !== 'object') return []
const data = item as Record<string, unknown>
return data.url
? [
{
url: String(data.url),
title: String(data.title ?? ''),
desc: String(data.desc ?? ''),
},
]
: []
})
} catch {
return []
}
}
function PracticeQuestion({
question,
index,
answer,
result,
onChange,
}: {
question: ReaderQuestion
index: number
answer?: Answer
result: boolean
onChange: (value: Answer) => void
}) {
const selected = result
? question.options.filter((option) => option.isSelected).map((option) => option.option)
: []
return (
<Card className='reader-practice__question' size='small'>
<Typography.Title level={5}>
{index + 1}. <Html inline>{question.titles}</Html>
</Typography.Title>
{question.style === 2 ? (
<Checkbox.Group
disabled={result}
value={result ? selected : Array.isArray(answer) ? answer : []}
options={question.options.map((option) => ({
label: <Html>{option.option}</Html>,
value: option.option,
}))}
onChange={(value) => onChange(value.map(String))}
/>
) : question.style === 1 || question.style === 3 ? (
<Radio.Group
disabled={result}
value={result ? selected[0] : answer}
options={question.options.map((option) => ({
label: <Html>{option.option}</Html>,
value: option.option,
}))}
onChange={(event) => onChange(event.target.value)}
/>
) : result ? (
<Typography.Paragraph>
<Typography.Text strong>你的答案:</Typography.Text>
{question.answerContent || '未作答'}
</Typography.Paragraph>
) : (
<Input.TextArea
value={typeof answer === 'string' ? answer : ''}
autoSize={{ minRows: 2, maxRows: 6 }}
placeholder={question.style === 4 ? '多个空请使用逗号分隔' : '请输入答案'}
onChange={(event) => onChange(event.target.value)}
/>
)}
{result ? (
<div className='reader-practice__result'>
<div>
<Tag color='green'>正确答案</Tag>
{question.style <= 3 ? (
question.options.some((option) => option.correct) ? (
<Space wrap>
{question.options
.filter((option) => option.correct)
.map((option) => (
<Html key={option.option}>{option.option}</Html>
))}
</Space>
) : (
'暂无'
)
) : question.answers ? (
<Html>{question.answers}</Html>
) : (
'暂无'
)}
</div>
{question.analysis ? (
<Typography.Paragraph>
<Typography.Text strong>解析:</Typography.Text>
<Html>{question.analysis}</Html>
</Typography.Paragraph>
) : null}
</div>
) : null}
</Card>
)
}
export function ReaderInteractionLayer({
bookId,
chapterId,
interaction,
onClose,
}: ReaderInteractionLayerProps) {
const [answers, setAnswers] = useState<Record<string, Answer>>({})
const [previewImage, setPreviewImage] = useState('')
const infoType =
interaction?.type === 'bubble' ||
interaction?.type === 'expand' ||
interaction?.type === 'gallery'
? interaction.type
: null
const position = interaction && 'position' in interaction ? interaction.position : ''
const infoQuery = useQuery({
queryKey: ['reader-info', infoType, bookId, chapterId, position],
enabled: Boolean(infoType && position),
queryFn: () => {
if (infoType === 'bubble') return getBubbleByPosition(bookId, chapterId, position)
if (infoType === 'gallery') return getGalleryByPosition(bookId, chapterId, position)
return getExtendByPosition(bookId, chapterId, position)
},
})
const practiceQuery = useQuery({
queryKey: ['reader-practice', bookId, chapterId, position],
enabled: interaction?.type === 'practice' && Boolean(position),
queryFn: async () => {
const answered = await checkChapterQuestion(bookId, chapterId, position)
const questions = answered
? await getChapterAnswers(bookId, chapterId, position)
: await getChapterQuestions(bookId, chapterId, position)
return { answered, questions }
},
})
useEffect(() => setAnswers({}), [position])
useEffect(() => setPreviewImage(''), [interaction])
const submitMutation = useMutation({
mutationFn: async () => {
const questions = practiceQuery.data?.questions ?? []
if (questions.some((question) => !answers[question.id]?.length)) {
throw new Error('请完成全部题目后再提交')
}
await submitChapterAnswers(
bookId,
chapterId,
position,
questions.map((question) => {
const answer = answers[question.id]
return {
question_id: question.id,
answer:
question.style <= 3
? question.options.map((option) => ({
option: option.option,
correct: option.correct,
is_select: Array.isArray(answer)
? answer.includes(option.option)
: answer === option.option,
}))
: Array.isArray(answer)
? answer.join(',')
: answer,
}
}),
)
},
onSuccess: async () => {
message.success('提交成功')
await practiceQuery.refetch()
},
onError: (error) => message.warning(error.message),
})
const gallery = useMemo(() => {
if (interaction?.type !== 'gallery') return []
return parseGallery(infoQuery.data?.content || interaction.fallback || '')
}, [infoQuery.data?.content, interaction])
if (interaction?.type === 'bubble') {
const content = infoQuery.data?.content || interaction.fallback || ''
const link = infoQuery.data?.link || interaction.link
return (
<Card
className='reader-bubble'
size='small'
title={infoQuery.data?.title || interaction.title}
style={{
left: Math.max(12, Math.min(interaction.rect?.left ?? 12, window.innerWidth - 372)),
top: Math.max(
12,
Math.min((interaction.rect?.bottom ?? 80) + 8, window.innerHeight - 220),
),
}}
extra={<Button type='text' icon={<CloseOutlined />} onClick={onClose} />}
>
{infoQuery.isLoading ? <Spin size='small' /> : null}
{infoQuery.error ? <Alert type='error' showIcon message={infoQuery.error.message} /> : null}
{content ? <Html>{content}</Html> : !infoQuery.isLoading ? <Empty /> : null}
{link ? (
<Typography.Link onClick={() => openReaderLink(link)}>查看详情</Typography.Link>
) : null}
</Card>
)
}
const modalType =
interaction?.type === 'expand' ||
interaction?.type === 'gallery' ||
interaction?.type === 'practice' ||
interaction?.type === 'image'
? interaction.type
: null
const interactionTitle = interaction && 'title' in interaction ? interaction.title : ''
return (
<Modal
open={Boolean(modalType)}
title={infoQuery.data?.name || infoQuery.data?.title || interactionTitle}
width={modalType === 'practice' ? 840 : 760}
footer={
modalType === 'practice' && practiceQuery.data && !practiceQuery.data.answered
? [
<Button key='cancel' onClick={onClose}>
取消
</Button>,
<Button
key='submit'
type='primary'
loading={submitMutation.isPending}
onClick={() => submitMutation.mutate()}
>
提交
</Button>,
]
: null
}
onCancel={onClose}
>
{modalType === 'image' && interaction?.type === 'image' ? (
<Image className='reader-preview-image' src={interaction.src} alt={interaction.title} />
) : null}
{modalType === 'expand' ? (
infoQuery.isLoading ? (
<Spin />
) : infoQuery.error ? (
<Alert type='error' showIcon message={infoQuery.error.message} />
) : infoQuery.data?.content ? (
<div
className='reader-rich-dialog'
onClick={(event) => {
const target = event.target
if (!(target instanceof Element)) return
const image = target.closest('img')
if (image instanceof HTMLImageElement) setPreviewImage(image.src)
const link = target.closest('a')
if (link instanceof HTMLAnchorElement) {
event.preventDefault()
openReaderLink(link.href)
}
}}
>
<Html>{infoQuery.data.content}</Html>
</div>
) : (
<Empty description='暂无扩展阅读内容' />
)
) : null}
{modalType === 'gallery' ? (
infoQuery.isLoading ? (
<Spin />
) : infoQuery.error ? (
<Alert type='error' showIcon message={infoQuery.error.message} />
) : gallery.length ? (
<Image.PreviewGroup>
<div className='reader-gallery'>
{gallery.map((item) => (
<figure key={`${item.url}-${item.title}`}>
<Image src={item.url} alt={item.title} />
<figcaption>
<Typography.Text strong>{item.title}</Typography.Text>
{item.desc ? <Typography.Paragraph>{item.desc}</Typography.Paragraph> : null}
</figcaption>
</figure>
))}
</div>
</Image.PreviewGroup>
) : (
<Empty description='暂无图集内容' />
)
) : null}
{modalType === 'practice' ? (
practiceQuery.isLoading ? (
<Spin />
) : practiceQuery.error ? (
<Alert type='error' showIcon message={practiceQuery.error.message} />
) : practiceQuery.data?.questions.length ? (
<Space className='reader-practice' orientation='vertical' size='middle'>
{practiceQuery.data.questions.map((question, index) => (
<PracticeQuestion
key={question.id}
question={question}
index={index}
answer={answers[question.id]}
result={practiceQuery.data.answered}
onChange={(value) =>
setAnswers((current) => ({ ...current, [question.id]: value }))
}
/>
))}
</Space>
) : (
<Empty description='暂无练习题' />
)
) : null}
{previewImage ? (
<Image
className='reader-dialog-hidden-image'
src={previewImage}
preview={{ open: true, onOpenChange: (open) => !open && setPreviewImage('') }}
/>
) : null}
</Modal>
)
}
import {
CommentOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
LikeFilled,
LikeOutlined,
MenuFoldOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
Avatar,
Button,
Card,
Empty,
Form,
Input,
......@@ -14,7 +23,7 @@ import {
Skeleton,
Space,
Switch,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
......@@ -30,15 +39,19 @@ import {
type ReaderNote,
} from '../api'
import { readerCommentsOptions, readerKeys, readerNotesOptions } from '../query'
import type { ReaderQueryState } from '../query-state'
import type { ReaderTextQuote } from './ReaderContent'
import type { ReaderTextAction, ReaderTextQuote } from './ReaderContent'
export type ReaderPanel = 'notes' | 'discussion'
interface ReaderSidePanelProps {
bookId: string
chapterId: string
panel: ReaderQueryState['panel']
panel: ReaderPanel
selectedQuote: ReaderTextQuote | null
onPanelChange: (panel: ReaderQueryState['panel']) => void
selectedTextAction: ReaderTextAction | null
onTextActionHandled: () => void
onPanelChange: (panel: ReaderPanel) => void
onCollapse: () => void
}
interface NoteFormValues {
......@@ -52,6 +65,7 @@ interface CommentFormValues {
interface CreateNoteValues extends NoteFormValues {
type: 1 | 2 | 3
quote?: ReaderTextQuote
}
function MutationError({ error }: { error: Error | null }) {
......@@ -75,46 +89,67 @@ function NoteList({
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='本章节还没有笔记' />
return (
<List
className='reader-note-list'
dataSource={notes}
split={false}
renderItem={(note) => (
<List.Item
className='reader-note-item'
actions={[
note.type === 3 ? (
<Button key='edit' type='link' size='small' onClick={() => onEdit(note)}>
编辑
</Button>
) : null,
<List.Item className='reader-note'>
<article className='reader-note__body'>
<Space className='reader-note__actions' size={2}>
{note.type === 3 ? (
<Tooltip title='编辑笔记'>
<Button
aria-label='编辑笔记'
className='reader-row-action'
type='text'
size='small'
icon={<EditOutlined />}
onClick={() => onEdit(note)}
/>
</Tooltip>
) : null}
<Popconfirm
key='delete'
title='删除这条笔记?'
okText='删除'
cancelText='取消'
onConfirm={() => onDelete(note)}
>
<Button danger type='link' size='small' loading={deletingId === note.id}>
删除
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
title={
<Space size={6}>
<Tag color={note.type === 3 ? 'blue' : 'default'}>
{note.type === 3 ? '笔记' : note.type === 2 ? '高亮' : '划线'}
</Tag>
{note.isOpen ? <Typography.Text type='secondary'>公开</Typography.Text> : null}
<Tooltip title='删除'>
<Button
danger
aria-label='删除笔记'
className='reader-row-action'
type='text'
size='small'
icon={<DeleteOutlined />}
loading={deletingId === note.id}
/>
</Tooltip>
</Popconfirm>
</Space>
}
description={
<>
<Typography.Paragraph
className='reader-panel__quote'
ellipsis={{ rows: 2, expandable: 'collapsible', symbol: '展开' }}
>
<span
className={`reader-panel__quote-text reader-panel__quote-text--${
note.type === 1 ? 'underline' : note.type === 2 ? 'highlight' : 'note'
}`}
style={
note.color
? note.type === 1
? { borderBottomColor: note.color }
: note.type === 2
? { backgroundColor: note.color }
: {
backgroundColor: `color-mix(in srgb, ${note.color} 18%, transparent)`,
borderBottomColor: note.color,
}
: undefined
}
>
“{note.content}”
</span>
</Typography.Paragraph>
{note.type === 3 && note.noteContent ? (
<Typography.Paragraph
......@@ -123,9 +158,12 @@ function NoteList({
{note.noteContent}
</Typography.Paragraph>
) : null}
</>
}
/>
{note.isOpen ? (
<Typography.Text className='reader-note__visibility' type='secondary'>
公开
</Typography.Text>
) : null}
</article>
</List.Item>
)}
/>
......@@ -148,10 +186,7 @@ function CommentItem({
nested?: boolean
}) {
return (
<Card
className={nested ? 'reader-comment reader-comment--reply' : 'reader-comment'}
size='small'
>
<article className={nested ? 'reader-comment reader-comment--reply' : 'reader-comment'}>
<Space align='start' size={8}>
<Avatar src={comment.authorAvatar || undefined}>
{comment.authorName.slice(0, 1) || '读'}
......@@ -163,11 +198,6 @@ function CommentItem({
<Typography.Text type='secondary'>{comment.createdAt}</Typography.Text>
) : null}
</Space>
{comment.title ? (
<Typography.Text strong className='reader-comment__title'>
{comment.title}
</Typography.Text>
) : null}
{comment.quoteContent ? (
<Typography.Paragraph className='reader-panel__quote' ellipsis={{ rows: 2 }}>
“{comment.quoteContent}”
......@@ -175,15 +205,18 @@ function CommentItem({
) : null}
<Typography.Paragraph>{comment.content.text || '—'}</Typography.Paragraph>
<Space size={2}>
<Tooltip title={comment.isPraised ? '取消点赞' : '点赞'}>
<Button
aria-label={comment.isPraised ? '取消点赞' : '点赞'}
type='text'
size='small'
icon={comment.isPraised ? <LikeFilled /> : <LikeOutlined />}
loading={praisingId === comment.id}
onClick={() => onPraise(comment)}
>
{comment.isPraised ? '已赞' : '点赞'}
{comment.praiseCount ? ` ${comment.praiseCount}` : ''}
{comment.praiseCount || null}
</Button>
</Tooltip>
{comment.isMine ? (
<Popconfirm
title='删除这条讨论?'
......@@ -191,9 +224,17 @@ function CommentItem({
cancelText='取消'
onConfirm={() => onDelete(comment)}
>
<Button danger type='text' size='small' loading={deletingId === comment.id}>
删除
</Button>
<Tooltip title='删除'>
<Button
danger
aria-label='删除讨论'
className='reader-row-action'
type='text'
size='small'
icon={<DeleteOutlined />}
loading={deletingId === comment.id}
/>
</Tooltip>
</Popconfirm>
) : null}
</Space>
......@@ -210,7 +251,7 @@ function CommentItem({
nested
/>
))}
</Card>
</article>
)
}
......@@ -219,12 +260,15 @@ export function ReaderSidePanel({
chapterId,
panel,
selectedQuote,
selectedTextAction,
onTextActionHandled,
onPanelChange,
onCollapse,
}: ReaderSidePanelProps) {
const queryClient = useQueryClient()
const [messageApi, contextHolder] = message.useMessage()
const notesQuery = useQuery(readerNotesOptions(bookId, chapterId, panel === 'notes'))
const commentsQuery = useQuery(readerCommentsOptions(bookId, chapterId, panel === 'discussion'))
const notesQuery = useQuery(readerNotesOptions(bookId, chapterId))
const commentsQuery = useQuery(readerCommentsOptions(bookId, chapterId))
const [noteForm] = Form.useForm<NoteFormValues>()
const [commentForm] = Form.useForm<CommentFormValues>()
const [noteModalOpen, setNoteModalOpen] = useState(false)
......@@ -238,11 +282,12 @@ export function ReaderSidePanel({
queryClient.invalidateQueries({ queryKey: readerKeys.comments(bookId, chapterId) })
const createNoteMutation = useMutation({
mutationFn: (values: CreateNoteValues) => {
if (!activeQuote) throw new Error('请先在章节正文中选择一段文字')
const quote = values.quote ?? activeQuote
if (!quote) throw new Error('请先在章节正文中选择一段文字')
return addNotes(bookId, chapterId, {
content: activeQuote.exact,
content: quote.exact,
noteContent: values.noteContent,
positioning: activeQuote.positioning,
positioning: quote.positioning,
isOpen: values.isOpen,
type: values.type,
color: values.type === 2 ? '#FFF08A' : '#FF0000',
......@@ -280,11 +325,13 @@ export function ReaderSidePanel({
},
})
const addCommentMutation = useMutation({
mutationFn: (values: CommentFormValues) => addComment(bookId, chapterId, values.content),
mutationFn: (values: CommentFormValues) =>
addComment(bookId, chapterId, values.content, activeQuote?.exact ?? ''),
onSuccess: async () => {
await invalidateComments()
messageApi.success('讨论已发表')
setCommentModalOpen(false)
setActiveQuote(null)
commentForm.resetFields()
},
})
......@@ -300,14 +347,28 @@ export function ReaderSidePanel({
messageApi.success('讨论已删除')
},
})
const createNote = createNoteMutation.mutate
useEffect(() => {
if (!selectedQuote || panel !== 'notes') return
if (!selectedQuote || !selectedTextAction) return
setEditingNote(null)
setActiveQuote(selectedQuote)
if (selectedTextAction === 'discussion') {
commentForm.resetFields()
setCommentModalOpen(true)
} else if (selectedTextAction === 'note') {
noteForm.setFieldsValue({ noteContent: '', isOpen: false })
setNoteModalOpen(true)
}, [noteForm, panel, selectedQuote])
} else if (selectedTextAction === 'underline' || selectedTextAction === 'highlight') {
createNote({
type: selectedTextAction === 'underline' ? 1 : 2,
noteContent: '',
isOpen: false,
quote: selectedQuote,
})
}
onTextActionHandled()
}, [commentForm, createNote, noteForm, onTextActionHandled, selectedQuote, selectedTextAction])
const openEdit = (note: ReaderNote) => {
setActiveQuote(null)
......@@ -318,31 +379,50 @@ export function ReaderSidePanel({
const submitNote = async () => {
const values = await noteForm.validateFields()
if (editingNote) editNoteMutation.mutate(values)
else createNoteMutation.mutate({ ...values, type: 3 })
}
const saveTextMark = (type: 1 | 2) => {
if (!activeQuote) return
createNoteMutation.mutate({ type, noteContent: '', isOpen: false })
else createNote({ ...values, type: 3 })
}
return (
<section className='reader-side-panel' aria-label='阅读互动面板'>
{contextHolder}
<header className='reader-side-panel__header'>
<Segmented
block
options={[
{ label: '笔记', value: 'notes' },
{ label: '讨论', value: 'discussion' },
{
label: (
<Space size={5}>
<FileTextOutlined />
笔记
</Space>
),
value: 'notes',
},
{
label: (
<Space size={5}>
<CommentOutlined />
讨论
</Space>
),
value: 'discussion',
},
]}
value={panel}
onChange={(value) => onPanelChange(value as ReaderQueryState['panel'])}
onChange={(value) => onPanelChange(value as ReaderPanel)}
/>
<Tooltip title='收起笔记与讨论'>
<Button
aria-label='收起笔记与讨论'
className='reader-view__side-toggle'
icon={<MenuFoldOutlined />}
type='text'
onClick={onCollapse}
/>
</Tooltip>
</header>
<div className='reader-side-panel__content'>
{panel === 'notes' ? (
<>
<Typography.Title className='reader-panel__heading' level={4}>
笔记
</Typography.Title>
{notesQuery.isPending ? <Skeleton active paragraph={{ rows: 7 }} /> : null}
{notesQuery.isError ? (
<Alert
......@@ -357,7 +437,9 @@ export function ReaderSidePanel({
notes={notesQuery.data}
onEdit={openEdit}
onDelete={(note) => deleteNoteMutation.mutate(note)}
deletingId={deleteNoteMutation.variables?.id}
deletingId={
deleteNoteMutation.isPending ? deleteNoteMutation.variables?.id : undefined
}
/>
) : null}
<MutationError
......@@ -366,12 +448,21 @@ export function ReaderSidePanel({
</>
) : (
<>
<Space className='reader-panel__heading' align='center'>
<Typography.Title level={4}>章节讨论</Typography.Title>
<Button type='primary' size='small' onClick={() => setCommentModalOpen(true)}>
发表讨论
</Button>
</Space>
<div className='reader-panel__actions'>
<Tooltip title='发表讨论'>
<Button
aria-label='发表讨论'
type='primary'
size='small'
icon={<PlusOutlined />}
onClick={() => {
setActiveQuote(null)
commentForm.resetFields()
setCommentModalOpen(true)
}}
/>
</Tooltip>
</div>
{commentsQuery.isPending ? <Skeleton active avatar paragraph={{ rows: 8 }} /> : null}
{commentsQuery.isError ? (
<Alert
......@@ -390,8 +481,10 @@ export function ReaderSidePanel({
comment={comment}
onPraise={(comment) => praiseMutation.mutate(comment)}
onDelete={(comment) => deleteCommentMutation.mutate(comment)}
praisingId={praiseMutation.variables?.id}
deletingId={deleteCommentMutation.variables?.id}
praisingId={praiseMutation.isPending ? praiseMutation.variables?.id : undefined}
deletingId={
deleteCommentMutation.isPending ? deleteCommentMutation.variables?.id : undefined
}
/>
))}
<MutationError
......@@ -422,26 +515,6 @@ export function ReaderSidePanel({
>
取消
</Button>,
!editingNote ? (
<Button
key='underline'
disabled={!activeQuote}
loading={createNoteMutation.isPending}
onClick={() => saveTextMark(1)}
>
划线
</Button>
) : null,
!editingNote ? (
<Button
key='highlight'
disabled={!activeQuote}
loading={createNoteMutation.isPending}
onClick={() => saveTextMark(2)}
>
高亮
</Button>
) : null,
<Button
key='save'
type='primary'
......@@ -473,9 +546,13 @@ export function ReaderSidePanel({
</Form>
</Modal>
<Modal
title='发表章节讨论'
title={activeQuote ? '发表选文讨论' : '发表章节讨论'}
open={commentModalOpen}
onCancel={() => setCommentModalOpen(false)}
onCancel={() => {
setCommentModalOpen(false)
setActiveQuote(null)
commentForm.resetFields()
}}
onOk={() =>
void commentForm.validateFields().then((values) => addCommentMutation.mutate(values))
}
......@@ -484,6 +561,15 @@ export function ReaderSidePanel({
cancelText='取消'
>
<Form form={commentForm} layout='vertical'>
{activeQuote ? (
<Form.Item label='摘录'>
<Input.TextArea
value={activeQuote.exact}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
) : null}
<Form.Item
name='content'
rules={[{ required: true, whitespace: true, message: '请输入讨论内容' }]}
......
export function sanitizeReaderHtml(content: string) {
const document = new DOMParser().parseFromString(content, 'text/html')
document
.querySelectorAll('script, iframe, object, embed, base, form, meta, style, link')
.forEach((element) => element.remove())
document.querySelectorAll('*').forEach((element) => {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase()
const value = attribute.value.trim().toLowerCase()
if (
name.startsWith('on') ||
(['href', 'src', 'xlink:href'].includes(name) && value.startsWith('javascript:'))
) {
element.removeAttribute(attribute.name)
}
}
})
return document.body.innerHTML
}
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
const panels = ['notes', 'discussion'] as const
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
export const readerSearchParams = {
anchor: parseAsString.withDefault('0'),
panel: parseAsStringLiteral(panels).withDefault('notes'),
catalogueCollapsed: parseAsBoolean.withDefault(false),
panelCollapsed: parseAsBoolean.withDefault(false),
}
export interface ReaderQueryState {
anchor: string
panel: (typeof panels)[number]
catalogueCollapsed: boolean
panelCollapsed: boolean
}
......@@ -25,7 +21,6 @@ export function useReaderQueryState() {
return {
state,
setAnchor: (anchor: string) => setState({ anchor }, { history: 'replace' }),
setPanel: (panel: ReaderQueryState['panel']) => setState({ panel }, { history: 'replace' }),
setCatalogueCollapsed: (catalogueCollapsed: boolean) =>
setState({ catalogueCollapsed }, { history: 'replace' }),
setPanelCollapsed: (panelCollapsed: boolean) =>
......
import { describe, expect, it } from 'vitest'
import { getReaderInteraction } from './reader-interaction'
function target(html: string, selector: string) {
document.body.innerHTML = html
return document.querySelector(selector)!
}
describe('reader content interaction', () => {
it('reads bubble and practice positions from editor html', () => {
expect(
getReaderInteraction(
target(
'<span class="chapter-item-tooltip" data-random="tip-1" data-title="提示"><b>词语</b></span>',
'b',
),
),
).toMatchObject({ type: 'bubble', position: 'tip-1', title: '提示' })
expect(
getReaderInteraction(
target(
'<div class="chapter-practice" data-practiceNum="test-1" data-title="练习"><span>开始</span></div>',
'span',
),
),
).toMatchObject({ type: 'practice', position: 'test-1', title: '练习' })
})
it('uses the last chapter id for internal links', () => {
expect(
getReaderInteraction(
target(
'<span class="chapter-item-link" data-linktype="2" data-chapters="10,11">下一节</span>',
'span',
),
),
).toEqual({ type: 'chapter', chapterId: '11' })
})
it('opens gallery before treating its image as a plain image', () => {
expect(
getReaderInteraction(
target(
'<div class="chapter-gallery-container" data-random="gallery-1"><img src="/a.png"></div>',
'img',
),
),
).toMatchObject({ type: 'gallery', position: 'gallery-1' })
})
})
export type ReaderInteraction =
| {
type: 'bubble' | 'expand' | 'gallery' | 'practice'
position: string
title: string
fallback?: string
link?: string
rect?: DOMRect
}
| { type: 'chapter'; chapterId: string }
| { type: 'link'; url: string }
| { type: 'image'; src: string; title: string }
function closest(target: Element, selector: string) {
return target.closest<HTMLElement>(selector)
}
export function getReaderInteraction(target: Element): ReaderInteraction | null {
const bubble = closest(target, '.chapter-item-tooltip')
if (bubble) {
return {
type: 'bubble',
position: bubble.dataset.random ?? '',
title: bubble.dataset.title ?? '内容提示',
fallback: bubble.dataset.content,
link: bubble.dataset.link,
rect: bubble.getBoundingClientRect(),
}
}
const chapterLink = closest(target, '.chapter-item-link')
if (chapterLink) {
if (Number(chapterLink.dataset.linktype) === 2) {
const chapters = (chapterLink.dataset.chapters ?? '').split(',').filter(Boolean)
return chapters.length ? { type: 'chapter', chapterId: chapters.at(-1)! } : null
}
return chapterLink.dataset.link ? { type: 'link', url: chapterLink.dataset.link } : null
}
for (const [selector, type] of [
['.chapter-gallery-container', 'gallery'],
['.chapter-practice', 'practice'],
['.chapter-expand', 'expand'],
] as const) {
const element = closest(target, selector)
if (element) {
return {
type,
position: element.dataset.random ?? element.dataset.practicenum ?? '',
title: element.dataset.name ?? element.dataset.title ?? '',
fallback: type === 'gallery' ? element.dataset.gallerylist : undefined,
}
}
}
const image = closest(target, 'img')
if (image instanceof HTMLImageElement && image.src) {
return { type: 'image', src: image.src, title: image.alt || '图片展示' }
}
const link = target.closest('a')
return link instanceof HTMLAnchorElement && link.href ? { type: 'link', url: link.href } : null
}
export function openReaderLink(url: string) {
try {
const link = new URL(url, window.location.origin)
if (!['http:', 'https:', 'mailto:'].includes(link.protocol)) return
window.open(link.href, '_blank', 'noopener,noreferrer')
} catch {
// Ignore invalid links.
}
}
......@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
import { calculateReadingProgress, getScrollTopForReadingProgress } from './reading-progress'
const reader = {
iframeTop: 88,
iframeHeight: 5000,
contentTop: 88,
contentHeight: 5000,
viewportHeight: 900,
headerHeight: 68,
}
......
......@@ -5,18 +5,18 @@ export function normalizeReadingProgress(anchor: string | number | null | undefi
}
interface ReaderScrollGeometry {
iframeTop: number
iframeHeight: number
contentTop: number
contentHeight: number
viewportHeight: number
headerHeight: number
}
function scrollableHeight({
iframeHeight,
contentHeight,
viewportHeight,
headerHeight,
}: ReaderScrollGeometry): number {
return Math.max(0, iframeHeight - Math.max(0, viewportHeight - headerHeight))
return Math.max(0, contentHeight - Math.max(0, viewportHeight - headerHeight))
}
export function calculateReadingProgress({
......@@ -26,7 +26,7 @@ export function calculateReadingProgress({
const distance = scrollableHeight(geometry)
if (distance === 0) return 100
return normalizeReadingProgress(
((scrollTop + geometry.headerHeight - geometry.iframeTop) / distance) * 100,
((scrollTop + geometry.headerHeight - geometry.contentTop) / distance) * 100,
)
}
......@@ -38,6 +38,6 @@ export function getScrollTopForReadingProgress({
if (ratio === 0) return 0
return Math.max(
0,
geometry.iframeTop - geometry.headerHeight + scrollableHeight(geometry) * ratio,
geometry.contentTop - geometry.headerHeight + scrollableHeight(geometry) * ratio,
)
}
.reader-view {
--reader-border: #e5e6eb;
--reader-nav: #f7f8fa;
--reader-primary: var(--main-color, #aa1941);
min-width: 1024px;
min-height: 100vh;
background: #f4f0e9;
color: #2b2520;
background: #fff;
color: #1f2329;
}
.reader-view__header {
......@@ -10,15 +14,11 @@
z-index: 20;
top: 0;
box-sizing: border-box;
display: grid;
grid-template-columns: 180px minmax(0, 1fr) 180px;
display: flex;
align-items: center;
min-height: 84px;
padding: 10px 24px;
background: rgb(255 253 248 / 88%);
border-bottom: 1px solid #e7dfd2;
box-shadow: 0 4px 16px rgb(77 56 30 / 4%);
backdrop-filter: blur(18px) saturate(145%);
height: 64px;
background: #fff;
border-bottom: 1px solid var(--reader-border);
}
.reader-view__header .ant-typography {
......@@ -29,29 +29,47 @@
margin-top: 1px;
}
.reader-view__header-title {
display: flex;
height: 100%;
min-width: 0;
text-align: center;
flex-direction: column;
justify-content: center;
padding-right: 24px;
text-align: left;
}
.reader-view__header-title h4 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reader-view__header-left {
display: flex;
height: 100%;
align-items: center;
padding: 0 8px 0 16px;
}
.reader-view__back {
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
color: #646a73;
}
.reader-view__back:hover {
background: #f2f3f5 !important;
color: #1f2329 !important;
}
.reader-view__layout {
display: grid;
grid-template-columns: 280px minmax(520px, 1fr) 288px;
align-items: start;
gap: 20px;
padding: 20px 24px 48px;
background: transparent;
grid-template-columns: 260px minmax(520px, 1fr) 320px;
min-height: calc(100vh - 64px);
}
.reader-view__layout--catalogue-collapsed {
grid-template-columns: 48px minmax(520px, 1fr) 288px;
grid-template-columns: 48px minmax(520px, 1fr) 320px;
}
.reader-view__layout--panel-collapsed {
grid-template-columns: 280px minmax(520px, 1fr) 48px;
grid-template-columns: 260px minmax(520px, 1fr) 48px;
}
.reader-view__layout--catalogue-collapsed.reader-view__layout--panel-collapsed {
grid-template-columns: 48px minmax(520px, 1fr) 48px;
......@@ -61,30 +79,28 @@
.reader-view__panel {
position: fixed;
z-index: 10;
top: 104px;
top: 64px;
box-sizing: border-box;
height: calc(100vh - 124px);
height: calc(100vh - 64px);
overflow-y: auto;
overscroll-behavior: contain;
background: rgb(255 253 248 / 92%);
border: 1px solid #e7dfd2;
border-radius: 14px;
box-shadow:
0 10px 28px rgb(77 56 30 / 6%),
inset 0 1px 0 rgb(255 255 255 / 80%);
scrollbar-gutter: stable;
}
.reader-view__catalogue {
left: 24px;
width: 280px;
padding: 10px 14px 20px;
left: 0;
width: 260px;
padding: 12px 0 24px;
background: var(--reader-nav);
border-right: 1px solid var(--reader-border);
}
.reader-view__panel {
right: 24px;
width: 288px;
padding: 10px 0 20px;
right: 0;
width: 320px;
padding: 12px 0 24px;
background: #fff;
border-left: 1px solid var(--reader-border);
}
.reader-view__layout--catalogue-collapsed .reader-view__catalogue {
......@@ -110,7 +126,7 @@
}
.reader-view__catalogue .reader-view__side-header {
margin: 0 0 8px 6px;
margin: 0 14px 8px 16px;
}
.reader-view__panel .reader-view__side-header {
margin: 0 14px 8px;
......@@ -128,70 +144,152 @@
height: 32px;
padding: 0;
place-items: center;
border-radius: 9px;
color: #71665a;
border-radius: 6px;
color: #646a73;
transition:
transform 120ms ease-out,
background-color 160ms ease-out;
}
.reader-view__side-toggle:hover {
background: #f0ebe3 !important;
background: #e9eaee !important;
}
.reader-view__side-toggle:active {
transform: scale(0.94);
}
.reader-view__catalogue .ant-tree {
padding: 8px;
background: transparent;
}
.reader-view__catalogue .ant-tree-node-content-wrapper {
min-height: 32px;
line-height: 32px;
.reader-view__catalogue .ant-tree-switcher-noop {
width: 0;
}
.reader-view__body {
grid-column: 2;
position: relative;
min-width: 0;
padding: 4px 0;
background: transparent;
padding: 0 48px 64px;
background: #fff;
}
.reader-side-panel {
min-height: 100%;
padding: 8px 20px 20px;
padding: 8px 20px 24px;
}
.reader-side-panel__content {
padding: 20px 0;
.reader-side-panel__header {
display: flex;
align-items: center;
gap: 8px;
}
.reader-side-panel__content h4 {
margin-top: 0;
.reader-side-panel__header .ant-segmented {
min-width: 0;
flex: 1;
padding: 0;
background: transparent;
border-bottom: 1px solid var(--reader-border);
border-radius: 0;
}
.reader-panel__heading {
margin: 0 0 12px;
.reader-side-panel__header .ant-segmented-item {
border-radius: 0;
}
.reader-note-item {
align-items: flex-start;
.reader-side-panel__header .ant-segmented-item:hover {
color: var(--reader-primary);
}
.reader-note-item .ant-list-item-meta {
min-width: 0;
.reader-side-panel__header .ant-segmented-item-selected {
color: var(--reader-primary);
background: color-mix(in srgb, var(--reader-primary) 8%, #fff);
box-shadow: inset 0 -2px 0 var(--reader-primary);
}
.reader-side-panel__content {
padding: 18px 0;
}
.reader-panel__actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.reader-note-item .ant-list-item-action {
margin-inline-start: 8px;
.reader-note-list {
border: 0;
}
.reader-note-list .reader-note {
padding: 0 0 12px;
border: 0;
}
.reader-note__body {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 12px;
background: #fff;
border: 1px solid var(--reader-border);
border-radius: 8px;
transition: border-color 120ms ease-out;
}
.reader-note__actions {
position: absolute;
z-index: 1;
top: 8px;
right: 8px;
}
.reader-note .reader-panel__quote {
padding-right: 52px;
}
.reader-note__visibility {
display: block;
margin-top: 8px;
}
.reader-note__body:hover,
.reader-note__body:focus-within,
.reader-comment:hover,
.reader-comment:focus-within {
border-color: color-mix(in srgb, var(--reader-primary) 42%, var(--reader-border));
}
.reader-row-action {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
}
.reader-note:hover .reader-row-action,
.reader-note:focus-within .reader-row-action,
.reader-comment:hover .reader-row-action,
.reader-comment:focus-within .reader-row-action {
opacity: 1;
pointer-events: auto;
}
.reader-panel__quote {
margin-bottom: 6px !important;
color: #71665a;
color: #646a73;
}
.reader-panel__quote-text {
padding: 0;
border-radius: 2px;
color: inherit;
}
.reader-panel__quote-text--highlight {
background: #fff08a;
}
.reader-panel__quote-text--underline {
border-bottom: 2px solid #aa1941;
}
.reader-panel__quote-text--note {
background: color-mix(in srgb, var(--main-color) 18%, transparent);
border-bottom: 2px solid var(--main-color);
}
.reader-comment {
margin-bottom: 10px;
border-color: #e7dfd2;
background: #fffefa;
box-sizing: border-box;
margin-bottom: 12px;
padding: 12px;
background: #fff;
border: 1px solid var(--reader-border);
border-radius: 8px;
transition: border-color 120ms ease-out;
}
.reader-comment--reply {
margin: 10px 0 0 28px;
background: #faf7f1;
padding: 10px;
background: #fafafa;
}
.reader-comment__body {
min-width: 0;
......@@ -200,32 +298,213 @@
.reader-comment__body .ant-typography {
margin-bottom: 6px;
}
.reader-comment__title {
display: block;
margin-top: 6px;
}
.reader-content {
display: grid;
gap: 14px;
width: min(860px, 100%);
width: min(900px, 100%);
margin: 0 auto;
}
.reader-content iframe {
.reader-content__chapter {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 720px;
background: #fffdf8;
border: 1px solid #e7dfd2;
border-radius: 12px;
box-shadow: 0 14px 40px rgb(77 56 30 / 8%);
padding: 48px 20px 96px;
overflow-wrap: anywhere;
background: #fff;
color: #1f2329;
font-family: Georgia, 'Noto Serif SC', 'Songti SC', serif;
font-size: 18px;
line-height: 2;
}
.reader-content__chapter img,
.reader-content__chapter video {
max-width: 100%;
height: auto;
}
.reader-content__chapter audio {
max-width: 100%;
}
.reader-content__chapter table {
display: block;
max-width: 100%;
overflow-x: auto;
}
.reader-content__chapter pre {
overflow-x: auto;
padding: 16px;
background: #f5f0e7;
}
.reader-content__chapter a {
color: var(--reader-primary);
}
.reader-content__chapter mark.reader-annotation {
padding: 0;
background: transparent;
border-radius: 2px;
color: inherit;
cursor: pointer;
}
.reader-content__chapter mark.reader-annotation--highlight {
background: var(--annotation-highlight-color, #fff08a);
}
.reader-content__chapter mark.reader-annotation--underline {
border-bottom: 2px solid var(--annotation-underline-color, #aa1941);
}
.reader-content__chapter mark.reader-annotation--note {
background: color-mix(in srgb, var(--annotation-note-color, #aa1941) 18%, transparent);
border-bottom: 2px solid var(--annotation-note-color, #aa1941);
}
.reader-content__chapter .chapter-item-tooltip,
.reader-content__chapter .chapter-item-link,
.reader-content__chapter .chapter-gallery-container,
.reader-content__chapter .chapter-practice,
.reader-content__chapter .chapter-expand {
cursor: pointer;
}
.reader-content__chapter .tooltip-icon svg,
.reader-content__chapter .chapter-item-link .svg-icon-span svg,
.reader-content__chapter .chapter-practice-title svg,
.reader-content__chapter .chapter-practice > div:first-child svg,
.reader-content__chapter .chapter-expand .expand-content svg,
.reader-content__chapter .chapter-gallery-inline > img {
display: none;
}
.reader-content__chapter .tooltip-icon::before {
content: 'ⓘ';
}
.reader-content__chapter .chapter-item-link .svg-icon-span::after {
content: ' ↗';
}
.reader-content__chapter .chapter-practice-title::after {
align-self: center;
padding: 0 14px;
content: '›';
font-size: 28px;
}
.reader-content__chapter .chapter-practice > div:first-child > div::before {
color: #fff;
content: '✎';
font-size: 24px;
}
.reader-content__chapter .chapter-expand .expand-content > div:last-child::after {
content: '↗';
}
.reader-content__chapter .chapter-gallery-inline::before {
content: '▧';
}
.reader-selection-toolbar {
position: fixed;
z-index: 1200;
display: flex;
padding: 4px;
background: #2b2520;
border-radius: 7px;
box-shadow: 0 6px 20px rgb(0 0 0 / 22%);
}
.reader-selection-toolbar .ant-btn {
padding-inline: 9px;
color: #fff;
}
.reader-selection-toolbar .ant-btn:hover {
color: #fff !important;
background: rgb(255 255 255 / 16%) !important;
}
.reader-bubble {
position: fixed;
z-index: 1100;
width: min(360px, calc(100vw - 24px));
max-height: 200px;
overflow: auto;
box-shadow: 0 8px 30px rgb(0 0 0 / 18%);
}
.reader-rich-dialog {
max-height: 65vh;
overflow: auto;
line-height: 1.8;
}
.reader-rich-dialog img,
.reader-rich-dialog video {
max-width: 100%;
height: auto;
}
.reader-rich-dialog audio {
max-width: 100%;
}
.reader-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
max-height: 65vh;
overflow: auto;
}
.reader-gallery figure {
margin: 0;
}
.reader-gallery .ant-image,
.reader-gallery img {
width: 100%;
}
.reader-gallery figcaption {
padding-top: 8px;
}
.reader-gallery figcaption .ant-typography {
margin-bottom: 0;
}
.reader-preview-image {
display: block;
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.reader-dialog-hidden-image {
display: none;
}
.reader-practice {
width: 100%;
max-height: 65vh;
overflow: auto;
}
.reader-practice > .ant-space-item {
width: 100%;
}
.reader-practice__question .ant-typography {
margin-bottom: 12px;
}
.reader-practice__question .ant-radio-group,
.reader-practice__question .ant-checkbox-group {
display: grid;
gap: 10px;
}
.reader-practice__question .ant-radio-wrapper,
.reader-practice__question .ant-checkbox-wrapper {
align-items: flex-start;
}
.reader-practice__question .ant-radio-label,
.reader-practice__question .ant-checkbox-label {
min-width: 0;
}
.reader-practice__question .ant-radio-label p,
.reader-practice__question .ant-checkbox-label p {
margin: 0;
}
.reader-practice__question .ant-radio-label img,
.reader-practice__question .ant-checkbox-label img {
max-width: 100%;
height: auto;
}
.reader-practice__result {
display: grid;
gap: 10px;
margin-top: 14px;
}
.reader-content__state {
padding: 48px 20px;
background: #fffdf8;
border-radius: 12px;
background: #fff;
}
.reader-content__pager {
display: flex;
......@@ -239,28 +518,37 @@
@media (max-width: 1240px) {
.reader-view__layout {
grid-template-columns: 240px minmax(520px, 1fr) 256px;
gap: 16px;
padding-inline: 16px;
grid-template-columns: 240px minmax(520px, 1fr) 280px;
}
.reader-view__layout--catalogue-collapsed {
grid-template-columns: 48px minmax(520px, 1fr) 256px;
grid-template-columns: 48px minmax(520px, 1fr) 280px;
}
.reader-view__layout--panel-collapsed {
grid-template-columns: 240px minmax(520px, 1fr) 48px;
}
.reader-view__catalogue {
left: 16px;
width: 240px;
}
.reader-view__panel {
right: 16px;
width: 256px;
width: 280px;
}
.reader-view__body {
padding-inline: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.reader-view__side-toggle {
.reader-view__side-toggle,
.reader-note__body,
.reader-comment,
.reader-row-action {
transition: none;
}
}
@media (hover: none) {
.reader-row-action {
opacity: 1;
pointer-events: auto;
}
}
import { useMutation, useQuery } from '@tanstack/react-query'
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'
import { LeftOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'
import { Alert, Button, Skeleton, Tooltip, Typography } from 'antd'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
......@@ -12,8 +12,12 @@ import {
} from '../api'
import { getAdjacentChapters } from '../chapter-navigation'
import { ReaderChapterTree } from '../components/ReaderChapterTree'
import { ReaderContent, type ReaderTextQuote } from '../components/ReaderContent'
import { ReaderSidePanel } from '../components/ReaderSidePanel'
import {
ReaderContent,
type ReaderTextAction,
type ReaderTextQuote,
} from '../components/ReaderContent'
import { ReaderSidePanel, type ReaderPanel } from '../components/ReaderSidePanel'
import { readerChaptersOptions, readerNotesOptions, readerSectionOptions } from '../query'
import { useReaderQueryState } from '../query-state'
import './ReaderView.css'
......@@ -34,7 +38,6 @@ export function ReaderView() {
const {
state: readerState,
setAnchor,
setPanel,
setCatalogueCollapsed,
setPanelCollapsed,
} = useReaderQueryState()
......@@ -44,6 +47,8 @@ export function ReaderView() {
const lastProgressAt = useRef(0)
const sessionOpen = useRef(false)
const [selectedQuote, setSelectedQuote] = useState<ReaderTextQuote | null>(null)
const [selectedTextAction, setSelectedTextAction] = useState<ReaderTextAction | null>(null)
const [panel, setPanel] = useState<ReaderPanel>('notes')
const progressMutation = useMutation({
mutationFn: ({ progress }: { progress: number }) => addProgress(bookId, chapterId, progress),
......@@ -72,36 +77,46 @@ export function ReaderView() {
lastProgressAt.current = 0
window.scrollTo({ top: 0, behavior: 'auto' })
const params = new URLSearchParams()
if (readerState.panel === 'discussion') params.set('panel', 'discussion')
if (readerState.catalogueCollapsed) params.set('catalogueCollapsed', 'true')
if (readerState.panelCollapsed) params.set('panelCollapsed', 'true')
const search = params.size ? `?${params.toString()}` : ''
navigate({ pathname: `/reader/${bookId}/${nextChapterId}`, search })
},
[
bookId,
chapterId,
navigate,
readerState.catalogueCollapsed,
readerState.panel,
readerState.panelCollapsed,
],
[bookId, chapterId, navigate, readerState.catalogueCollapsed, readerState.panelCollapsed],
)
useEffect(() => setSelectedQuote(null), [chapterId])
useEffect(() => {
setSelectedQuote(null)
setSelectedTextAction(null)
}, [chapterId])
useEffect(() => {
if (!bookId || !chapterId) return
reportReadRecordMutation()
}, [bookId, chapterId, reportReadRecordMutation])
const handleTextQuoteSelected = useCallback(
(quote: ReaderTextQuote) => {
const handleTextQuoteAction = useCallback(
(action: ReaderTextAction, quote: ReaderTextQuote) => {
if (action === 'encyclopedia' || action === 'dictionary') {
const url =
action === 'encyclopedia'
? `https://baike.baidu.com/item/${encodeURIComponent(quote.exact)}?fromModule=lemma_search-box`
: `https://dict.baidu.com/s?wd=${encodeURIComponent(quote.exact)}`
window.open(url, '_blank', 'noopener,noreferrer')
return
}
setSelectedQuote(quote)
void setPanel('notes')
setSelectedTextAction(action)
setPanel(action === 'discussion' ? 'discussion' : 'notes')
void setPanelCollapsed(false)
},
[setPanel],
[setPanel, setPanelCollapsed],
)
const handleTextActionHandled = useCallback(() => {
setSelectedQuote(null)
setSelectedTextAction(null)
}, [])
const handleAnnotationSelect = useCallback(() => setPanel('notes'), [])
const reportReadTime = useCallback(
(isOpen: boolean) => {
......@@ -149,12 +164,17 @@ export function ReaderView() {
<main className='reader-view'>
<header className='reader-view__header'>
<div className='reader-view__header-left'>
<Button type='text' onClick={() => navigate(`/book/${bookId}`)}>
← 返回图书
</Button>
<Tooltip title='返回书籍'>
<Button
aria-label='返回书籍'
className='reader-view__back'
icon={<LeftOutlined />}
type='text'
onClick={() => navigate(`/book/${bookId}`)}
/>
</Tooltip>
</div>
<div className='reader-view__header-title'>
<Typography.Text type='secondary'>正在阅读</Typography.Text>
<Typography.Title level={4}>{chapterName}</Typography.Title>
</div>
</header>
......@@ -201,6 +221,7 @@ export function ReaderView() {
</aside>
<section className='reader-view__body'>
<ReaderContent
bookId={bookId}
chapterId={chapterId}
chapterName={chapterName}
section={sectionQuery.data}
......@@ -214,9 +235,10 @@ export function ReaderView() {
hasNextChapter={Boolean(adjacentChapters.next)}
onPreviousChapter={() => changeChapter(adjacentChapters.previous?.id ?? '')}
onNextChapter={() => changeChapter(adjacentChapters.next?.id ?? '')}
onChapterOpen={changeChapter}
onUnlock={() => navigate(`/book/${bookId}`)}
onTextQuoteSelected={handleTextQuoteSelected}
onAnnotationSelect={() => void setPanel('notes')}
onTextQuoteAction={handleTextQuoteAction}
onAnnotationSelect={handleAnnotationSelect}
/>
{progressMutation.error || readRecordMutation.error || readTimeMutation.error ? (
<Alert
......@@ -229,26 +251,30 @@ export function ReaderView() {
) : null}
</section>
<aside className='reader-view__panel' aria-label='笔记与讨论'>
{readerState.panelCollapsed ? (
<div className='reader-view__side-header reader-view__side-header--panel'>
<Tooltip title={readerState.panelCollapsed ? '展开笔记与讨论' : '收起笔记与讨论'}>
<Tooltip title='展开笔记与讨论'>
<Button
aria-label={readerState.panelCollapsed ? '展开笔记与讨论' : '收起笔记与讨论'}
aria-label='展开笔记与讨论'
className='reader-view__side-toggle'
icon={readerState.panelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
icon={<MenuUnfoldOutlined />}
type='text'
onClick={() => void setPanelCollapsed(!readerState.panelCollapsed)}
onClick={() => void setPanelCollapsed(false)}
/>
</Tooltip>
</div>
{!readerState.panelCollapsed ? (
) : (
<ReaderSidePanel
bookId={bookId}
chapterId={chapterId}
panel={readerState.panel}
panel={panel}
selectedQuote={selectedQuote}
selectedTextAction={selectedTextAction}
onTextActionHandled={handleTextActionHandled}
onPanelChange={setPanel}
onCollapse={() => void setPanelCollapsed(true)}
/>
) : null}
)}
</aside>
</section>
</main>
......
......@@ -2,7 +2,6 @@
"name": "center-book-workspace",
"private": true,
"version": "1.0.0",
"packageManager": "pnpm@11.17.0",
"scripts": {
"dev": "pnpm --parallel --filter @ebook/admin --filter @ebook/learning dev",
"dev:admin": "pnpm --filter @ebook/admin dev",
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论