feat: chapter function
This commit is contained in:
parent
e46a110720
commit
dbfe057292
199
app/page.tsx
199
app/page.tsx
|
@ -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 */}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -21,7 +21,8 @@
|
|||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"downlevelIteration":true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
|
Loading…
Reference in New Issue