diff --git a/src/db/indexedDb.ts b/src/db/indexedDb.ts index 11c8acc..02a9bef 100644 --- a/src/db/indexedDb.ts +++ b/src/db/indexedDb.ts @@ -420,13 +420,8 @@ export class IndexedDb implements Db { } async getQdNewChapterId(): Promise { const smallest_id = await get_data_with_convert(this.qddb, 'chapters', undefined, async (key, data) => { - if ('compressed' in data) { - const decompressed = await decompress(data.compressed); - const decoded = new TextDecoder().decode(decompressed); - data = JSON.parse(decoded) as QdChapterInfo; - } return data.id; - }, 'id', 'prev'); + }, 'id', 'next'); if (smallest_id === undefined || smallest_id >= 0) { return -1; } diff --git a/src/manage/qd/BookNewChapter.module.css b/src/manage/qd/BookNewChapter.module.css new file mode 100644 index 0000000..6b1a3d0 --- /dev/null +++ b/src/manage/qd/BookNewChapter.module.css @@ -0,0 +1,67 @@ +.c { + display: flex; + flex-direction: column; + overflow: hidden; + height: calc(100vh - 40px); + max-height: calc(100vh - 40px); + align-items: stretch; + margin: 0; + padding: 0; +} + +.top { + display: flex; + align-items: center; +} + +.name { + flex: 3; +} + +.actions { + flex: 1; + display: flex; + justify-content: flex-end; +} + +.editor { + flex: 1; + height: 100%; +} + +.save { + color: gray; + cursor: pointer; + margin: 8px; +} + +.save:hover { + color: #4096ff; +} + +.save.disabled { + color: lightgray; + cursor: not-allowed; +} + +.save.disabled:hover { + color: lightgray; +} + +.loc { + color: gray; + cursor: pointer; + margin: 8px; +} + +.loc:hover { + color: #4096ff; +} + +.loc.unset { + color: red; +} + +.loc.unset:hover { + color: #4096ff; +} diff --git a/src/manage/qd/BookNewChapter.tsx b/src/manage/qd/BookNewChapter.tsx index d0f1049..6b692df 100644 --- a/src/manage/qd/BookNewChapter.tsx +++ b/src/manage/qd/BookNewChapter.tsx @@ -1,16 +1,31 @@ -import { useBookContext } from "./BookStatusProvider"; +import { loadChapterListsIfNeeded, useBookContext, useBookStatus } from "./BookStatusProvider"; import type { QdChapterInfo } from "../../types"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useDb } from "../dbProvider"; import { useBookInfo } from "./BookInfoProvider"; -import { Result, Skeleton } from "antd"; +import { Result, Skeleton, Input, Tooltip, Modal } from "antd"; +import NewChapterEditor from "./NewChapterEditor"; +import styles from "./BookNewChapter.module.css"; +import Icon from "../../components/Icon"; +import SaveOutlined from "../../../node_modules/@material-icons/svg/svg/save/outline.svg"; +import ViewListOutlined from "../../../node_modules/@material-icons/svg/svg/view_list/outline.svg"; +import { ChapterShowMode, get_new_volumes } from "../../utils/qd"; +import VolumesList from "./VolumesList"; +import { useNavigate } from "react-router"; export default function BookNewChapter() { const setItems = useBookContext(); const [chapter, setChapter] = useState(null); const [err, setErr] = useState(null); + const [content, setContent] = useState(''); + const [chapterName, setChapterName] = useState(''); + const [loc, setLoc] = useState<[number | null, number | null] | null>(null); const bookInfo = useBookInfo(); + const [bookStatus, setBookStatus] = useBookStatus(); const db = useDb(); + const [openChapterList, setOpenChapterList] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const navigate = useNavigate(); setItems([ { title: "新章节" }, ]) @@ -154,8 +169,73 @@ export default function BookNewChapter() { setErr(e instanceof Error ? e.message : String(e)); }); }, []); + useEffect(() => { + loadChapterListsIfNeeded(bookInfo.id, bookStatus, setBookStatus, db).catch(e => { + console.warn(e); + }); + }, [bookInfo.id]); + const vols = useMemo(() => { + let vols = bookInfo.volumes; + if (bookStatus.chapterLists) { + vols = get_new_volumes(bookStatus.chapterLists, bookInfo.volumes, ChapterShowMode.All); + if (vols.length > 0 && vols[0].id == 'vol_new') { + vols.splice(0, 1); + } + } + return vols; + }, [bookInfo.volumes, bookStatus.chapterLists]); + let saveClass = styles.save; + const saveDisabled = !chapterName.trim() || !content.trim() || !loc; + if (saveDisabled || isSaving) { + saveClass += ` ${styles.disabled}`; + } + let locClass = styles.loc; + if (!loc) { + locClass += ` ${styles.unset}`; + } + async function handleSave() { + if (saveDisabled) return; + if (!chapter) return; + if (!loc) return; + setIsSaving(true); + try { + chapter.contents = content.split('\n'); + chapter.chapterInfo.chapterName = chapterName; + chapter.chapterInfo.prev = loc[0] ?? undefined; + chapter.chapterInfo.next = loc[1] ?? undefined; + await db.saveQdChapter(chapter); + setBookStatus(prev => ({ + ...prev, + chapterLists: undefined, + })); + navigate(`/qd/book/${bookInfo.id}/chapter/${chapter.id}`); + } catch (e) { + console.warn(e); + setIsSaving(false); + } + } return (<> {err && } {!err && !chapter && } + {chapter && !err &&
+
+ setChapterName(e.target.value)} className={styles.name} /> +
+ setOpenChapterList(true)} /> + handleSave()} /> +
+ setOpenChapterList(false)} footer={null} width={{ + sm: 400, + md: 600, + lg: 800, + xl: 1000, + xxl: 1400, + xxxl: 1800, + }}> + + +
+ +
} ); } diff --git a/src/manage/qd/NewChapterEditor.tsx b/src/manage/qd/NewChapterEditor.tsx new file mode 100644 index 0000000..c8522c0 --- /dev/null +++ b/src/manage/qd/NewChapterEditor.tsx @@ -0,0 +1,60 @@ +import { Component, createRef } from "react"; +import MonacoEditor, { MonacoEditorHandle } from 'react-monaco-editor'; + +export interface NewChapterEditorProps { + content: string; + onContentChanged: (content: string) => void; + className?: string; +} + +interface State { + height: number; +} + +export default class NewChapterEditor extends Component { + ref; + #editorContainerRef = createRef(); + #resizeObserver?: ResizeObserver; + constructor(props: NewChapterEditorProps) { + super(props); + this.ref = createRef(); + this.state = { height: 0 }; + } + componentDidMount() { + if (this.#editorContainerRef.current) { + this.#resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + this.setState({ height: entry.contentRect.height }); + } + this.layout(); + }); + this.#resizeObserver.observe(this.#editorContainerRef.current); + // 初始化高度 + this.setState({ height: this.#editorContainerRef.current.clientHeight }); + } + } + componentWillUnmount() { + this.#resizeObserver?.disconnect(); + } + layout() { + this.ref.current?.editor.layout(); + } + render() { + return ( +
+ this.props.onContentChanged(newValue)} + options={{ + wordWrap: 'on', + }} + /> +
+ ); + } +} diff --git a/src/manage/qd/VolumesList.tsx b/src/manage/qd/VolumesList.tsx index eb94c6f..19d843f 100644 --- a/src/manage/qd/VolumesList.tsx +++ b/src/manage/qd/VolumesList.tsx @@ -1,18 +1,22 @@ -import { Collapse, CollapseProps, Flex, Tooltip } from "antd"; +import { Collapse, CollapseProps, Flex, Tooltip, Alert } from "antd"; import type { Volume } from "../../qdtypes"; import styles from './VolumesList.module.css'; import { Link } from "react-router"; import { CheckCircleOutlined } from "@ant-design/icons"; import OpenInNewTab from "../../../node_modules/@material-icons/svg/svg/open_in_new/twotone.svg"; import Icon from "../../components/Icon"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import LocationSearchingTwotone from "../../../node_modules/@material-icons/svg/svg/location_searching/twotone.svg"; +import { generateId } from "../../utils"; export type VolumesListProps = { volumes: Volume[]; bookId: number; oneLine?: boolean; current?: number; + loc?: [number | null, number | null] | null; + locTip?: string; + setLoc?: (loc: [number | null, number | null] | null) => void; } async function open_in_qidian(bookId: number, chapterId: number) { @@ -30,12 +34,13 @@ async function open_in_qidian(bookId: number, chapterId: number) { } } -export default function VolumesList({ volumes, bookId, oneLine, current }: VolumesListProps) { +export default function VolumesList({ volumes, bookId, oneLine, current, loc, locTip, setLoc }: VolumesListProps) { const containerRef = useRef(null); const currentVolumeId = useMemo(() => { if (!current) return null; return volumes.find(v => v.chapters.some(ch => ch.id === current))?.id ?? null; }, [volumes, current]); + const name = useState(() => generateId())[0]; const [activeKeys, setActiveKeys] = useState([]); @@ -66,18 +71,37 @@ export default function VolumesList({ volumes, bookId, oneLine, current }: Volum } }; + const hasLoc = loc !== undefined; const items = useMemo(() => volumes.map(v => { - const children = v.chapters.map(chapter => ( - - {chapter.name} + const chCount = v.chapters.length; + const children = v.chapters.map((chapter, index) => { + const id = hasLoc ? generateId() : undefined; + const curId = hasLoc ? chapter.id : null; + const preId = hasLoc && index > 0 ? v.chapters[index - 1].id : null; + const checked = hasLoc && loc && loc[0] === preId && loc[1] === curId; + return + {hasLoc && setLoc && setLoc([preId, curId])} data-loc-id={`${preId},${curId}`} name={name} />} + {!hasLoc && {chapter.name}} + {hasLoc && } {chapter.isSaved && } - + {chapter.id >= 0 && open_in_qidian(bookId, chapter.id)} /> - + } - )); + }); + if (hasLoc) { + const id = generateId(); + const curId = v.chapters[chCount - 1].id; + const checked = loc && loc[0] === curId && loc[1] === null; + children.push( + + setLoc && setLoc([curId, null])} data-loc-id={`${curId},null`} name={name} /> + + + ); + } return { key: v.id, label: v.name, @@ -89,8 +113,22 @@ export default function VolumesList({ volumes, bookId, oneLine, current }: Volum {children} } - }), [volumes, bookId, oneLine]); + }), [volumes, bookId, oneLine, hasLoc]); + useEffect(() => { + if (!loc) return; + const el = containerRef.current?.querySelector(`input[data-loc-id="${loc[0]},${loc[1]}"]`) as HTMLInputElement | null; + if (el) { + setTimeout(() => { + if (!el.checked) el.checked = true; + }, 1); + } else { + if (setLoc) { + setLoc(null); + } + } + }, [loc]); return (
+ {hasLoc && }
c.id === ch.prev); - if (chIndex !== -1) { - prevVol.chapters.splice(chIndex + 1, 0, { - id: ch.id, - name: ch.name, - isSaved: true, - }); - continue; - } - } - } - if (ch.next) { + if ((merge_mode === MergeMode.NextOnly && ch.next && !ch.prev) || (merge_mode === MergeMode.AllWithNext && ch.prev && ch.next)) { const nextVol = volMap.get(ch.next); if (nextVol) { const chIndex = nextVol.chapters.findIndex(c => c.id === ch.next); @@ -88,6 +94,39 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo name: ch.name, isSaved: true, }); + volMap.set(ch.id, nextVol); + continue; + } + } + } + if ((merge_mode === MergeMode.PreOnly && ch.prev && !ch.next) || (merge_mode === MergeMode.AllWithPrev && ch.prev && ch.next)) { + const prevVol = volMap.get(ch.prev); + if (prevVol) { + const chIndex = prevVol.chapters.findIndex(c => c.id === ch.prev); + if (chIndex !== -1) { + prevVol.chapters.splice(chIndex + 1, 0, { + id: ch.id, + name: ch.name, + isSaved: true, + }); + volMap.set(ch.id, prevVol); + continue; + } + } + } + if (merge_mode === MergeMode.All && ch.prev && ch.next) { + const prevVol = volMap.get(ch.prev); + const nextVol = volMap.get(ch.next); + if (prevVol && nextVol && prevVol === nextVol) { + const preIndex = prevVol.chapters.findIndex(c => c.id === ch.prev); + const nextIndex = prevVol.chapters.findIndex(c => c.id === ch.next); + if (preIndex !== -1 && nextIndex !== -1 && preIndex + 1 === nextIndex) { + prevVol.chapters.splice(preIndex + 1, 0, { + id: ch.id, + name: ch.name, + isSaved: true, + }); + volMap.set(ch.id, prevVol); continue; } } @@ -96,6 +135,13 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo } needed = not_found; changed = current_len !== needed.length; + if (!changed) { + const nextMode = NextMode(merge_mode); + if (nextMode) { + merge_mode = nextMode; + changed = true; + } + } } while (changed && needed.length > 0); for (const ch of needed) { volCh.push({ @@ -117,6 +163,7 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo vol.chapters = vol.chapters.filter(ch => ch.isSaved); vol.chapters.forEach(ch => delete ch.isSaved); } + return vols.filter(vol => vol.chapters.length > 0); } } else if (keepMode == ChapterShowMode.UnsavedOnly) { const chIds = new Set(chapterLists.map(ch => ch.id));