feat: add reading time (#509)
All checks were successful
Build and Deploy / build-and-deploy (push) Has been skipped
Build and Push Docker Images / build (push) Has been skipped

This commit is contained in:
dribble-njr 2025-01-08 22:01:44 +08:00 committed by GitHub
parent fc1712f948
commit de2cc52e15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 155 additions and 67 deletions

10
package-lock.json generated
View File

@ -33,6 +33,7 @@
"pinia": "^2.2.7", "pinia": "^2.2.7",
"qiniu-js": "^3.4.2", "qiniu-js": "^3.4.2",
"radix-vue": "^1.9.10", "radix-vue": "^1.9.10",
"reading-time": "^1.5.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiny-oss": "^0.5.1", "tiny-oss": "^0.5.1",
@ -12496,9 +12497,10 @@
}, },
"node_modules/mdast-util-to-string": { "node_modules/mdast-util-to-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@types/mdast": "^4.0.0" "@types/mdast": "^4.0.0"
}, },
@ -15945,6 +15947,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/reading-time": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz",
"integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==",
"license": "MIT"
},
"node_modules/rechoir": { "node_modules/rechoir": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.6.2.tgz", "resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.6.2.tgz",

View File

@ -45,6 +45,7 @@
"pinia": "^2.2.7", "pinia": "^2.2.7",
"qiniu-js": "^3.4.2", "qiniu-js": "^3.4.2",
"radix-vue": "^1.9.10", "radix-vue": "^1.9.10",
"reading-time": "^1.5.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiny-oss": "^0.5.1", "tiny-oss": "^0.5.1",

View File

@ -71,9 +71,9 @@ const formatItems = [
const store = useStore() const store = useStore()
const displayStore = useDisplayStore() const displayStore = useDisplayStore()
const { isDark, isCiteStatus, output, primaryColor } = storeToRefs(store) const { isDark, isCiteStatus, isCountStatus, output, primaryColor } = storeToRefs(store)
const { toggleDark, editorRefresh, citeStatusChanged } = store const { toggleDark, editorRefresh, citeStatusChanged, countStatusChanged } = store
const copyMode = useStorage(addPrefix(`copyMode`), `txt`) const copyMode = useStorage(addPrefix(`copyMode`), `txt`)
const source = ref(``) const source = ref(``)
@ -173,6 +173,13 @@ const formatOptions = ref<Format[]>([`rgb`, `hex`, `hsl`, `hsv`])
> >
微信外链转底部引用 微信外链转底部引用
</MenubarCheckboxItem> </MenubarCheckboxItem>
<MenubarSeparator />
<MenubarCheckboxItem
:checked="isCountStatus"
@click="countStatusChanged()"
>
统计字数和阅读时间
</MenubarCheckboxItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
<EditDropdown /> <EditDropdown />
@ -384,6 +391,31 @@ const formatOptions = ref<Format[]>([`rgb`, `hex`, `hsl`, `hsv`])
</Button> </Button>
</div> </div>
</div> </div>
<div class="space-y-2">
<h2>统计字数和阅读时间</h2>
<div class="grid grid-cols-5 justify-items-center gap-2">
<Button
class="w-full"
variant="outline"
:class="{
'border-black dark:border-white': store.isCountStatus,
}"
@click="!store.isCountStatus && store.countStatusChanged()"
>
开启
</Button>
<Button
class="w-full"
variant="outline"
:class="{
'border-black dark:border-white': !store.isCountStatus,
}"
@click="store.isCountStatus && store.countStatusChanged()"
>
关闭
</Button>
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<h2>段落首行缩进</h2> <h2>段落首行缩进</h2>
<div class="grid grid-cols-5 justify-items-center gap-2"> <div class="grid grid-cols-5 justify-items-center gap-2">

View File

