uview-plus 3.8.86 → 3.8.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/changelog.md +150 -3
  2. package/components/u-action-sheet/u-action-sheet.vue +21 -2
  3. package/components/u-barcode/u-barcode.vue +0 -2
  4. package/components/u-button/u-button.vue +2 -5
  5. package/components/u-canvas/u-canvas.vue +368 -74
  6. package/components/u-cell/u-cell.vue +8 -0
  7. package/components/u-datetime-picker/u-datetime-picker.vue +53 -4
  8. package/components/u-dragsort/u-dragsort.vue +55 -31
  9. package/components/u-icon/u-icon.vue +12 -3
  10. package/components/u-icon/util.js +81 -10
  11. package/components/u-index-item/u-index-item.vue +1 -1
  12. package/components/u-index-list/u-index-list.vue +1 -1
  13. package/components/u-novel-reader/content-normalizer.js +80 -0
  14. package/components/u-novel-reader/layout-engine.js +225 -0
  15. package/components/u-novel-reader/measure-adapter.js +69 -0
  16. package/components/u-novel-reader/novelReader.js +35 -0
  17. package/components/u-novel-reader/persistence.js +144 -0
  18. package/components/u-novel-reader/props.js +100 -0
  19. package/components/u-novel-reader/reader-catalog.vue +234 -0
  20. package/components/u-novel-reader/reader-content.vue +226 -0
  21. package/components/u-novel-reader/reader-core.js +151 -0
  22. package/components/u-novel-reader/reader-settings.vue +325 -0
  23. package/components/u-novel-reader/reader-toolbar.vue +313 -0
  24. package/components/u-novel-reader/theme-vars.scss +49 -0
  25. package/components/u-novel-reader/u-novel-reader.vue +836 -0
  26. package/components/u-poster/u-poster.vue +236 -219
  27. package/components/u-qrcode/qrcode.js +56 -49
  28. package/components/u-qrcode/u-qrcode.vue +159 -88
  29. package/components/u-scroll-list/scrollWxs.wxs +1 -1
  30. package/components/u-slider/u-slider.vue +1 -1
  31. package/components/u-swipe-action-item/index.wxs +43 -25
  32. package/components/u-swipe-action-item/nvue.js +9 -0
  33. package/components/u-swipe-action-item/other.js +32 -21
  34. package/components/u-swipe-action-item/props.js +5 -0
  35. package/components/u-swipe-action-item/swipeActionItem.js +1 -0
  36. package/components/u-swipe-action-item/u-swipe-action-item.vue +28 -4
  37. package/components/u-switch/u-switch.vue +1 -1
  38. package/components/u-tabbar/u-tabbar.vue +2 -1
  39. package/components/u-tabbar-item/u-tabbar-item.vue +64 -21
  40. package/components/u-tabs/u-tabs.vue +11 -8
  41. package/components/u-tabs-pro/u-tabs-pro.vue +212 -0
  42. package/components/u-text/text.js +1 -1
  43. package/components/u-text/u-text.vue +18 -6
  44. package/components/u-upload/u-upload.vue +1 -1
  45. package/components/u-waterfall/u-waterfall.vue +118 -65
  46. package/libs/config/config.js +1 -1
  47. package/libs/config/props.js +1 -0
  48. package/libs/function/index.js +66 -0
  49. package/libs/mixin/mixin.js +4 -50
  50. package/libs/root/index.js +78 -5
  51. package/libs/root/page.js +7 -7
  52. package/libs/root/root.js +73 -3
  53. package/libs/util/app-nvue-webview-canvas.js +486 -0
  54. package/libs/util/gcanvas/bridge/bridge-weex.js +0 -2
  55. package/libs/util/gcanvas/context-2d/FillStyleLinearGradient.js +0 -0
  56. package/libs/util/gcanvas/context-2d/FillStylePattern.js +0 -0
  57. package/libs/util/gcanvas/context-2d/FillStyleRadialGradient.js +0 -0
  58. package/libs/util/gcanvas/context-2d/RenderingContext.js +0 -0
  59. package/libs/util/gcanvas/context-webgl/ActiveInfo.js +0 -0
  60. package/libs/util/gcanvas/context-webgl/Buffer.js +0 -0
  61. package/libs/util/gcanvas/context-webgl/Framebuffer.js +0 -0
  62. package/libs/util/gcanvas/context-webgl/GLenum.js +0 -0
  63. package/libs/util/gcanvas/context-webgl/GLmethod.js +0 -0
  64. package/libs/util/gcanvas/context-webgl/GLtype.js +0 -0
  65. package/libs/util/gcanvas/context-webgl/Program.js +0 -0
  66. package/libs/util/gcanvas/context-webgl/Renderbuffer.js +0 -0
  67. package/libs/util/gcanvas/context-webgl/RenderingContext.js +0 -0
  68. package/libs/util/gcanvas/context-webgl/Shader.js +0 -0
  69. package/libs/util/gcanvas/context-webgl/ShaderPrecisionFormat.js +0 -0
  70. package/libs/util/gcanvas/context-webgl/Texture.js +0 -0
  71. package/libs/util/gcanvas/context-webgl/UniformLocation.js +0 -0
  72. package/libs/util/gcanvas/context-webgl/classUtils.js +0 -0
  73. package/libs/util/gcanvas/env/canvas.js +0 -0
  74. package/libs/util/gcanvas/env/image.js +0 -0
  75. package/libs/util/gcanvas/env/tool.js +0 -0
  76. package/libs/util/gcanvas/index.js +3 -3
  77. package/package.json +1 -1
  78. package/types/comps/swipeActionItem.d.ts +13 -0
  79. package/types/comps/tabs.d.ts +2 -1
  80. package/types/comps/text.d.ts +1 -1
  81. package/types/func.d.ts +23 -0
  82. package/types/index.d.ts +1 -0
