feat: chapter function

This commit is contained in:
周炽键 2025-07-15 18:17:48 +08:00
parent e46a110720
commit dbfe057292
6 changed files with 6829 additions and 99 deletions

View File

@ -4,36 +4,89 @@ import { Code } from "@nextui-org/code";
import { Icon } from "@iconify/react";
import {
Button,
Card,
CardBody,
CardHeader,
Chip,
Divider,
ScrollShadow,
Select,
SelectItem,
} from "@nextui-org/react";
import { cn } from "@nextui-org/theme";
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { title } from "@/components/primitives";
import { ChapterItem, Chapters, readFile, splitChapter } from "./utils/chapter";
import { ChapterCard } from "@/components/ChapterCard";
export default function Home() {
const [text,setText] = useState("")
const [chapters,setChapters] = useState<Chapters>([])
const [activeChapter,setActiveChapter] = useState<number|null>(null)
const textareaRef = useRef<HTMLTextAreaElement|null>(null)
const onSplit = useCallback(async ()=>{
const dom = textareaRef.current
if(!dom) return
const index = dom.selectionStart
if(index<0) return
console.log("bookmark",index)
const content = '====SPLIT CHAPTER===='
setText(txt=>{
return txt.substring(0,index)+'\n'+content+"\n"+txt.substring(15)
})
},[])
const onMergeNext = useCallback((chapter:ChapterItem)=>{
const current = chapters.findIndex(detail=>detail.key===chapter.key)
if(current==-1) return
const next = current+1
const nextChapter = chapters[next]
if(!nextChapter) return
chapter.text += ["",nextChapter.title,nextChapter.text].join("\n")
const newChapters = chapters.slice()
newChapters.splice(next,1)
const newText = newChapters.reduce<string>((txt,chapter)=>{
txt += chapter.rawTitle+'\n'
txt += chapter.text+"\n"
return txt
},"")
setText(newText)
},[chapters])
const selectFile = useCallback(()=>{
const input = document.createElement("input")
input.type = 'file'
input.accept = '.txt'
input.addEventListener("change",async function(event){
setActiveChapter(null)
const [file] = input.files||[]
if(!file) {
setText("")
return
}
console.time("read-txt")
setText(await readFile(file))
console.timeEnd("read-txt")
})
input.click()
},[])
useEffect(()=>{
console.time("split-chapter")
const chapters = splitChapter(text)
setChapters(chapters)
if(chapters.length>0) {
setActiveChapter(1)
}
console.timeEnd("split-chapter")
},[text])
return (
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="inline-block max-w-xl text-center justify-center">
<span className={title()}>Place your changes here</span>
</div>
<div className="mt-8 gap-16">
<Snippet hideCopyButton hideSymbol className="gap-4" variant="bordered">
<span>
Get started by editing <Code color="primary">app/page.tsx</Code>
</span>
<span>Please feel free to use the example components below.</span>
</Snippet>
</div>
<div className="pt-6 w-48">
{/* <div className="flex flex-row pt-6 w-48"> */}
<div className="flex flex-row pt-6 w-auto">
<Select
className="flex-auto"
items={[
{ key: "story-1", label: "story-1" },
{ key: "story-2", label: "story-2" },
@ -44,6 +97,8 @@ export default function Home() {
>
{(story) => <SelectItem key={story.key}>{story.label}</SelectItem>}
</Select>
<Button className="flex-auto w-auto" onPress={selectFile}>Select</Button>
</div>
<div className="pt-6">
@ -67,84 +122,28 @@ export default function Home() {
id="menu-scroll"
>
<div className="flex flex-col gap-4 py-3 pr-4">
<Card
key="card-1"
isPressable
className={`max-w-[384px] border-1 border-divider/15 bg-themeBlue/20`}
shadow="none"
>
<CardHeader className="flex items-center justify-between">
<div className="flex gap-1.5">
<Chip
className="mr-1 text-themeBlue bg-themeBlue/20"
radius="sm"
size="sm"
variant="flat"
>
Editing
</Chip>
<p className="text-left mr-1">
Chapter 1 - Chapter 1 title
</p>
</div>
</CardHeader>
<Divider />
<CardBody>
<p className="line-clamp-2">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip
</p>
</CardBody>
</Card>
<Card
key="card-2"
isPressable
className={`max-w-[384px] border-1 border-divider/15`}
shadow="none"
>
<CardHeader className="flex items-center justify-between">
<div className="flex gap-1.5">
<p className="text-left mr-1">
Chapter 2 - Chapter 2 title
</p>
</div>
</CardHeader>
<Divider />
<CardBody>
<p className="line-clamp-2">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip
</p>
</CardBody>
</Card>
<Card
key="card-3"
isPressable
className={`max-w-[384px] border-1 border-divider/15`}
shadow="none"
>
<CardHeader className="flex items-center justify-between">
<div className="flex gap-1.5">
<p className="text-left mr-1">
Chapter 3 - Chapter 3 title
</p>
</div>
</CardHeader>
<Divider />
<CardBody>
<p className="line-clamp-2">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip
</p>
</CardBody>
</Card>
{
chapters.map((chapter,index)=>{
return <ChapterCard
chapter={chapter}
active={activeChapter===chapter.number}
key={`chapter-${chapter.key}`}
onPress={async ()=>{
setActiveChapter(chapter.number)
if(!textareaRef.current) return
textareaRef.current.focus()
await new Promise<void>(resolve=>{
requestAnimationFrame(()=>{
resolve()
})
})
textareaRef.current.selectionStart = chapter.indexOf
textareaRef.current.selectionEnd = chapter.indexOf
}}
onMergeNext={index===chapters.length-1?undefined:onMergeNext}
/>
})
}
</div>
</ScrollShadow>
</div>
@ -197,8 +196,9 @@ export default function Home() {
/>
}
variant="flat"
onPress={onSplit}
>
button-2
Split
</Button>
</div>
<div>
@ -207,6 +207,11 @@ export default function Home() {
{/* Adjusted to use flex display for layout */}
<textarea
className="flex-1 p-3 resize-none rounded-md border border-transparent bg-slate-50 dark:bg-gray-200 text-gray-900" // Use flex-1 to allow the textarea to fill available space
value={text}
onChange={(event)=>{
setText(event.target.value)
}}
ref={textareaRef}
/>
<div className="bg-gray-100 p-1 rounded-md self-end ml-2">
{/* Added margin-left to separate from textarea, align-self to position at the bottom */}

145
app/utils/chapter.ts Normal file
View File

@ -0,0 +1,145 @@
export interface ChapterItem {
rawTitle:string
title:string
/**
* the chapter content
*/
text:string
/**
* the chapter number
*/
number:number
key:string
indexOf:number
}
export type Chapters = ChapterItem[]
function formatLines(text:string){
const lines = text.split('\n')
// .reduce<string[]>((result,line)=>{
// const childLines = line.split('\r\n').map(childLine=>{
// return childLine.split("\r")
// })
// return result.concat(
// ...childLines
// )
// },[])
return lines
}
export function splitChapter(text:string){
const lines = formatLines(text)
const chapters:Chapters = []
/**
* the chapters array index, not the chapter number
* like pointer
*/
let chapterIndex = -1
let offset = 0
for(let lineIndex=0;lineIndex<lines.length;lineIndex++) {
const lineText = lines[lineIndex]
offset += lineText.length
if(!lineText || !lineText.trim()) {
continue
}
const title = getChapterTitle(lineText)
if(title!==null){
++chapterIndex;
chapters[chapterIndex] = {
rawTitle:lineText,
title,
text:"",
number:chapterIndex+1,
key:generateKey(),
indexOf:Math.max(0,offset-1)
}
// lastLineOffset+=lineText.length
continue
}
if(chapterIndex===-1) {
chapterIndex = 0
chapters[chapterIndex] = {
title:"",
rawTitle:"",
text:lineText+"\n",
number:1,
key:generateKey(),
indexOf:Math.max(0,offset)
}
continue
}
const chapter = chapters[chapterIndex]
chapter.text += lineText
// chapter.indexOf = lastLineOffset+lineText.length
// lastLineOffset = chapter.indexOf
}
console.info("chapters",chapters)
return chapters
}
function getChapterTitle(text:string){
const matchTitleSpecialCharRegexp = /[^\s\da-z]/i
// example: Chapter 1
if(/^chapter\s*\d+/i.test(text)) {
return text.replace(/^chapter\s*\d+/i,"").replace(matchTitleSpecialCharRegexp,"").trim()
}
// example Chapter: title
// example Chapter 2: title
// example Chapter one: title
// example Chapter One: "title"
// example Chapter One: [title]
// example Chapter III: [title]
// example Chapter: [title]
if(/^chapter\s*.*[^\s\da-z]([\s\da-z]+)/i.test(text)) {
return text.replace(/^chapter\s*.*[^\s\da-z]([\s\da-z]+)/i,"$1").replace(matchTitleSpecialCharRegexp,"").trim()
}
if(
/^([-=+<>#\*])\1{2,}$/.test(text)
) {
return ""
}
if(/SPLIT\s*CHAPTER/.test(text)) return ""
if(/CHAPTER\s*SPLIT/.test(text)) return ""
return null
}
function randomString(count:number) {
const seeds = ['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z'];
return Array(count).fill("").map(()=>{
const index = Math.floor(Math.random() * 1000) % (seeds.length)
return seeds[index]
}).join("")
}
const generateKey = () => randomString(20)
export async function readFile(file:File){
const reader = new FileReader()
const promise = new Promise<string>((resolve,reject)=>{
reader.addEventListener("loadend",function(){
let txt = reader.result as unknown as string
txt = txt.replace('\r\n','\n').replace('\r','\n')
resolve(reader.result as unknown as string)
})
reader.addEventListener("error",reject)
})
reader.readAsText(file,"utf-8")
return promise
}

View File

@ -0,0 +1,76 @@
"use client";
import { ChapterItem } from "@/app/utils/chapter";
import React, { useMemo } from "react";
import { Card, CardHeader, Chip, Divider, CardBody, Button } from "@nextui-org/react"
import classNames from "classnames";
import { Icon } from "@iconify/react";
export interface ChapterCardProps {
chapter:ChapterItem
active:boolean
onPress?:()=>void
onMergeNext?:(chapter:ChapterItem)=>void
}
export const ChapterCard:React.FC<ChapterCardProps> = (props) => {
const chapterCardTitle = useMemo(()=>{
return [
`Chapter ${props.chapter.number}`,
props.chapter.title
].filter(item=>!!item).join(' - ')
},[props.chapter])
return (
<Card
isPressable
className={classNames('max-w-[384px] border-1 border-divider/15',{
"bg-themeBlue/20":props.active
})}
shadow="none"
onPress={props.onPress}
>
<CardHeader className="flex items-center justify-between">
<div className="flex gap-1.5">
{
props.active && (
<Chip
className="mr-1 text-themeBlue bg-themeBlue/20"
radius="sm"
size="sm"
variant="flat"
>
Editing
</Chip>
)
}
<p className="text-left mr-1">
{chapterCardTitle}
</p>
</div>
{
!!props.onMergeNext && (
<Icon
icon="material-symbols:cell-merge"
width={16}
onClick={()=>{
props.onMergeNext?.(props.chapter)
}}
/>
)
}
</CardHeader>
<Divider />
<CardBody>
<p className="line-clamp-2">
{
props.chapter.text.trim().slice(0,300)
}
</p>
</CardBody>
</Card>
)
}

View File

@ -3,7 +3,7 @@
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev --turbo -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx -c .eslintrc.json --fix"
@ -23,6 +23,7 @@
"@nextui-org/theme": "2.2.11",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
"classnames": "^2.5.1",
"clsx": "2.1.1",
"framer-motion": "~11.1.1",
"intl-messageformat": "^10.5.0",

View File

@ -21,7 +21,8 @@
],
"paths": {
"@/*": ["./*"]
}
},
"downlevelIteration":true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]

6502
yarn.lock Normal file

File diff suppressed because it is too large Load Diff