@ -84,6 +84,7 @@ const defaultTheme: Theme = {
'border-radius': `6px`, 'border-radius': `6px`,
'color': `rgba(0,0,0,0.5)`, 'color': `rgba(0,0,0,0.5)`,
'background': `var(--blockquote-background)`, 'background': `var(--blockquote-background)`,
'margin-bottom': `1em`,
}, },
// 引用内容 // 引用内容
@ -334,6 +335,7 @@ const graceTheme = toMerged(defaultTheme, {
'border-radius': `6px`, 'border-radius': `6px`,
'color': `rgba(0,0,0,0.6)`, 'color': `rgba(0,0,0,0.6)`,
'box-shadow': `0 4px 6px rgba(0,0,0,0.05)`, 'box-shadow': `0 4px 6px rgba(0,0,0,0.05)`,
'margin-bottom': `1em`,
}, },
'blockquote_p': { 'blockquote_p': {

View File

@ -1,9 +1,10 @@
import type { ReadTimeResults } from 'reading-time'
import DEFAULT_CONTENT from '@/assets/example/markdown.md?raw' import DEFAULT_CONTENT from '@/assets/example/markdown.md?raw'
import DEFAULT_CSS_CONTENT from '@/assets/example/theme-css.txt?raw' import DEFAULT_CSS_CONTENT from '@/assets/example/theme-css.txt?raw'
import { altKey, codeBlockThemeOptions, colorOptions, fontFamilyOptions, fontSizeOptions, legendOptions, shiftKey, themeMap, themeOptions } from '@/config' import { altKey, codeBlockThemeOptions, colorOptions, fontFamilyOptions, fontSizeOptions, legendOptions, shiftKey, themeMap, themeOptions } from '@/config'
import { addPrefix, css2json, customCssWithTemplate, customizeTheme, downloadMD, exportHTML, formatDoc } from '@/utils' import { addPrefix, css2json, customCssWithTemplate, customizeTheme, downloadMD, exportHTML, formatDoc } from '@/utils'
import { initRenderer } from '@/utils/renderer'
import { initRenderer } from '@/utils/renderer'
import CodeMirror from 'codemirror' import CodeMirror from 'codemirror'
import { marked } from 'marked' import { marked } from 'marked'
@ -24,6 +25,10 @@ export const useStore = defineStore(`store`, () => {
const isCiteStatus = useStorage(`isCiteStatus`, false) const isCiteStatus = useStorage(`isCiteStatus`, false)
const toggleCiteStatus = useToggle(isCiteStatus) const toggleCiteStatus = useToggle(isCiteStatus)
// 是否统计字数和阅读时间
const isCountStatus = useStorage(`isCountStatus`, false)
const toggleCountStatus = useToggle(isCountStatus)
// 是否开启段落首行缩进 // 是否开启段落首行缩进
const isUseIndent = useStorage(addPrefix(`use_indent`), false) const isUseIndent = useStorage(addPrefix(`use_indent`), false)
const toggleUseIndent = useToggle(isUseIndent) const toggleUseIndent = useToggle(isUseIndent)
@ -174,14 +179,23 @@ export const useStore = defineStore(`store`, () => {
isUseIndent: isUseIndent.value, isUseIndent: isUseIndent.value,
}) })
const readingTime = ref<ReadTimeResults | null>(null)
// 更新编辑器 // 更新编辑器
const editorRefresh = () => { const editorRefresh = () => {
codeThemeChange() codeThemeChange()
renderer.reset({ citeStatus: isCiteStatus.value, legend: legend.value, isUseIndent: isUseIndent.value }) renderer.reset({ citeStatus: isCiteStatus.value, legend: legend.value, isUseIndent: isUseIndent.value, countStatus: isCountStatus.value })
const { markdownContent } = renderer.parseFrontMatterAndContent(editor.value!.getValue()) const { markdownContent, readingTime: readingTimeResult } = renderer.parseFrontMatterAndContent(editor.value!.getValue())
console.log(`Reading time result:`, readingTimeResult)
readingTime.value = readingTimeResult
let outputTemp = marked.parse(markdownContent) as string let outputTemp = marked.parse(markdownContent) as string
console.log(readingTime.value)
// 阅读时间及字数统计
outputTemp = renderer.buildReadingTime(readingTimeResult) + outputTemp
// 去除第一行的 margin-top // 去除第一行的 margin-top
outputTemp = outputTemp.replace(/(style=".*?)"/, `$1;margin-top: 0"`) outputTemp = outputTemp.replace(/(style=".*?)"/, `$1;margin-top: 0"`)
// 引用脚注 // 引用脚注
@ -275,6 +289,7 @@ export const useStore = defineStore(`store`, () => {
const resetStyle = () => { const resetStyle = () => {
isCiteStatus.value = false isCiteStatus.value = false
isMacCodeBlock.value = true isMacCodeBlock.value = true
isCountStatus.value = false
theme.value = themeOptions[0].value theme.value = themeOptions[0].value
fontFamily.value = fontFamilyOptions[0].value fontFamily.value = fontFamilyOptions[0].value
@ -366,6 +381,10 @@ export const useStore = defineStore(`store`, () => {
toggleCiteStatus() toggleCiteStatus()
}) })
const countStatusChanged = withAfterRefresh(() => {
toggleCountStatus()
})
const useIndentChanged = withAfterRefresh(() => { const useIndentChanged = withAfterRefresh(() => {
toggleUseIndent() toggleUseIndent()
}) })
@ -427,6 +446,9 @@ export const useStore = defineStore(`store`, () => {
isUseIndent, isUseIndent,
useIndentChanged, useIndentChanged,
isCountStatus,
countStatusChanged,
output, output,
editor, editor,
cssEditor, cssEditor,
@ -436,6 +458,7 @@ export const useStore = defineStore(`store`, () => {
primaryColor, primaryColor,
codeBlockTheme, codeBlockTheme,
legend, legend,
readingTime,
editorRefresh, editorRefresh,

View File

@ -26,6 +26,7 @@ export interface IOpts {
isUseIndent: boolean isUseIndent: boolean
legend?: string legend?: string
citeStatus?: boolean citeStatus?: boolean
countStatus?: boolean
} }
export type ThemeStyles = Record<Block | Inline, ExtendedProperties> export type ThemeStyles = Record<Block | Inline, ExtendedProperties>

View File

@ -1,14 +1,18 @@
import type { ExtendedProperties, IOpts, ThemeStyles } from '@/types' import type { ExtendedProperties, IOpts, ThemeStyles } from '@/types'
import type { PropertiesHyphen } from 'csstype' import type { PropertiesHyphen } from 'csstype'
import type { Renderer, RendererObject, Tokens } from 'marked' import type { Renderer, RendererObject, Tokens } from 'marked'
import type { ReadTimeResults } from 'reading-time'
import { cloneDeep, toMerged } from 'es-toolkit' import { cloneDeep, toMerged } from 'es-toolkit'
import frontMatter from 'front-matter' import frontMatter from 'front-matter'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import { marked } from 'marked' import { marked } from 'marked'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import readingTime from 'reading-time'
import { getStyleString } from '.' import { getStyleString } from '.'
import markedAlert from './MDAlert' import markedAlert from './MDAlert'
import { MDKatex } from './MDKatex' import { MDKatex } from './MDKatex'
marked.setOptions({ marked.setOptions({
@ -109,6 +113,36 @@ const macCodeSvg = `
</svg> </svg>
`.trim() `.trim()
interface ParseResult {
yamlData: Record<string, any>
markdownContent: string
readingTime: ReadTimeResults
}
function parseFrontMatterAndContent(markdownText: string): ParseResult {
try {
const parsed = frontMatter(markdownText)
const yamlData = parsed.attributes
const markdownContent = parsed.body
const readingTimeResult = readingTime(markdownContent)
return {
yamlData: yamlData as Record<string, any>,
markdownContent,
readingTime: readingTimeResult,
}
}
catch (error) {
console.error(`Error parsing front-matter:`, error)
return {
yamlData: {},
markdownContent: markdownText,
readingTime: readingTime(markdownText),
}
}
}
export function initRenderer(opts: IOpts) { export function initRenderer(opts: IOpts) {
const footnotes: [number, string, string][] = [] const footnotes: [number, string, string][] = []
let footnoteIndex: number = 0 let footnoteIndex: number = 0
@ -121,19 +155,6 @@ export function initRenderer(opts: IOpts) {
return getStyles(styleMapping, tag, addition) return getStyles(styleMapping, tag, addition)
} }
function parseFrontMatterAndContent(markdownText: string) {
try {
const parsed = frontMatter(markdownText)
const yamlData = parsed.attributes
const markdownContent = parsed.body
return { yamlData, markdownContent }
}
catch (error) {
console.error(`Error parsing front-matter:`, error)
return { yamlData: {}, markdownContent: markdownText }
}
}
function styledContent(styleLabel: string, content: string, tagName?: string): string { function styledContent(styleLabel: string, content: string, tagName?: string): string {
const tag = tagName ?? styleLabel const tag = tagName ?? styleLabel
return `<${tag} ${styles(styleLabel)}>${content}</${tag}>` return `<${tag} ${styles(styleLabel)}>${content}</${tag}>`
@ -156,6 +177,20 @@ export function initRenderer(opts: IOpts) {
marked.use(markedAlert({ styles: styleMapping })) marked.use(markedAlert({ styles: styleMapping }))
} }
function buildReadingTime(readingTime: ReadTimeResults): string {
if (!opts.countStatus) {
return ``
}
if (!readingTime.words) {
return ``
}
return `
<blockquote ${styles(`blockquote`)}>
<p ${styles(`blockquote_p`)}> ${readingTime?.words} ${Math.ceil(readingTime?.minutes)} </p>
</blockquote>
`
}
const buildFootnotes = () => { const buildFootnotes = () => {
if (!footnotes.length) { if (!footnotes.length) {
return `` return ``
@ -305,6 +340,7 @@ export function initRenderer(opts: IOpts) {
setOptions, setOptions,
reset, reset,
parseFrontMatterAndContent, parseFrontMatterAndContent,
buildReadingTime,
createContainer(content: string) { createContainer(content: string) {
return styledContent(`container`, content, `section`) return styledContent(`container`, content, `section`)
}, },

View File

@ -12,7 +12,7 @@ import CodeMirror from 'codemirror'
const store = useStore() const store = useStore()
const displayStore = useDisplayStore() const displayStore = useDisplayStore()
const { isDark, output, editor } = storeToRefs(store) const { isDark, output, editor, readingTime } = storeToRefs(store)
const { isShowCssEditor } = storeToRefs(displayStore) const { isShowCssEditor } = storeToRefs(displayStore)
const { const {
@ -61,7 +61,7 @@ function leftAndRightScroll() {
} }
const percentage const percentage
= source.scrollTop / (source.scrollHeight - source.offsetHeight) = source.scrollTop / (source.scrollHeight - source.offsetHeight)
const height = percentage * (target.scrollHeight - target.offsetHeight) const height = percentage * (target.scrollHeight - target.offsetHeight)
target.scrollTo(0, height) target.scrollTo(0, height)
@ -274,7 +274,7 @@ function mdLocalToRemote() {
let [, , matchStr] = item let [, , matchStr] = item
matchStr = matchStr.replace(/^.\//, ``) // 处理 ./img/ img/ 统一相对路径风格 matchStr = matchStr.replace(/^.\//, ``) // 处理 ./img/ img/ 统一相对路径风格
const { file } const { file }
= list.find(f => f.path === `${root}${matchStr}`) || {} = list.find(f => f.path === `${root}${matchStr}`) || {}
uploadImage(file!, (url) => { uploadImage(file!, (url) => {
resolve({ matchStr, url }) resolve({ matchStr, url })
}) })
@ -363,29 +363,18 @@ onMounted(() => {
<template> <template>
<div ref="container" class="container flex flex-col"> <div ref="container" class="container flex flex-col">
<EditorHeader <EditorHeader @add-format="addFormat" @format-content="formatContent" @start-copy="startCopy" @end-copy="endCopy" />
@add-format="addFormat"
@format-content="formatContent"
@start-copy="startCopy"
@end-copy="endCopy"
/>
<main class="container-main flex-1"> <main class="container-main flex-1">
<div class="container-main-section h-full flex border-1"> <div class="container-main-section h-full flex border-1">
<PostSlider /> <PostSlider />
<div <div
ref="codeMirrorWrapper" ref="codeMirrorWrapper" class="codeMirror-wrapper flex-1 border-r-1" :class="{
class="codeMirror-wrapper flex-1 border-r-1"
:class="{
'order-1': !store.isEditOnLeft, 'order-1': !store.isEditOnLeft,
}" }"
> >
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
<textarea <textarea id="editor" type="textarea" placeholder="Your markdown text here." />
id="editor"
type="textarea"
placeholder="Your markdown text here."
/>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent class="w-64"> <ContextMenuContent class="w-64">
<ContextMenuItem inset @click="toggleShowUploadImgDialog()"> <ContextMenuItem inset @click="toggleShowUploadImgDialog()">
@ -414,12 +403,7 @@ onMounted(() => {
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</div> </div>
<div <div id="preview" ref="preview" :span="isShowCssEditor ? 8 : 12" class="preview-wrapper flex-1 p-5">
id="preview"
ref="preview"
:span="isShowCssEditor ? 8 : 12"
class="preview-wrapper flex-1 p-5"
>
<div id="output-wrapper" :class="{ output_night: !backLight }"> <div id="output-wrapper" :class="{ output_night: !backLight }">
<div class="preview border shadow-xl"> <div class="preview border shadow-xl">
<section id="output" v-html="output" /> <section id="output" v-html="output" />
@ -434,32 +418,33 @@ onMounted(() => {
</div> </div>
<CssEditor class="flex-1" /> <CssEditor class="flex-1" />
</div> </div>
<footer class="flex flex-1 justify-end pr-5 text-[12px]">
字数 {{ readingTime?.words }} 阅读大约需 {{ Math.ceil(readingTime?.minutes ?? 0) }} 分钟
</footer>
<UploadImgDialog @upload-image="uploadImage" />
<InsertFormDialog />
<RunLoading />
<AlertDialog v-model:open="store.isOpenConfirmDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>提示</AlertDialogTitle>
<AlertDialogDescription>
此操作将丢失本地自定义样式是否继续
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="store.resetStyle()">
确认
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main> </main>
<UploadImgDialog
@upload-image="uploadImage"
/>
<InsertFormDialog />
<RunLoading />
<AlertDialog v-model:open="store.isOpenConfirmDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>提示</AlertDialogTitle>
<AlertDialogDescription>
此操作将丢失本地自定义样式是否继续
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="store.resetStyle()">
确认
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</template> </template>