feat: 保存阅读进度书签
This commit is contained in:
@@ -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