Merge pull request #83 from zxz0119/save-reading-progress
feat: 保存阅读进度书签
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user