提交 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: [] }),
})
}
......
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,
)
}
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='笔记与讨论'>
<div className='reader-view__side-header reader-view__side-header--panel'>
<Tooltip title={readerState.panelCollapsed ? '展开笔记与讨论' : '收起笔记与讨论'}>
<Button
aria-label={readerState.panelCollapsed ? '展开笔记与讨论' : '收起笔记与讨论'}
className='reader-view__side-toggle'
icon={readerState.panelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
type='text'
onClick={() => void setPanelCollapsed(!readerState.panelCollapsed)}
/>
</Tooltip>
</div>
{!readerState.panelCollapsed ? (
{readerState.panelCollapsed ? (
<div className='reader-view__side-header reader-view__side-header--panel'>
<Tooltip title='展开笔记与讨论'>
<Button
aria-label='展开笔记与讨论'
className='reader-view__side-toggle'
icon={<MenuUnfoldOutlined />}
type='text'
onClick={() => void setPanelCollapsed(false)}
/>
</Tooltip>
</div>
) : (
<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 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论