支持插入新章节

This commit is contained in:
2026-03-04 21:56:48 +08:00
parent e898f898f5
commit 39a7702dfe
8 changed files with 338 additions and 34 deletions

View File

@@ -420,13 +420,8 @@ export class IndexedDb implements Db {
}
async getQdNewChapterId(): Promise<number> {
const smallest_id = await get_data_with_convert<CompressedQdChapterInfo | QdChapterInfo, number>(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;
}

View File

@@ -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;
}

View File

@@ -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<QdChapterInfo | null>(null);
const [err, setErr] = useState<string | null>(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 && <Result title="加载失败" status="error" subTitle={err} />}
{!err && !chapter && <Skeleton active />}
{chapter && !err && <div className={styles.c}>
<div className={styles.top}>
<Input placeholder="章节名称" value={chapterName} onChange={(e) => setChapterName(e.target.value)} className={styles.name} />
<div className={styles.actions}>
<Tooltip title="设置章节位置" placement="left"><Icon><ViewListOutlined fill="currentColor" className={locClass} onClick={() => setOpenChapterList(true)} /></Icon></Tooltip>
<Tooltip title="保存新章节" placement="left"><Icon><SaveOutlined fill="currentColor" className={saveClass} onClick={saveDisabled || isSaving ? undefined : () => handleSave()} /></Icon></Tooltip>
</div>
<Modal title="选择章节位置" open={openChapterList} onCancel={() => setOpenChapterList(false)} footer={null} width={{
sm: 400,
md: 600,
lg: 800,
xl: 1000,
xxl: 1400,
xxxl: 1800,
}}>
<VolumesList bookId={bookInfo.id} volumes={vols} setLoc={setLoc} loc={loc} />
</Modal>
</div>
<NewChapterEditor content={content} onContentChanged={setContent} className={styles.editor} />
</div>}
</>);
}

View File

@@ -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<NewChapterEditorProps, State> {
ref;
#editorContainerRef = createRef<HTMLDivElement>();
#resizeObserver?: ResizeObserver;
constructor(props: NewChapterEditorProps) {
super(props);
this.ref = createRef<MonacoEditorHandle>();
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 (
<div ref={this.#editorContainerRef} className={this.props.className}>
<MonacoEditor
ref={this.ref}
value={this.props.content}
language="plaintext"
width="100%"
height={this.state.height || '100%'}
onChange={(newValue) => this.props.onContentChanged(newValue)}
options={{
wordWrap: 'on',
}}
/>
</div>
);
}
}

View File

@@ -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<HTMLDivElement>(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<string[]>([]);
@@ -66,18 +71,37 @@ export default function VolumesList({ volumes, bookId, oneLine, current }: Volum
}
};
const hasLoc = loc !== undefined;
const items = useMemo<CollapseProps['items']>(() => volumes.map(v => {
const children = v.chapters.map(chapter => (
<Flex className={oneLine ? styles.chone : styles.ch} key={chapter.id} data-chapter-id={chapter.id}>
<Link to={`/qd/book/${bookId}/chapter/${chapter.id}`}>{chapter.name}</Link>
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 <Flex className={oneLine ? styles.chone : styles.ch} key={chapter.id} data-chapter-id={chapter.id} align="center">
{hasLoc && <input type="radio" id={id} checked={checked ?? false} onChange={() => setLoc && setLoc([preId, curId])} data-loc-id={`${preId},${curId}`} name={name} />}
{!hasLoc && <Link to={`/qd/book/${bookId}/chapter/${chapter.id}`}>{chapter.name}</Link>}
{hasLoc && <label htmlFor={id}>{chapter.name}</label>}
<Flex className={styles.action}>
{chapter.isSaved && <CheckCircleOutlined className={styles.saved} />}
<Tooltip title="在起点上查看(新标签页)">
{chapter.id >= 0 && <Tooltip title="在起点上查看(新标签页)">
<Icon><OpenInNewTab fill="currentColor" width="20" className={styles.open} onClick={() => open_in_qidian(bookId, chapter.id)} /></Icon>
</Tooltip>
</Tooltip>}
</Flex>
</Flex>
));
});
if (hasLoc) {
const id = generateId();
const curId = v.chapters[chCount - 1].id;
const checked = loc && loc[0] === curId && loc[1] === null;
children.push(
<Flex className={oneLine ? styles.chone : styles.ch} key={`loc-${v.id}`}>
<input type="radio" id={id} checked={checked ?? false} onChange={() => setLoc && setLoc([curId, null])} data-loc-id={`${curId},null`} name={name} />
<label htmlFor={id}></label>
</Flex>
);
}
return {
key: v.id,
label: v.name,
@@ -89,8 +113,22 @@ export default function VolumesList({ volumes, bookId, oneLine, current }: Volum
{children}
</Flex>
}
}), [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 (<div className={styles.c} ref={containerRef}>
{hasLoc && <Alert type="info" title={locTip ?? "新章节将插入到选中的位置之前"} />}
<div className={styles.cl}><Collapse
key={bookId}
activeKey={activeKeys}

View File

@@ -163,3 +163,10 @@ export function toHex(bytes: Uint8Array): string {
/**@ts-expect-error Detect ServiceWorkerGlobalScope */
export const isServiceWorker = typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope;
let idCounter = 0;
export function generateId(prefix: string = 'id'): string {
idCounter += 1;
return `${prefix}_${idCounter}`;
}

View File

@@ -111,6 +111,14 @@ export default function t() {
time: 11,
prev: 3,
},
{
primaryKey: 0,
id: 5,
name: 'Chapter 5',
bookId: 0,
time: 12,
prev: 4,
},
{
primaryKey: 0,
id: 33,
@@ -136,6 +144,7 @@ export default function t() {
{ id: 1, name: 'Chapter 1', isSaved: true },
{ id: 3, name: 'Chapter 3' },
{ id: 4, name: 'Chapter 4', isSaved: true },
{ id: 5, name: 'Chapter 5', isSaved: true },
]
}
]);
@@ -155,6 +164,7 @@ export default function t() {
chapters: [
{ id: 1, name: 'Chapter 1' },
{ id: 4, name: 'Chapter 4' },
{ id: 5, name: 'Chapter 5' },
],
},
]);

View File

@@ -36,6 +36,24 @@ export function hash_qdchapter_info(info: QdChapterInfo): string {
return toHex(hash);
}
enum MergeMode {
PreOnly,
NextOnly,
All,
AllWithNext,
AllWithPrev,
}
const SORT_MODE: MergeMode[] = [MergeMode.PreOnly, MergeMode.NextOnly, MergeMode.All, MergeMode.AllWithNext, MergeMode.AllWithPrev];
function NextMode(mode: MergeMode) {
const index = SORT_MODE.indexOf(mode);
if (index === -1) return null;
if (index === SORT_MODE.length - 1) return null;
return SORT_MODE[index + 1];
}
export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Volume[], keepMode: ChapterShowMode): Volume[] {
const vols: Volume[] = [];
if (keepMode == ChapterShowMode.All || keepMode == ChapterShowMode.SavedOnly) {
@@ -57,28 +75,16 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo
needed.push(ch);
} else {
chInfo.isSaved = true;
chInfo.name = ch.name;
}
}
let changed = false;
let merge_mode = SORT_MODE[0];
do {
const current_len = needed.length;
const not_found: QdChapterSimpleInfo[] = [];
for (const ch of needed) {
if (ch.prev) {
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,
});
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));