Merge pull request #83 from zxz0119/save-reading-progress

feat: 保存阅读进度书签
This commit is contained in:
Sanbu 散步
2026-05-06 11:25:00 +08:00
committed by GitHub
3 changed files with 428 additions and 6 deletions
@@ -4,7 +4,7 @@
v-if="showProgress"
class="reading-progress"
:class="{ 'is-dragging': isDragging }"
:title="isDragging ? '拖动调整位置' : '阅读进度 ' + progress + '%'"
:title="progressTitle"
@mousedown="startDrag"
@touchstart="startDrag"
@click="handleClick"
@@ -28,6 +28,10 @@
<div v-if="showArrow && !isDragging" key="arrow" class="progress-arrow"></div>
<div v-else key="percent" class="progress-text">{{ progress }}%</div>
</Transition>
<div v-if="!isDragging && bookmarkLabel" class="bookmark-label">
{{ bookmarkLabel }}
</div>
<!-- 拖拽时的提示 -->
<div v-if="isDragging" class="drag-hint">拖动调整</div>
@@ -36,31 +40,125 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vitepress'
import {
createReadingBookmark,
readReadingBookmark,
writeReadingBookmark
} from '../utils/readingBookmark.js'
const route = useRoute()
const progress = ref(0)
const showProgress = ref(false)
const showArrow = ref(false)
const articleTitle = ref('')
const activeSection = ref('')
const restoredBookmark = ref(null)
// Circle circumference = 2 * PI * r, where r=24
const circumference = 2 * Math.PI * 24
let scrollTimer = null
let saveTimer = null
let restoreTimer = null
let clickSaveTimer = null
// 拖拽相关状态
const isDragging = ref(false)
const startY = ref(0)
const startProgress = ref(0)
const movedDuringDrag = ref(false)
let dragRafId = null
let skipNextClick = false
const currentPath = () =>
`${window.location.pathname}${window.location.search || ''}`
const getClientStorage = () => {
try {
return window.localStorage
} catch {
return null
}
}
const getMaxScrollY = () =>
Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
const getArticleTitle = () => {
const heading = document.querySelector('.vp-doc h1')
return (heading?.textContent || document.title || '').trim()
}
const updateActiveSection = () => {
const headings = Array.from(
document.querySelectorAll('.vp-doc h2, .vp-doc h3')
)
let current = ''
for (const heading of headings) {
if (heading.getBoundingClientRect().top <= 96) {
current = heading.textContent?.trim() || ''
} else {
break
}
}
activeSection.value = current
}
const bookmarkLabel = computed(() => {
const title = articleTitle.value || restoredBookmark.value?.title || ''
const section = activeSection.value || restoredBookmark.value?.section || ''
return section || title
})
const bookmarkTitle = computed(() => {
const title =
articleTitle.value || restoredBookmark.value?.title || '当前文章'
const section = activeSection.value || restoredBookmark.value?.section || ''
return section ? `${title} - ${section}` : title
})
const progressTitle = computed(() =>
isDragging.value
? '拖动调整位置'
: `${bookmarkTitle.value} · 阅读进度 ${progress.value}%`
)
const saveBookmark = () => {
writeReadingBookmark(
getClientStorage(),
createReadingBookmark({
path: currentPath(),
title: articleTitle.value,
section: activeSection.value,
scrollY: window.scrollY,
progress: progress.value
})
)
}
const scheduleBookmarkSave = () => {
if (saveTimer) {
window.clearTimeout(saveTimer)
}
saveTimer = window.setTimeout(saveBookmark, 180)
}
const updateProgress = () => {
// 拖拽时不更新进度,避免冲突
if (isDragging.value) return
articleTitle.value = getArticleTitle()
updateActiveSection()
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
const docHeight = getMaxScrollY()
const scrollPercent = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0
progress.value = Math.min(Math.round(scrollPercent), 100)
showProgress.value = scrollTop > 0 // 开始滚动就显示
restoredBookmark.value = null
// 滚动时显示百分比
showArrow.value = false
@@ -76,6 +174,55 @@ const updateProgress = () => {
showArrow.value = true
}
}, 1500)
scheduleBookmarkSave()
}
const restoreBookmark = async () => {
await nextTick()
if (restoreTimer) {
window.clearTimeout(restoreTimer)
}
restoreTimer = window.setTimeout(() => {
articleTitle.value = getArticleTitle()
updateActiveSection()
const saved = readReadingBookmark(
getClientStorage(),
currentPath(),
getMaxScrollY()
)
if (!saved || saved.scrollY <= 0) {
updateProgress()
return
}
restoredBookmark.value = saved
articleTitle.value = saved.title || articleTitle.value
activeSection.value = saved.section || activeSection.value
progress.value = saved.progress
showProgress.value = true
showArrow.value = true
window.scrollTo({
top: saved.scrollY,
behavior: 'auto'
})
window.setTimeout(updateProgress, 0)
}, 80)
}
const resetRouteState = () => {
progress.value = 0
showProgress.value = false
showArrow.value = false
restoredBookmark.value = null
articleTitle.value = ''
activeSection.value = ''
}
// 开始拖拽
@@ -85,6 +232,7 @@ const startDrag = (e) => {
isDragging.value = true
startY.value = 'touches' in e ? e.touches[0].clientY : e.clientY
startProgress.value = progress.value
movedDuringDrag.value = false
// 添加全局事件监听
document.addEventListener('mousemove', onDrag, { passive: false })
@@ -100,6 +248,9 @@ const onDrag = (e) => {
const currentY = 'touches' in e ? e.touches[0].clientY : e.clientY
const deltaY = startY.value - currentY // 向上拖动为正值
if (Math.abs(deltaY) > 4) {
movedDuringDrag.value = true
}
// 每拖动 3 像素调整 1% 进度
const sensitivity = 3
@@ -130,6 +281,7 @@ const onDrag = (e) => {
// 结束拖拽
const endDrag = () => {
const shouldSkipClick = movedDuringDrag.value
isDragging.value = false
// 清除事件监听
@@ -147,22 +299,44 @@ const endDrag = () => {
if (window.scrollY > 0) {
showArrow.value = true
}
articleTitle.value = getArticleTitle()
updateActiveSection()
saveBookmark()
if (shouldSkipClick) {
skipNextClick = true
window.setTimeout(() => {
skipNextClick = false
}, 0)
}
}
// 点击回到顶部
const handleClick = (e) => {
const handleClick = () => {
// 如果是拖拽结束后的点击,不触发回到顶部
if (isDragging.value) return
if (isDragging.value || skipNextClick) {
skipNextClick = false
return
}
window.scrollTo({
top: 0,
behavior: 'smooth'
})
if (clickSaveTimer) {
window.clearTimeout(clickSaveTimer)
}
clickSaveTimer = window.setTimeout(() => {
updateProgress()
saveBookmark()
}, 400)
}
onMounted(() => {
window.addEventListener('scroll', updateProgress, { passive: true })
updateProgress()
restoreBookmark()
})
onUnmounted(() => {
@@ -170,6 +344,15 @@ onUnmounted(() => {
if (scrollTimer) {
clearTimeout(scrollTimer)
}
if (saveTimer) {
clearTimeout(saveTimer)
}
if (restoreTimer) {
clearTimeout(restoreTimer)
}
if (clickSaveTimer) {
clearTimeout(clickSaveTimer)
}
// 清理拖拽事件
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', endDrag)
@@ -179,6 +362,14 @@ onUnmounted(() => {
cancelAnimationFrame(dragRafId)
}
})
watch(
() => route.path,
() => {
resetRouteState()
restoreBookmark()
}
)
</script>
<style scoped>
@@ -278,6 +469,35 @@ onUnmounted(() => {
animation: bounce 1s ease-in-out infinite;
}
.bookmark-label {
position: absolute;
right: 0;
bottom: 100%;
width: max-content;
max-width: min(260px, calc(100vw - 48px));
margin-bottom: 8px;
padding: 5px 9px;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
opacity: 0;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
transition: opacity 0.18s ease, transform 0.18s ease;
transform: translateY(4px);
}
.reading-progress:hover .bookmark-label {
opacity: 1;
transform: translateY(0);
}
@keyframes bounce {
0%, 100% {
transform: translate(-50%, -50%);
@@ -0,0 +1,77 @@
export const READING_BOOKMARK_VERSION = 1
export const READING_BOOKMARK_KEY_PREFIX = 'ev-reading-bookmark:'
export const getReadingBookmarkKey = (path) =>
`${READING_BOOKMARK_KEY_PREFIX}${path || '/'}`
const clampNumber = (value, min, max, fallback = min) => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return fallback
return Math.min(max, Math.max(min, numeric))
}
export const createReadingBookmark = ({
path,
title = '',
section = '',
scrollY = 0,
progress = 0,
now = () => Date.now()
}) => ({
version: READING_BOOKMARK_VERSION,
path: path || '/',
title: String(title || '').trim(),
section: String(section || '').trim(),
scrollY: Math.max(0, Math.round(Number(scrollY) || 0)),
progress: Math.round(clampNumber(progress, 0, 100, 0)),
updatedAt: now()
})
const normalizeBookmark = (
value,
expectedPath,
maxScrollY = Number.MAX_SAFE_INTEGER
) => {
if (!value || typeof value !== 'object') return null
if (value.version !== READING_BOOKMARK_VERSION) return null
if (value.path !== expectedPath) return null
return {
version: READING_BOOKMARK_VERSION,
path: value.path,
title: String(value.title || '').trim(),
section: String(value.section || '').trim(),
scrollY: Math.round(
clampNumber(value.scrollY, 0, Math.max(0, maxScrollY), 0)
),
progress: Math.round(clampNumber(value.progress, 0, 100, 0)),
updatedAt: Number(value.updatedAt) || 0
}
}
export const readReadingBookmark = (storage, path, maxScrollY) => {
if (!storage) return null
try {
const expectedPath = path || '/'
const raw = storage.getItem(getReadingBookmarkKey(expectedPath))
if (!raw) return null
return normalizeBookmark(JSON.parse(raw), expectedPath, maxScrollY)
} catch {
return null
}
}
export const writeReadingBookmark = (storage, bookmark) => {
if (!storage || !bookmark?.path) return false
try {
storage.setItem(
getReadingBookmarkKey(bookmark.path),
JSON.stringify(bookmark)
)
return true
} catch {
return false
}
}
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
createReadingBookmark,
getReadingBookmarkKey,
readReadingBookmark,
writeReadingBookmark
} from './readingBookmark.js'
const createStorage = () => {
const values = new Map()
return {
getItem(key) {
return values.has(key) ? values.get(key) : null
},
setItem(key, value) {
values.set(key, String(value))
}
}
}
describe('reading bookmarks', () => {
it('stores bookmarks by full path so each locale is independent', () => {
const storage = createStorage()
const zhBookmark = createReadingBookmark({
path: '/easy-vibe/zh-cn/stage-1/intro/',
title: '中文标题',
section: '小节',
scrollY: 320,
progress: 45,
now: () => 123
})
const enBookmark = createReadingBookmark({
path: '/easy-vibe/en/stage-1/intro/',
title: 'English title',
scrollY: 80,
progress: 12,
now: () => 456
})
assert.equal(writeReadingBookmark(storage, zhBookmark), true)
assert.equal(writeReadingBookmark(storage, enBookmark), true)
assert.deepEqual(
readReadingBookmark(storage, '/easy-vibe/zh-cn/stage-1/intro/', 1000),
{
version: 1,
path: '/easy-vibe/zh-cn/stage-1/intro/',
title: '中文标题',
section: '小节',
scrollY: 320,
progress: 45,
updatedAt: 123
}
)
assert.equal(
readReadingBookmark(storage, '/easy-vibe/en/stage-1/intro/', 1000)
.title,
'English title'
)
})
it('normalizes invalid numeric values', () => {
assert.deepEqual(
createReadingBookmark({
path: '/easy-vibe/ja-jp/page/',
scrollY: -5,
progress: 140,
now: () => 1
}),
{
version: 1,
path: '/easy-vibe/ja-jp/page/',
title: '',
section: '',
scrollY: 0,
progress: 100,
updatedAt: 1
}
)
})
it('ignores malformed or mismatched stored values', () => {
const storage = createStorage()
storage.setItem(getReadingBookmarkKey('/easy-vibe/ko-kr/page/'), '{bad')
assert.equal(
readReadingBookmark(storage, '/easy-vibe/ko-kr/page/', 1000),
null
)
storage.setItem(
getReadingBookmarkKey('/easy-vibe/ko-kr/page/'),
JSON.stringify({
version: 1,
path: '/easy-vibe/zh-cn/page/',
scrollY: 20,
progress: 10
})
)
assert.equal(
readReadingBookmark(storage, '/easy-vibe/ko-kr/page/', 1000),
null
)
})
it('clamps restored scroll position to current document height', () => {
const storage = createStorage()
const path = '/easy-vibe/fr-fr/page/'
writeReadingBookmark(
storage,
createReadingBookmark({
path,
scrollY: 9000,
progress: 88,
now: () => 99
})
)
assert.equal(readReadingBookmark(storage, path, 640).scrollY, 640)
})
})