@@ -0,0 +1,234 @@
1
+ <template>
2
+ <view class="up-novel-reader__catalog" :style="catalogStyle">
3
+ <view class="up-novel-reader__catalog-header">
4
+ <text class="up-novel-reader__catalog-title">目录</text>
5
+ <text class="up-novel-reader__catalog-count">{{ chapters.length }} 章</text>
6
+ </view>
7
+ <slot v-if="$slots.default"></slot>
8
+ <scroll-view v-else class="up-novel-reader__catalog-scroll" scroll-y>
9
+ <view
10
+ v-for="chapter in chapters"
11
+ :key="chapter.id || chapter.index"
12
+ class="up-novel-reader__catalog-item"
13
+ :class="{
14
+ 'is-current': isCurrent(chapter),
15
+ 'is-locked': chapter.isLocked
16
+ }"
17
+ :aria-disabled="chapter.isLocked ? 'true' : 'false'"
18
+ @tap="selectChapter(chapter)"
19
+ >
20
+ <view class="up-novel-reader__catalog-index">
21
+ <u-icon
22
+ v-if="chapter.isLocked"
23
+ name="lock"
24
+ size="14"
25
+ :color="mutedColor"
26
+ ></u-icon>
27
+ <text v-else>{{ displayIndex(chapter) }}</text>
28
+ </view>
29
+ <text class="up-novel-reader__catalog-item-title">{{ chapter.title || `第${displayIndex(chapter)}章` }}</text>
30
+ <u-icon
31
+ v-if="isCurrent(chapter)"
32
+ name="checkmark"
33
+ size="16"
34
+ :color="activeColor"
35
+ ></u-icon>
36
+ </view>
37
+ <view v-if="!chapters.length" class="up-novel-reader__catalog-empty">暂无目录</view>
38
+ <view v-if="bookmarks.length" class="up-novel-reader__bookmark-section">
39
+ <view class="up-novel-reader__bookmark-heading">
40
+ <u-icon name="bookmark" size="15" :color="activeColor"></u-icon>
41
+ <text>书签</text>
42
+ </view>
43
+ <view
44
+ v-for="bookmark in bookmarks"
45
+ :key="bookmark.id"
46
+ class="up-novel-reader__bookmark-item"
47
+ @tap="selectBookmark(bookmark)"
48
+ >
49
+ <text class="up-novel-reader__bookmark-excerpt">{{ bookmark.excerpt || '书签位置' }}</text>
50
+ <text class="up-novel-reader__bookmark-progress">{{ bookmark.chapterIndex + 1 }} 章</text>
51
+ </view>
52
+ </view>
53
+ </scroll-view>
54
+ </view>
55
+ </template>
56
+
57
+ <script>
58
+ export default {
59
+ name: 'NovelReaderCatalog',
60
+ props: {
61
+ chapters: {
62
+ type: Array,
63
+ default: () => []
64
+ },
65
+ currentChapter: {
66
+ type: Object,
67
+ default: null
68
+ },
69
+ bookmarks: {
70
+ type: Array,
71
+ default: () => []
72
+ },
73
+ progress: {
74
+ type: Object,
75
+ default: () => ({})
76
+ },
77
+ themeTokens: {
78
+ type: Object,
79
+ default: () => ({})
80
+ },
81
+ },
82
+ emits: ['chapter-select', 'bookmark-select'],
83
+ computed: {
84
+ catalogStyle() {
85
+ return {
86
+ color: this.themeTokens.text || '#303133',
87
+ backgroundColor: this.themeTokens.background || '#ffffff'
88
+ }
89
+ },
90
+ activeColor() {
91
+ return this.themeTokens.active || '#2979ff'
92
+ },
93
+ mutedColor() {
94
+ return this.themeTokens.muted || '#909399'
95
+ }
96
+ },
97
+ methods: {
98
+ displayIndex(chapter) {
99
+ const index = Number(chapter && chapter.index)
100
+ return Number.isFinite(index) ? index + 1 : ''
101
+ },
102
+ isCurrent(chapter) {
103
+ return Boolean(
104
+ chapter &&
105
+ this.currentChapter &&
106
+ chapter.id === this.currentChapter.id
107
+ )
108
+ },
109
+ selectChapter(chapter) {
110
+ if (!chapter || chapter.isLocked) return
111
+ this.$emit('chapter-select', chapter)
112
+ },
113
+ selectBookmark(bookmark) {
114
+ if (!bookmark) return
115
+ this.$emit('bookmark-select', bookmark)
116
+ }
117
+ }
118
+ }
119
+ </script>
120
+
121
+ <style lang="scss" scoped>
122
+ .up-novel-reader__catalog {
123
+ display: flex;
124
+ flex-direction: column;
125
+ width: 82vw;
126
+ max-width: 360px;
127
+ height: 100%;
128
+ box-sizing: border-box;
129
+ }
130
+
131
+ .up-novel-reader__catalog-header {
132
+ display: flex;
133
+ flex-direction: row;
134
+ align-items: flex-end;
135
+ justify-content: space-between;
136
+ padding: 20px 18px 14px;
137
+ border-bottom: 1px solid var(--up-novel-reader-border, rgba(48, 49, 51, 0.12));
138
+ }
139
+
140
+ .up-novel-reader__catalog-title {
141
+ font-size: 20px;
142
+ font-weight: 600;
143
+ }
144
+
145
+ .up-novel-reader__catalog-count {
146
+ color: var(--up-novel-reader-muted, #909399);
147
+ font-size: 12px;
148
+ }
149
+
150
+ .up-novel-reader__catalog-scroll {
151
+ flex: 1;
152
+ min-height: 0;
153
+ }
154
+
155
+ .up-novel-reader__catalog-item {
156
+ display: flex;
157
+ flex-direction: row;
158
+ align-items: center;
159
+ min-height: 48px;
160
+ padding: 0 18px;
161
+ gap: 10px;
162
+ }
163
+
164
+ .up-novel-reader__catalog-item.is-current {
165
+ color: var(--up-novel-reader-active, #2979ff);
166
+ background-color: rgba(41, 121, 255, 0.08);
167
+ }
168
+
169
+ .up-novel-reader__catalog-item.is-locked {
170
+ opacity: 0.45;
171
+ }
172
+
173
+ .up-novel-reader__catalog-index {
174
+ display: flex;
175
+ flex-direction: row;
176
+ align-items: center;
177
+ justify-content: center;
178
+ width: 24px;
179
+ color: var(--up-novel-reader-muted, #909399);
180
+ font-size: 12px;
181
+ }
182
+
183
+ .up-novel-reader__catalog-item-title {
184
+ flex: 1;
185
+ overflow: hidden;
186
+ font-size: 14px;
187
+ text-overflow: ellipsis;
188
+ white-space: nowrap;
189
+ }
190
+
191
+ .up-novel-reader__catalog-empty {
192
+ padding: 36px 18px;
193
+ color: var(--up-novel-reader-muted, #909399);
194
+ text-align: center;
195
+ font-size: 14px;
196
+ }
197
+
198
+ .up-novel-reader__bookmark-section {
199
+ margin-top: 10px;
200
+ padding: 16px 18px 24px;
201
+ border-top: 1px solid var(--up-novel-reader-border, rgba(48, 49, 51, 0.12));
202
+ }
203
+
204
+ .up-novel-reader__bookmark-heading {
205
+ display: flex;
206
+ flex-direction: row;
207
+ align-items: center;
208
+ margin-bottom: 10px;
209
+ gap: 6px;
210
+ color: var(--up-novel-reader-muted, #909399);
211
+ font-size: 13px;
212
+ }
213
+
214
+ .up-novel-reader__bookmark-item {
215
+ display: flex;
216
+ flex-direction: row;
217
+ justify-content: space-between;
218
+ padding: 8px 0;
219
+ gap: 12px;
220
+ }
221
+
222
+ .up-novel-reader__bookmark-excerpt {
223
+ flex: 1;
224
+ overflow: hidden;
225
+ font-size: 13px;
226
+ text-overflow: ellipsis;
227
+ white-space: nowrap;
228
+ }
229
+
230
+ .up-novel-reader__bookmark-progress {
231
+ color: var(--up-novel-reader-muted, #909399);
232
+ font-size: 11px;
233
+ }
234
+ </style>
@@ -0,0 +1,226 @@
1
+ <template>
2
+ <view
3
+ class="up-novel-reader__content"
4
+ :style="{ color: themeTokens.text, backgroundColor: themeTokens.background }"
5
+ @tap="handleTap"
6
+ >
7
+ <view v-if="loading" class="up-novel-reader__state">
8
+ <slot name="loading">
9
+ <text class="up-novel-reader__state-text">章节加载中</text>
10
+ </slot>
11
+ </view>
12
+ <view v-else-if="error" class="up-novel-reader__state">
13
+ <slot name="error" :error="error" :retry="retry">
14
+ <text class="up-novel-reader__state-text">{{ error.message || '章节加载失败' }}</text>
15
+ <view class="up-novel-reader__state-action" @tap.stop="retry">重试</view>
16
+ </slot>
17
+ </view>
18
+ <view v-else-if="!hasContent" class="up-novel-reader__state">
19
+ <slot name="empty">
20
+ <text class="up-novel-reader__state-text">暂无正文</text>
21
+ </slot>
22
+ </view>
23
+ <scroll-view
24
+ v-else-if="mode === 'scroll'"
25
+ class="up-novel-reader__scroll"
26
+ scroll-y
27
+ :scroll-top="scrollTop"
28
+ @scroll="handleScroll"
29
+ >
30
+ <view class="up-novel-reader__article" :style="articleStyle">
31
+ <view
32
+ v-for="paragraph in paragraphs"
33
+ :key="paragraph.index"
34
+ class="up-novel-reader__paragraph"
35
+ :style="paragraphStyle"
36
+ >
37
+ <text>{{ paragraph.text }}</text>
38
+ </view>
39
+ </view>
40
+ </scroll-view>
41
+ <swiper
42
+ v-else
43
+ class="up-novel-reader__swiper"
44
+ :current="pageIndex"
45
+ :duration="pageAnimation ? 300 : 0"
46
+ :disable-touch="false"
47
+ @change="handlePageChange"
48
+ >
49
+ <swiper-item
50
+ v-for="page in pages"
51
+ :key="page.index"
52
+ class="up-novel-reader__page"
53
+ >
54
+ <view class="up-novel-reader__article" :style="articleStyle">
55
+ <text class="up-novel-reader__page-text">{{ page.text }}</text>
56
+ </view>
57
+ </swiper-item>
58
+ </swiper>
59
+ </view>
60
+ </template>
61
+
62
+ <script>
63
+ import { addUnit } from '../../libs/function/index'
64
+
65
+ export default {
66
+ name: 'NovelReaderContent',
67
+ props: {
68
+ mode: {
69
+ type: String,
70
+ default: 'scroll'
71
+ },
72
+ paragraphs: {
73
+ type: Array,
74
+ default: () => []
75
+ },
76
+ pages: {
77
+ type: Array,
78
+ default: () => []
79
+ },
80
+ pageIndex: {
81
+ type: Number,
82
+ default: 0
83
+ },
84
+ scrollTop: {
85
+ type: Number,
86
+ default: 0
87
+ },
88
+ settings: {
89
+ type: Object,
90
+ default: () => ({})
91
+ },
92
+ themeTokens: {
93
+ type: Object,
94
+ default: () => ({})
95
+ },
96
+ loading: {
97
+ type: Boolean,
98
+ default: false
99
+ },
100
+ error: {
101
+ type: Object,
102
+ default: null
103
+ },
104
+ pageAnimation: {
105
+ type: Boolean,
106
+ default: true
107
+ }
108
+ },
109
+ emits: ['content-scroll', 'page-change', 'tap-zone', 'retry'],
110
+ computed: {
111
+ hasContent() {
112
+ return this.paragraphs.length > 0 || this.pages.length > 0
113
+ },
114
+ articleStyle() {
115
+ const style = {
116
+ width: this.settings.contentWidth || '92%',
117
+ color: this.themeTokens.text,
118
+ fontSize: addUnit(this.settings.fontSize || 18, 'px'),
119
+ lineHeight: this.getLineHeight(this.settings.lineHeight),
120
+ fontWeight: this.settings.fontWeight || 400
121
+ }
122
+ if (this.settings.fontFamily && this.settings.fontFamily !== 'system') {
123
+ style.fontFamily = this.settings.fontFamily
124
+ }
125
+ return style
126
+ },
127
+ paragraphStyle() {
128
+ return {
129
+ marginBottom: addUnit(this.settings.paragraphSpacing || 0, 'px')
130
+ }
131
+ }
132
+ },
133
+ methods: {
134
+ getLineHeight(value) {
135
+ const lineHeight = Number(value)
136
+ if (!Number.isFinite(lineHeight)) return '1.8'
137
+ return lineHeight <= 4 ? String(lineHeight) : addUnit(lineHeight, 'px')
138
+ },
139
+ handleScroll(event) {
140
+ this.$emit('content-scroll', event)
141
+ },
142
+ handlePageChange(event) {
143
+ this.$emit('page-change', {
144
+ pageIndex: Number(event && event.detail && event.detail.current) || 0
145
+ })
146
+ },
147
+ handleTap(event) {
148
+ const detail = event && event.detail ? event.detail : {}
149
+ const x = Number(detail.x)
150
+ const width = Number(detail.width) || 0
151
+ const resolvedWidth = width || (typeof uni !== 'undefined' && uni.getWindowInfo
152
+ ? uni.getWindowInfo().windowWidth
153
+ : 375)
154
+ const zone = x < resolvedWidth / 3
155
+ ? 'left'
156
+ : x > resolvedWidth * 2 / 3
157
+ ? 'right'
158
+ : 'center'
159
+ this.$emit('tap-zone', zone)
160
+ },
161
+ retry() {
162
+ this.$emit('retry')
163
+ }
164
+ }
165
+ }
166
+ </script>
167
+
168
+ <style lang="scss" scoped>
169
+ .up-novel-reader__content {
170
+ width: 100%;
171
+ height: 100%;
172
+ overflow: hidden;
173
+ }
174
+
175
+ .up-novel-reader__scroll,
176
+ .up-novel-reader__swiper {
177
+ width: 100%;
178
+ height: 100%;
179
+ }
180
+
181
+ .up-novel-reader__article {
182
+ box-sizing: border-box;
183
+ min-height: 100%;
184
+ margin: 0 auto;
185
+ padding: 32px 0;
186
+ }
187
+
188
+ .up-novel-reader__paragraph {
189
+ white-space: pre-wrap;
190
+ word-break: break-word;
191
+ }
192
+
193
+ .up-novel-reader__page {
194
+ display: flex;
195
+ flex-direction: row;
196
+ align-items: flex-start;
197
+ justify-content: center;
198
+ overflow: hidden;
199
+ }
200
+
201
+ .up-novel-reader__page-text {
202
+ white-space: pre-wrap;
203
+ word-break: break-word;
204
+ }
205
+
206
+ .up-novel-reader__state {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ flex-direction: column;
211
+ width: 100%;
212
+ height: 100%;
213
+ gap: 16px;
214
+ }
215
+
216
+ .up-novel-reader__state-text {
217
+ color: var(--up-novel-reader-muted, #909399);
218
+ font-size: 14px;
219
+ }
220
+
221
+ .up-novel-reader__state-action {
222
+ padding: 8px 18px;
223
+ color: var(--up-primary, #2979ff);
224
+ font-size: 14px;
225
+ }
226
+ </style>
@@ -0,0 +1,151 @@
1
+ export const DEFAULT_READER_SETTINGS = {
2
+ theme: 'day',
3
+ fontSize: 18,
4
+ lineHeight: 1.8,
5
+ paragraphSpacing: 16,
6
+ contentWidth: '92%',
7
+ fontFamily: 'system',
8
+ fontWeight: 400,
9
+ animation: true
10
+ }
11
+
12
+ const SETTING_KEYS = Object.keys(DEFAULT_READER_SETTINGS)
13
+ const ALLOWED_MODES = ['scroll', 'page']
14
+
15
+ function clamp(value, min, max, fallback) {
16
+ const numericValue = Number(value)
17
+ if (!Number.isFinite(numericValue)) return fallback
18
+ return Math.min(max, Math.max(min, numericValue))
19
+ }
20
+
21
+ function normalizeContentWidth(value) {
22
+ if (typeof value === 'number') {
23
+ return clamp(value, 40, 100, 92)
24
+ }
25
+ if (typeof value === 'string' && value.trim()) {
26
+ return value.trim()
27
+ }
28
+ return DEFAULT_READER_SETTINGS.contentWidth
29
+ }
30
+
31
+ export function mergeReaderSettings(...sources) {
32
+ const result = { ...DEFAULT_READER_SETTINGS }
33
+ sources.filter(Boolean).forEach((source) => {
34
+ SETTING_KEYS.forEach((key) => {
35
+ if (source[key] !== undefined) {
36
+ result[key] = source[key]
37
+ }
38
+ })
39
+ })
40
+ result.fontSize = clamp(result.fontSize, 12, 48, DEFAULT_READER_SETTINGS.fontSize)
41
+ result.lineHeight = clamp(result.lineHeight, 1, 3, DEFAULT_READER_SETTINGS.lineHeight)
42
+ result.paragraphSpacing = clamp(result.paragraphSpacing, 0, 80, DEFAULT_READER_SETTINGS.paragraphSpacing)
43
+ result.contentWidth = normalizeContentWidth(result.contentWidth)
44
+ result.fontWeight = result.fontWeight >= 600 ? 600 : 400
45
+ result.animation = result.animation !== false
46
+ return result
47
+ }
48
+
49
+ export function normalizeMode(mode) {
50
+ return ALLOWED_MODES.includes(mode) ? mode : 'scroll'
51
+ }
52
+
53
+ export function toggleControls(state) {
54
+ return {
55
+ ...state,
56
+ controlsVisible: !state.controlsVisible
57
+ }
58
+ }
59
+
60
+ export function setMode(state, mode) {
61
+ const normalizedMode = normalizeMode(mode)
62
+ return {
63
+ ...state,
64
+ mode: normalizedMode
65
+ }
66
+ }
67
+
68
+ export function setSettings(state, settings) {
69
+ return {
70
+ ...state,
71
+ settings: mergeReaderSettings(state.settings, settings)
72
+ }
73
+ }
74
+
75
+ export function setProgress(state, progress) {
76
+ return {
77
+ ...state,
78
+ progress: {
79
+ ...state.progress,
80
+ ...progress,
81
+ pageIndex: Math.max(0, Number(progress && progress.pageIndex) || 0),
82
+ charOffset: Math.max(0, Number(progress && progress.charOffset) || 0),
83
+ scrollTop: Math.max(0, Number(progress && progress.scrollTop) || 0)
84
+ }
85
+ }
86
+ }
87
+
88
+ export function toggleBookmark(bookmarks, bookmark) {
89
+ const list = Array.isArray(bookmarks) ? bookmarks : []
90
+ const bookmarkId = bookmark && bookmark.id
91
+ if (!bookmarkId) return list.slice()
92
+ const existingIndex = list.findIndex((item) => item && item.id === bookmarkId)
93
+ if (existingIndex === -1) return [...list, bookmark]
94
+ return list.filter((_, index) => index !== existingIndex)
95
+ }
96
+
97
+ export function createBookmark({ chapterId, chapterIndex, charOffset, pageIndex, scrollTop, excerpt, createdAt } = {}) {
98
+ const normalizedChapterId = chapterId == null ? '' : String(chapterId)
99
+ const normalizedOffset = Math.max(0, Number(charOffset) || 0)
100
+ return {
101
+ id: `${normalizedChapterId}:${normalizedOffset}`,
102
+ chapterId,
103
+ chapterIndex: Number(chapterIndex) || 0,
104
+ charOffset: normalizedOffset,
105
+ pageIndex: Math.max(0, Number(pageIndex) || 0),
106
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
107
+ excerpt: excerpt || '',
108
+ createdAt: Number(createdAt) || Date.now()
109
+ }
110
+ }
111
+
112
+ export function startReading(state, timestamp = Date.now()) {
113
+ return {
114
+ ...state,
115
+ active: true,
116
+ lastActiveAt: timestamp
117
+ }
118
+ }
119
+
120
+ export function pauseReading(state, timestamp = Date.now()) {
121
+ const nextState = consumeReadingTime(state, timestamp)
122
+ return {
123
+ ...nextState,
124
+ active: false
125
+ }
126
+ }
127
+
128
+ export function consumeReadingTime(state, timestamp = Date.now()) {
129
+ if (!state.active || !state.lastActiveAt) return state
130
+ const elapsed = Math.max(0, timestamp - state.lastActiveAt)
131
+ return {
132
+ ...state,
133
+ readingTime: Math.max(0, Number(state.readingTime) || 0) + elapsed,
134
+ lastActiveAt: timestamp
135
+ }
136
+ }
137
+
138
+ export default {
139
+ DEFAULT_READER_SETTINGS,
140
+ mergeReaderSettings,
141
+ normalizeMode,
142
+ toggleControls,
143
+ setMode,
144
+ setSettings,
145
+ setProgress,
146
+ toggleBookmark,
147
+ createBookmark,
148
+ startReading,
149
+ pauseReading,
150
+ consumeReadingTime
151
+ }