@hanology/cham-browser 0.4.71 → 0.4.73
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.
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
|
|
5
|
+
const appVue = readFileSync(
|
|
6
|
+
resolve(__dirname, '../src/App.vue'),
|
|
7
|
+
'utf-8',
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
describe('App.vue structure', () => {
|
|
11
|
+
it('wraps router-view in Suspense', () => {
|
|
12
|
+
expect(appVue).toContain('<Suspense')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('Suspense has a fallback slot with route-loading', () => {
|
|
16
|
+
expect(appVue).toContain('#fallback')
|
|
17
|
+
expect(appVue).toContain('route-loading')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('component uses route.fullPath as key', () => {
|
|
21
|
+
expect(appVue).toMatch(/:key="route\.fullPath"/)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders <component :is="Component" /> inside Suspense', () => {
|
|
25
|
+
expect(appVue).toContain(':is="Component"')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('has page-fade CSS transition classes', () => {
|
|
29
|
+
expect(appVue).toContain('.page-fade-enter-active')
|
|
30
|
+
expect(appVue).toContain('.page-fade-leave-active')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does NOT wrap Suspense in Transition (causes async nav failures)', () => {
|
|
34
|
+
// Transition + Suspense combination breaks async component navigation
|
|
35
|
+
expect(appVue).not.toMatch(/<Transition[^>]*>[\s\S]*<Suspense/)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import type { BookMeta, Piece } from '../src/types'
|
|
3
|
+
|
|
4
|
+
function makePiece(overrides: Partial<Piece> & { num: number }): Piece {
|
|
5
|
+
return {
|
|
6
|
+
bookId: 'test',
|
|
7
|
+
title: 'Test Piece',
|
|
8
|
+
author: 'Tester',
|
|
9
|
+
authorId: 'tester',
|
|
10
|
+
era: 'Tang',
|
|
11
|
+
genre: 'poetry',
|
|
12
|
+
verses: [{ text: '床前明月光' }],
|
|
13
|
+
sections: {},
|
|
14
|
+
annotations: [],
|
|
15
|
+
...overrides,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeMeta(overrides: Partial<BookMeta> = {}): BookMeta {
|
|
20
|
+
return {
|
|
21
|
+
id: 'test',
|
|
22
|
+
title: 'Test Book',
|
|
23
|
+
genre: 'poetry',
|
|
24
|
+
count: 2,
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useBook', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.resetModules()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('getPiece returns piece by num after load', async () => {
|
|
35
|
+
const pieces = [
|
|
36
|
+
makePiece({ num: 1, title: 'First' }),
|
|
37
|
+
makePiece({ num: 2, title: 'Second' }),
|
|
38
|
+
]
|
|
39
|
+
vi.stubGlobal('fetch', () =>
|
|
40
|
+
Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
|
|
41
|
+
)
|
|
42
|
+
vi.stubGlobal('import.meta', { env: { SSR: false } })
|
|
43
|
+
|
|
44
|
+
const { useBook } = await import('../src/composables/useBook')
|
|
45
|
+
const { load, getPiece, loaded } = useBook()
|
|
46
|
+
expect(loaded.value).toBe(false)
|
|
47
|
+
await load('test')
|
|
48
|
+
expect(loaded.value).toBe(true)
|
|
49
|
+
expect(getPiece(1)?.title).toBe('First')
|
|
50
|
+
expect(getPiece(2)?.title).toBe('Second')
|
|
51
|
+
expect(getPiece(3)).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('getAdjacentNums returns prev and next', async () => {
|
|
55
|
+
const pieces = [
|
|
56
|
+
makePiece({ num: 1 }),
|
|
57
|
+
makePiece({ num: 3 }),
|
|
58
|
+
makePiece({ num: 7 }),
|
|
59
|
+
]
|
|
60
|
+
vi.stubGlobal('fetch', () =>
|
|
61
|
+
Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
|
|
62
|
+
)
|
|
63
|
+
vi.stubGlobal('import.meta', { env: { SSR: false } })
|
|
64
|
+
|
|
65
|
+
const { useBook } = await import('../src/composables/useBook')
|
|
66
|
+
const { load, getAdjacentNums } = useBook()
|
|
67
|
+
await load('test')
|
|
68
|
+
|
|
69
|
+
expect(getAdjacentNums(1)).toEqual({ prev: null, next: 3 })
|
|
70
|
+
expect(getAdjacentNums(3)).toEqual({ prev: 1, next: 7 })
|
|
71
|
+
expect(getAdjacentNums(7)).toEqual({ prev: 3, next: null })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('getPiecesByAuthor filters by author name', async () => {
|
|
75
|
+
const pieces = [
|
|
76
|
+
makePiece({ num: 1, author: '李白' }),
|
|
77
|
+
makePiece({ num: 2, author: '杜甫' }),
|
|
78
|
+
makePiece({ num: 3, author: '李白' }),
|
|
79
|
+
]
|
|
80
|
+
vi.stubGlobal('fetch', () =>
|
|
81
|
+
Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
|
|
82
|
+
)
|
|
83
|
+
vi.stubGlobal('import.meta', { env: { SSR: false } })
|
|
84
|
+
|
|
85
|
+
const { useBook } = await import('../src/composables/useBook')
|
|
86
|
+
const { load, getPiecesByAuthor } = useBook()
|
|
87
|
+
await load('test')
|
|
88
|
+
|
|
89
|
+
const byLi = getPiecesByAuthor('李白')
|
|
90
|
+
expect(byLi).toHaveLength(2)
|
|
91
|
+
expect(byLi.every(p => p.author === '李白')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('cleans hard wraps in piece sections', async () => {
|
|
95
|
+
const pieces = [
|
|
96
|
+
makePiece({
|
|
97
|
+
num: 1,
|
|
98
|
+
sections: { body: '第一行\n第二行\n\n第三段\n第四段' },
|
|
99
|
+
}),
|
|
100
|
+
]
|
|
101
|
+
vi.stubGlobal('fetch', () =>
|
|
102
|
+
Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
|
|
103
|
+
)
|
|
104
|
+
vi.stubGlobal('import.meta', { env: { SSR: false } })
|
|
105
|
+
|
|
106
|
+
const { useBook } = await import('../src/composables/useBook')
|
|
107
|
+
const { load, getPiece } = useBook()
|
|
108
|
+
await load('test')
|
|
109
|
+
|
|
110
|
+
const p = getPiece(1)!
|
|
111
|
+
// Single newlines within paragraphs should be removed, double newlines preserved
|
|
112
|
+
expect(p.sections.body).toBe('第一行第二行\n\n第三段第四段')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { nextTick } from 'vue'
|
|
3
|
+
|
|
4
|
+
// useReadingMode accesses localStorage and document at module level,
|
|
5
|
+
// so we must mock them before importing.
|
|
6
|
+
const localStorageStore: Record<string, string> = {}
|
|
7
|
+
const attributeStore: Record<string, string> = {}
|
|
8
|
+
|
|
9
|
+
vi.stubGlobal('localStorage', {
|
|
10
|
+
getItem: (k: string) => localStorageStore[k] ?? null,
|
|
11
|
+
setItem: (k: string, v: string) => { localStorageStore[k] = v },
|
|
12
|
+
removeItem: (k: string) => { delete localStorageStore[k] },
|
|
13
|
+
clear: () => Object.keys(localStorageStore).forEach(k => delete localStorageStore[k]),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
vi.stubGlobal('document', {
|
|
17
|
+
documentElement: {
|
|
18
|
+
setAttribute: (k: string, v: string) => { attributeStore[k] = v },
|
|
19
|
+
getAttribute: (k: string) => attributeStore[k] ?? null,
|
|
20
|
+
style: {
|
|
21
|
+
setProperty: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('useReadingMode', () => {
|
|
27
|
+
let useReadingMode: typeof import('../src/composables/useReadingMode').useReadingMode
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
// Clear stores
|
|
31
|
+
Object.keys(localStorageStore).forEach(k => delete localStorageStore[k])
|
|
32
|
+
Object.keys(attributeStore).forEach(k => delete attributeStore[k])
|
|
33
|
+
// Import fresh each time to exercise module-level init
|
|
34
|
+
vi.resetModules()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('defaults to vertical layout when no saved preference', async () => {
|
|
38
|
+
const mod = await import('../src/composables/useReadingMode')
|
|
39
|
+
useReadingMode = mod.useReadingMode
|
|
40
|
+
const { layout } = useReadingMode()
|
|
41
|
+
expect(layout.value).toBe('vertical')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('restores saved horizontal layout after nextTick', async () => {
|
|
45
|
+
localStorageStore.layout = 'horizontal'
|
|
46
|
+
const mod = await import('../src/composables/useReadingMode')
|
|
47
|
+
useReadingMode = mod.useReadingMode
|
|
48
|
+
const { layout } = useReadingMode()
|
|
49
|
+
// Module-level nextTick runs during import, so by now layout is restored
|
|
50
|
+
await nextTick()
|
|
51
|
+
expect(layout.value).toBe('horizontal')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('toggleLayout switches between horizontal and vertical', async () => {
|
|
55
|
+
const mod = await import('../src/composables/useReadingMode')
|
|
56
|
+
useReadingMode = mod.useReadingMode
|
|
57
|
+
const { layout, toggleLayout } = useReadingMode()
|
|
58
|
+
expect(layout.value).toBe('vertical')
|
|
59
|
+
toggleLayout()
|
|
60
|
+
expect(layout.value).toBe('horizontal')
|
|
61
|
+
toggleLayout()
|
|
62
|
+
expect(layout.value).toBe('vertical')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('cycleTheme rotates through themes', async () => {
|
|
66
|
+
const mod = await import('../src/composables/useReadingMode')
|
|
67
|
+
useReadingMode = mod.useReadingMode
|
|
68
|
+
const { theme, cycleTheme } = useReadingMode()
|
|
69
|
+
const themes = ['light', 'sepia', 'dark', 'oled']
|
|
70
|
+
expect(theme.value).toBe('light')
|
|
71
|
+
for (let i = 1; i <= 4; i++) {
|
|
72
|
+
cycleTheme()
|
|
73
|
+
expect(theme.value).toBe(themes[i % 4])
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('layout watcher writes to localStorage (not immediate)', async () => {
|
|
78
|
+
localStorageStore.layout = 'horizontal'
|
|
79
|
+
const mod = await import('../src/composables/useReadingMode')
|
|
80
|
+
useReadingMode = mod.useReadingMode
|
|
81
|
+
const { layout, setLayout } = useReadingMode()
|
|
82
|
+
// Layout watcher should NOT have overwritten saved value immediately
|
|
83
|
+
expect(localStorageStore.layout).toBe('horizontal')
|
|
84
|
+
await nextTick()
|
|
85
|
+
expect(localStorageStore.layout).toBe('horizontal')
|
|
86
|
+
// Now change layout — watcher should fire
|
|
87
|
+
setLayout('vertical')
|
|
88
|
+
await nextTick()
|
|
89
|
+
expect(localStorageStore.layout).toBe('vertical')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('exposes all expected reactive refs and setters', async () => {
|
|
93
|
+
const mod = await import('../src/composables/useReadingMode')
|
|
94
|
+
useReadingMode = mod.useReadingMode
|
|
95
|
+
const r = useReadingMode()
|
|
96
|
+
expect(r.theme).toBeDefined()
|
|
97
|
+
expect(r.layout).toBeDefined()
|
|
98
|
+
expect(r.mainFontSize).toBeDefined()
|
|
99
|
+
expect(r.bodyFontSize).toBeDefined()
|
|
100
|
+
expect(r.annotationsVisible).toBeDefined()
|
|
101
|
+
expect(r.annotationPane).toBeDefined()
|
|
102
|
+
expect(r.setTheme).toBeTypeOf('function')
|
|
103
|
+
expect(r.cycleTheme).toBeTypeOf('function')
|
|
104
|
+
expect(r.setLayout).toBeTypeOf('function')
|
|
105
|
+
expect(r.toggleLayout).toBeTypeOf('function')
|
|
106
|
+
expect(r.setMainFontSize).toBeTypeOf('function')
|
|
107
|
+
expect(r.setBodyFontSize).toBeTypeOf('function')
|
|
108
|
+
expect(r.setAnnotationsVisible).toBeTypeOf('function')
|
|
109
|
+
expect(r.toggleAnnotationsVisible).toBeTypeOf('function')
|
|
110
|
+
expect(r.setAnnotationPane).toBeTypeOf('function')
|
|
111
|
+
expect(r.toggleAnnotationPane).toBeTypeOf('function')
|
|
112
|
+
})
|
|
113
|
+
})
|
package/template/src/App.vue
CHANGED
|
@@ -52,17 +52,15 @@ function onKey(event: KeyboardEvent) {
|
|
|
52
52
|
<template>
|
|
53
53
|
<div @keydown="onKey">
|
|
54
54
|
<router-view v-slot="{ Component, route }">
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</Suspense>
|
|
65
|
-
</Transition>
|
|
55
|
+
<Suspense>
|
|
56
|
+
<component :is="Component" :key="route.fullPath" />
|
|
57
|
+
<template #fallback>
|
|
58
|
+
<div class="route-loading">
|
|
59
|
+
<img v-if="logoUrl" :src="logoUrl" alt="" class="route-loading-logo" />
|
|
60
|
+
<div v-else class="route-loading-seal">文</div>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
</Suspense>
|
|
66
64
|
</router-view>
|
|
67
65
|
<!-- 橫排模式才顯示浮動設定鈕 -->
|
|
68
66
|
<ReadingToolbar v-if="!isVertical" />
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import type { Annotation, VerseLine, PieceSource } from '../types'
|
|
4
4
|
import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations } from '../composables/useAnnotationRenderer'
|
|
5
|
+
import { parseAnnotationBlock } from '../utils/annotationParser'
|
|
6
|
+
import PronunciationGroup from './PronunciationGroup.vue'
|
|
5
7
|
|
|
6
8
|
const props = defineProps<{
|
|
7
9
|
num: number
|
|
@@ -54,6 +56,10 @@ const sourceLabel = (() => {
|
|
|
54
56
|
const r = props.source?.range as Record<string, string> | undefined
|
|
55
57
|
return r?.chapter || ''
|
|
56
58
|
})()
|
|
59
|
+
|
|
60
|
+
const annotationEntries = computed(() =>
|
|
61
|
+
props.annotationText ? parseAnnotationBlock(props.annotationText) : []
|
|
62
|
+
)
|
|
57
63
|
</script>
|
|
58
64
|
|
|
59
65
|
<template>
|
|
@@ -69,7 +75,22 @@ const sourceLabel = (() => {
|
|
|
69
75
|
v-html="verseHtml(i)"
|
|
70
76
|
/>
|
|
71
77
|
</div>
|
|
72
|
-
<div v-if="annotationText" class="part-annotations">
|
|
78
|
+
<div v-if="annotationText && annotationEntries.length > 0" class="part-annotations">
|
|
79
|
+
<div v-for="entry in annotationEntries" :key="entry.num" class="part-ann-entry">
|
|
80
|
+
<div class="part-ann-head">
|
|
81
|
+
<span class="part-ann-num">{{ entry.numDisplay }}</span>
|
|
82
|
+
<span class="part-ann-term">{{ entry.term }}</span>
|
|
83
|
+
<PronunciationGroup
|
|
84
|
+
v-for="seg in entry.pronSegments"
|
|
85
|
+
:key="seg.lang"
|
|
86
|
+
:segment="seg"
|
|
87
|
+
class="part-ann-pron"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
<div v-if="entry.definition" class="part-ann-def">{{ entry.definition }}</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div v-else-if="annotationText" class="part-annotations">
|
|
73
94
|
<div v-for="line in annotationText.split('\n')" :key="line" class="part-ann-line">{{ line }}</div>
|
|
74
95
|
</div>
|
|
75
96
|
</div>
|
|
@@ -114,6 +135,59 @@ const sourceLabel = (() => {
|
|
|
114
135
|
margin-top: 16px;
|
|
115
136
|
padding-top: 12px;
|
|
116
137
|
border-top: 1px dashed var(--border-light);
|
|
138
|
+
text-align: start;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.part-ann-entry {
|
|
142
|
+
padding: 12px 0;
|
|
143
|
+
border-bottom: 1px solid var(--border-light);
|
|
144
|
+
}
|
|
145
|
+
.part-ann-entry:last-child {
|
|
146
|
+
border-bottom: none;
|
|
147
|
+
padding-bottom: 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.part-ann-head {
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-wrap: wrap;
|
|
153
|
+
align-items: baseline;
|
|
154
|
+
gap: 6px 10px;
|
|
155
|
+
margin-bottom: 4px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.part-ann-num {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
justify-content: center;
|
|
162
|
+
width: 22px;
|
|
163
|
+
height: 22px;
|
|
164
|
+
border-radius: 4px;
|
|
165
|
+
background: var(--vermillion);
|
|
166
|
+
color: var(--paper);
|
|
167
|
+
font-family: var(--sans);
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
font-weight: 700;
|
|
170
|
+
flex-shrink: 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.part-ann-term {
|
|
174
|
+
font-weight: 700;
|
|
175
|
+
font-size: 1.05em;
|
|
176
|
+
color: var(--ink);
|
|
177
|
+
padding: 2px 8px;
|
|
178
|
+
background: var(--surface-warm);
|
|
179
|
+
border-radius: 3px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.part-ann-pron {
|
|
183
|
+
margin-left: 2px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.part-ann-def {
|
|
187
|
+
color: var(--ink-mid);
|
|
188
|
+
line-height: 2;
|
|
189
|
+
white-space: pre-line;
|
|
190
|
+
padding-left: 32px;
|
|
117
191
|
}
|
|
118
192
|
|
|
119
193
|
.part-ann-line {
|
|
@@ -188,4 +262,36 @@ const sourceLabel = (() => {
|
|
|
188
262
|
border-top: none;
|
|
189
263
|
border-left: 1px dashed var(--border-light);
|
|
190
264
|
}
|
|
265
|
+
|
|
266
|
+
.part-block--vertical .part-ann-entry {
|
|
267
|
+
margin-bottom: 0;
|
|
268
|
+
margin-left: 16px;
|
|
269
|
+
padding: 0;
|
|
270
|
+
border-bottom: none;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.part-block--vertical .part-ann-head {
|
|
274
|
+
align-items: flex-start;
|
|
275
|
+
gap: 4px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.part-block--vertical .part-ann-num {
|
|
279
|
+
width: auto;
|
|
280
|
+
height: auto;
|
|
281
|
+
border-radius: 0;
|
|
282
|
+
background: none;
|
|
283
|
+
color: var(--vermillion);
|
|
284
|
+
font-size: inherit;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.part-block--vertical .part-ann-term {
|
|
288
|
+
background: none;
|
|
289
|
+
padding: 0;
|
|
290
|
+
font-size: inherit;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.part-block--vertical .part-ann-def {
|
|
294
|
+
padding-left: 0;
|
|
295
|
+
margin-left: 12px;
|
|
296
|
+
}
|
|
191
297
|
</style>
|