@hanology/cham-browser 0.1.0 → 0.2.0

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 (43) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.js +191 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/pipeline.d.ts +14 -0
  8. package/dist/pipeline.js +377 -0
  9. package/dist/pipeline.js.map +1 -0
  10. package/package.json +22 -3
  11. package/template/index.html +29 -0
  12. package/template/src/App.vue +29 -0
  13. package/template/src/components/AnnotationLayerSelector.vue +66 -0
  14. package/template/src/components/AnnotationTooltip.vue +189 -0
  15. package/template/src/components/BookCard.vue +85 -0
  16. package/template/src/components/HorizontalDisplay.vue +100 -0
  17. package/template/src/components/PoemCard.vue +131 -0
  18. package/template/src/components/PronunciationGroup.vue +45 -0
  19. package/template/src/components/ReadingToolbar.vue +131 -0
  20. package/template/src/components/SectionBlock.vue +142 -0
  21. package/template/src/components/SideNav.vue +291 -0
  22. package/template/src/components/VerticalScroll.vue +120 -0
  23. package/template/src/composables/useAnnotationRenderer.ts +158 -0
  24. package/template/src/composables/useBook.ts +93 -0
  25. package/template/src/composables/useData.ts +41 -0
  26. package/template/src/composables/useHorizontalScroll.ts +60 -0
  27. package/template/src/composables/useLibrary.ts +40 -0
  28. package/template/src/composables/usePageLayout.ts +25 -0
  29. package/template/src/composables/useReadingMode.ts +70 -0
  30. package/template/src/composables/useTitle.ts +5 -0
  31. package/template/src/main.ts +22 -0
  32. package/template/src/router.ts +29 -0
  33. package/template/src/shims-vue.d.ts +7 -0
  34. package/template/src/styles/main.css +136 -0
  35. package/template/src/types.ts +164 -0
  36. package/template/src/utils/annotationParser.ts +58 -0
  37. package/template/src/utils/chineseNumber.ts +41 -0
  38. package/template/src/views/AuthorView.vue +338 -0
  39. package/template/src/views/BookHome.vue +375 -0
  40. package/template/src/views/LibraryHome.vue +419 -0
  41. package/template/src/views/PieceView.vue +793 -0
  42. package/src/index.ts +0 -20
  43. package/tsconfig.json +0 -16
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import type { Annotation, VerseLine } from '../types'
3
+ import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations, countVerseSpans } from '../composables/useAnnotationRenderer'
4
+
5
+ const props = defineProps<{
6
+ title: string
7
+ author: string
8
+ verses: VerseLine[]
9
+ authorInitial: string
10
+ annotations: Annotation[]
11
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ annotationHover: [event: MouseEvent, annotations: Annotation[]]
15
+ annotationLeave: []
16
+ annotationTap: [event: MouseEvent, annotations: Annotation[]]
17
+ openAuthor: [name: string]
18
+ }>()
19
+
20
+ function verseHtml(index: number): string {
21
+ let offset = 0
22
+ for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
23
+ const spans = buildVerseAnnotations(props.annotations, index)
24
+ return renderAnnotatedText(props.verses[index].text, spans, true, offset)
25
+ }
26
+
27
+ function onHover(event: MouseEvent) {
28
+ const matched = resolveHoveredAnnotations(event, props.annotations)
29
+ if (matched) emit('annotationHover', event, matched)
30
+ }
31
+
32
+ function onLeave() {
33
+ emit('annotationLeave')
34
+ }
35
+
36
+ function onTap(event: MouseEvent) {
37
+ const matched = resolveHoveredAnnotations(event, props.annotations)
38
+ if (matched) emit('annotationTap', event, matched)
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <div class="v-scroll" @mouseover="onHover" @mouseleave="onLeave" @click="onTap">
44
+ <span class="v-scroll-title">{{ title }}</span>
45
+ <span class="v-scroll-author v-scroll-clickable" @click="emit('openAuthor', author)">{{ author }}</span>
46
+ <div class="v-scroll-body">
47
+ <span
48
+ v-for="(_, i) in verses"
49
+ :key="i"
50
+ class="v-scroll-line"
51
+ v-html="verseHtml(i)"
52
+ />
53
+ </div>
54
+ </div>
55
+ </template>
56
+
57
+ <style scoped>
58
+ .v-scroll {
59
+ writing-mode: vertical-rl;
60
+ text-orientation: mixed;
61
+ height: calc(100vh - 120px);
62
+ overflow-x: auto; overflow-y: hidden;
63
+ padding: 32px 24px;
64
+ background: var(--surface);
65
+ border: 1px solid var(--border);
66
+ border-radius: 8px;
67
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
68
+ position: relative;
69
+ scrollbar-width: thin;
70
+ scrollbar-color: var(--gold) var(--paper);
71
+ }
72
+ .v-scroll::-webkit-scrollbar { height: 4px; }
73
+ .v-scroll::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
74
+
75
+ .v-scroll-title {
76
+ font-size: 36px; font-weight: 900; color: var(--ink);
77
+ letter-spacing: 12px; margin-left: 24px;
78
+ padding-left: 24px; border-left: 3px solid var(--vermillion);
79
+ line-height: 1.6;
80
+ }
81
+ .v-scroll-author {
82
+ font-size: 22px; font-weight: 400; color: var(--ink-light);
83
+ margin-left: 16px; padding-left: 16px; letter-spacing: 6px;
84
+ }
85
+ .v-scroll-clickable { cursor: pointer; transition: color 0.15s; }
86
+ .v-scroll-clickable:hover { color: var(--vermillion); }
87
+ .v-scroll-body { margin-left: 28px; }
88
+ .v-scroll-line {
89
+ font-size: var(--main-font-size, 24px); line-height: 2.4; letter-spacing: 8px;
90
+ color: var(--ink); display: block;
91
+ }
92
+
93
+ :deep(.ann-target) {
94
+ border-left: 2px solid var(--vermillion);
95
+ padding-left: 2px;
96
+ cursor: help;
97
+ transition: background 0.15s;
98
+ }
99
+ :deep(.ann-num) {
100
+ font-size: 0.45em;
101
+ font-family: var(--sans);
102
+ font-weight: 600;
103
+ color: var(--vermillion);
104
+ text-combine-upright: all;
105
+ text-align: end;
106
+ letter-spacing: 0;
107
+ }
108
+ :deep(.ann-target:hover) {
109
+ background: rgba(194, 58, 43, 0.08);
110
+ }
111
+ :deep(.ann-target.pronunciation:hover) {
112
+ background: rgba(58, 107, 94, 0.08);
113
+ }
114
+ :deep(.ann-target.pronunciation) {
115
+ border-left-color: var(--jade);
116
+ }
117
+ :deep(.ann-target.pronunciation.semantic) {
118
+ border-left-color: var(--gold);
119
+ }
120
+ </style>
@@ -0,0 +1,158 @@
1
+ import { ref } from 'vue'
2
+ import type { Annotation } from '../types'
3
+ import { useReadingMode } from './useReadingMode'
4
+ import { toChineseNumber } from '../utils/chineseNumber'
5
+
6
+ export interface AnnSpan {
7
+ start: number
8
+ end: number
9
+ annotations: Annotation[]
10
+ }
11
+
12
+ export function esc(s: string): string {
13
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14
+ }
15
+
16
+ export function buildVerseAnnotations(annotations: Annotation[], verseIndex: number): AnnSpan[] {
17
+ const anns = annotations.filter(a =>
18
+ a.range.scope === 'verse' && a.range.verseIndex === verseIndex
19
+ )
20
+ const spanMap = new Map<string, Annotation[]>()
21
+ for (const a of anns) {
22
+ const key = `${a.range.start ?? 0}:${a.range.end ?? 0}`
23
+ if (!spanMap.has(key)) spanMap.set(key, [])
24
+ spanMap.get(key)!.push(a)
25
+ }
26
+ return Array.from(spanMap.entries()).map(([k, matched]) => {
27
+ const [start, end] = k.split(':').map(Number)
28
+ return { start, end, annotations: matched }
29
+ }).sort((a, b) => a.start - b.start)
30
+ }
31
+
32
+ export function countVerseSpans(annotations: Annotation[], verseIndex: number): number {
33
+ const anns = annotations.filter(a =>
34
+ a.range.scope === 'verse' && a.range.verseIndex === verseIndex
35
+ )
36
+ const keys = new Set<string>()
37
+ for (const a of anns) {
38
+ keys.add(`${a.range.start ?? 0}:${a.range.end ?? 0}`)
39
+ }
40
+ return keys.size
41
+ }
42
+
43
+ export function renderAnnotatedText(text: string, spans: AnnSpan[], useRuby = false, startNum = 0): string {
44
+ if (!spans.length) return esc(text)
45
+
46
+ let annCounter = startNum
47
+ let html = ''
48
+ let cursor = 0
49
+ for (const span of spans) {
50
+ if (span.start > cursor) {
51
+ html += esc(text.slice(cursor, span.start))
52
+ }
53
+ annCounter++
54
+ const ids = span.annotations.map(a => a.id).join(',')
55
+ const kinds = [...new Set(span.annotations.map(a => a.kind))].join(' ')
56
+ const numText = toChineseNumber(annCounter)
57
+ const body = esc(text.slice(span.start, span.end))
58
+ if (useRuby) {
59
+ html += `<ruby class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="ann-num">${numText}</rt><rp></rp></ruby>`
60
+ } else {
61
+ html += `<span class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<sup class="ann-num">${numText}</sup></span>`
62
+ }
63
+ cursor = span.end
64
+ }
65
+ if (cursor < text.length) {
66
+ html += esc(text.slice(cursor))
67
+ }
68
+ return html
69
+ }
70
+
71
+ export interface VerseGutterRender {
72
+ textHtml: string
73
+ gutterHtml: string
74
+ }
75
+
76
+ export function renderVerseGutter(text: string, spans: AnnSpan[], startNum = 0): VerseGutterRender {
77
+ if (!spans.length) return { textHtml: esc(text), gutterHtml: '' }
78
+
79
+ const gutter = new Array<string>(text.length).fill(' ')
80
+ let annCounter = startNum
81
+ let textHtml = ''
82
+ let cursor = 0
83
+
84
+ for (const span of spans) {
85
+ if (span.start > cursor) {
86
+ textHtml += esc(text.slice(cursor, span.start))
87
+ }
88
+ annCounter++
89
+ const ids = span.annotations.map(a => a.id).join(',')
90
+ const kinds = [...new Set(span.annotations.map(a => a.kind))].join(' ')
91
+ textHtml += `<span class="ann-target ${kinds}" data-ann-ids="${ids}">${esc(text.slice(span.start, span.end))}</span>`
92
+ gutter[span.start] = `<span class="ann-gutter-num ${kinds}" data-ann-ids="${ids}">${toChineseNumber(annCounter)}</span>`
93
+ cursor = span.end
94
+ }
95
+ if (cursor < text.length) {
96
+ textHtml += esc(text.slice(cursor))
97
+ }
98
+
99
+ return { textHtml, gutterHtml: gutter.join('') }
100
+ }
101
+
102
+ export function resolveHoveredAnnotations(
103
+ event: MouseEvent,
104
+ annotations: Annotation[],
105
+ ): Annotation[] | null {
106
+ const target = (event.target as HTMLElement).closest('.ann-target') as HTMLElement | null
107
+ if (!target) return null
108
+ const ids = target.getAttribute('data-ann-ids')?.split(',') || []
109
+ const matched = annotations.filter(a => ids.includes(a.id))
110
+ return matched.length ? matched : null
111
+ }
112
+
113
+ export function useAnnotationTooltip() {
114
+ const visible = ref(false)
115
+ const items = ref<Annotation[]>([])
116
+ const style = ref<Record<string, string>>({})
117
+ const { layout } = useReadingMode()
118
+
119
+ function show(event: MouseEvent, annotations: Annotation[]) {
120
+ items.value = annotations
121
+ const el = (event.target as HTMLElement).closest('.ann-target') as HTMLElement | null
122
+ const rect = (el ?? event.target as HTMLElement).getBoundingClientRect()
123
+
124
+ if (layout.value === 'vertical') {
125
+ const left = rect.left - 12
126
+ style.value = {
127
+ right: Math.max(8, window.innerWidth - left) + 'px',
128
+ top: '50%',
129
+ transform: 'translateY(-50%)',
130
+ }
131
+ } else {
132
+ const isMobile = window.innerWidth < 768
133
+ if (isMobile) {
134
+ style.value = {
135
+ left: '4vw',
136
+ right: '4vw',
137
+ bottom: '0',
138
+ maxWidth: 'none',
139
+ }
140
+ } else {
141
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - 288))
142
+ const top = Math.max(8, rect.bottom + 8)
143
+ style.value = {
144
+ left: left + 'px',
145
+ top: Math.min(top, window.innerHeight - 200) + 'px',
146
+ }
147
+ }
148
+ }
149
+ visible.value = true
150
+ }
151
+
152
+ function hide() { visible.value = false }
153
+ function toggle(event: MouseEvent, annotations: Annotation[]) {
154
+ if (visible.value) { hide() } else { show(event, annotations) }
155
+ }
156
+
157
+ return { visible, items, style, show, hide, toggle }
158
+ }
@@ -0,0 +1,93 @@
1
+ import { ref, shallowRef, computed, type Ref, type ShallowRef, type ComputedRef } from 'vue'
2
+ import type { BookMeta, Piece, Author } from '../types'
3
+
4
+ // ─── Helpers ──────────────────────────────────────────────────
5
+
6
+ function cleanHardWraps(text: string): string {
7
+ return text
8
+ .split('\n\n')
9
+ .map(seg => seg.replace(/\n/g, ''))
10
+ .join('\n\n')
11
+ }
12
+
13
+ function cleanPiece(piece: Piece): Piece {
14
+ const sections: Record<string, string> = {}
15
+ for (const [key, val] of Object.entries(piece.sections)) {
16
+ sections[key] = cleanHardWraps(val)
17
+ }
18
+ return { ...piece, sections }
19
+ }
20
+
21
+ // ─── Book Cache (module-level singleton) ──────────────────────
22
+
23
+ const bookCache = new Map<string, { meta: BookMeta; pieces: Piece[] }>()
24
+
25
+ async function fetchBook(bookId: string): Promise<{ meta: BookMeta; pieces: Piece[] }> {
26
+ if (bookCache.has(bookId)) return bookCache.get(bookId)!
27
+
28
+ let data: { meta: BookMeta; pieces: Piece[] }
29
+
30
+ if (import.meta.env.SSR) {
31
+ const { readFileSync } = await import('fs')
32
+ const { join } = await import('path')
33
+ const base = process.env.CHAM_DATA_DIR || join('public', 'data')
34
+ data = JSON.parse(readFileSync(join(base, 'books', `${bookId}.json`), 'utf-8'))
35
+ } else {
36
+ const res = await fetch(`/data/books/${bookId}.json`)
37
+ data = await res.json()
38
+ }
39
+
40
+ data.pieces = data.pieces.map(cleanPiece)
41
+ bookCache.set(bookId, data)
42
+ return data
43
+ }
44
+
45
+ // ─── Composable ───────────────────────────────────────────────
46
+
47
+ export function useBook(): {
48
+ bookId: Ref<string>
49
+ pieces: ShallowRef<Piece[]>
50
+ meta: Ref<BookMeta | null>
51
+ loaded: ComputedRef<boolean>
52
+ load: (id: string) => Promise<void>
53
+ getPiece: (num: number) => Piece | undefined
54
+ getPiecesByAuthor: (name: string) => Piece[]
55
+ getAdjacentNums: (num: number) => { prev: number | null; next: number | null }
56
+ } {
57
+ const bookId = ref('')
58
+ const pieces = shallowRef<Piece[]>([])
59
+ const meta = ref<BookMeta | null>(null)
60
+ const loaded = computed(() => meta.value !== null)
61
+
62
+ // Index maps (rebuilt on load)
63
+ let byNum = new Map<number, Piece>()
64
+
65
+ async function load(id: string): Promise<void> {
66
+ bookId.value = id
67
+ const data = await fetchBook(id)
68
+ pieces.value = data.pieces
69
+ meta.value = data.meta
70
+
71
+ byNum = new Map()
72
+ for (const p of data.pieces) byNum.set(p.num, p)
73
+ }
74
+
75
+ function getPiece(num: number): Piece | undefined {
76
+ return byNum.get(num)
77
+ }
78
+
79
+ function getPiecesByAuthor(name: string): Piece[] {
80
+ return pieces.value.filter(p => p.author === name)
81
+ }
82
+
83
+ function getAdjacentNums(num: number): { prev: number | null; next: number | null } {
84
+ const nums = pieces.value.map(p => p.num).sort((a, b) => a - b)
85
+ const idx = nums.indexOf(num)
86
+ return {
87
+ prev: idx > 0 ? nums[idx - 1] : null,
88
+ next: idx < nums.length - 1 ? nums[idx + 1] : null,
89
+ }
90
+ }
91
+
92
+ return { bookId, pieces, meta, loaded, load, getPiece, getPiecesByAuthor, getAdjacentNums }
93
+ }
@@ -0,0 +1,41 @@
1
+ import { ref, shallowRef, computed } from 'vue'
2
+ import type { Author, Dynasty } from '../types'
3
+
4
+ const authors = shallowRef<Author[]>([])
5
+ const dynasties = ref<Record<string, Dynasty>>({})
6
+ const sharedLoaded = ref(false)
7
+
8
+ async function loadShared(): Promise<void> {
9
+ if (sharedLoaded.value) return
10
+
11
+ if (import.meta.env.SSR) {
12
+ const { readFileSync } = await import('fs')
13
+ const { resolve } = await import('path')
14
+ const base = process.env.CHAM_DATA_DIR || resolve('public/data')
15
+ authors.value = JSON.parse(readFileSync(`${base}/authors.json`, 'utf-8'))
16
+ dynasties.value = JSON.parse(readFileSync(`${base}/dynasties.json`, 'utf-8'))
17
+ } else {
18
+ const [aRes, dRes] = await Promise.all([
19
+ fetch('/data/authors.json'),
20
+ fetch('/data/dynasties.json'),
21
+ ])
22
+ authors.value = await aRes.json()
23
+ dynasties.value = await dRes.json()
24
+ }
25
+
26
+ sharedLoaded.value = true
27
+ }
28
+
29
+ const authorByName = computed(() => {
30
+ const map = new Map<string, Author>()
31
+ for (const a of authors.value) map.set(a.name, a)
32
+ return map
33
+ })
34
+
35
+ export function useData() {
36
+ function getAuthor(name: string): Author | undefined {
37
+ return authorByName.value.get(name)
38
+ }
39
+
40
+ return { authors, dynasties, loaded: sharedLoaded, loadShared, getAuthor }
41
+ }
@@ -0,0 +1,60 @@
1
+ import { type Ref, onMounted, onBeforeUnmount, readonly, ref } from 'vue'
2
+
3
+ export function useHorizontalScroll(container: Ref<HTMLElement | null>) {
4
+ const isScrolling = ref(false)
5
+ let scrollTimer: ReturnType<typeof setTimeout> | null = null
6
+
7
+ function isTrackpad(e: WheelEvent): boolean {
8
+ // Trackpad produces smooth, small deltas; mouse wheel produces large, quantized deltas
9
+ return e.deltaMode === 0 && (Math.abs(e.deltaY) < 12 && e.deltaY !== 0)
10
+ || Math.abs(e.deltaX) > 0
11
+ }
12
+
13
+ function onWheel(e: WheelEvent) {
14
+ const el = container.value
15
+ if (!el) return
16
+
17
+ if (isTrackpad(e)) return
18
+
19
+ e.preventDefault()
20
+ // Mouse wheel: swap scroll direction so scroll-up → right, scroll-down → left
21
+ el.scrollLeft -= e.deltaY
22
+ }
23
+
24
+ function scrollToStart() {
25
+ const el = container.value
26
+ if (!el) return
27
+ el.scrollLeft = 0
28
+ }
29
+
30
+ function scrollToEnd() {
31
+ const el = container.value
32
+ if (!el) return
33
+ el.scrollLeft = el.scrollWidth - el.clientWidth
34
+ }
35
+
36
+ function onScroll() {
37
+ isScrolling.value = true
38
+ if (scrollTimer) clearTimeout(scrollTimer)
39
+ scrollTimer = setTimeout(() => {
40
+ isScrolling.value = false
41
+ }, 150)
42
+ }
43
+
44
+ onMounted(() => {
45
+ const el = container.value
46
+ if (!el) return
47
+ el.addEventListener('wheel', onWheel, { passive: false })
48
+ el.addEventListener('scroll', onScroll, { passive: true })
49
+ })
50
+
51
+ onBeforeUnmount(() => {
52
+ const el = container.value
53
+ if (!el) return
54
+ el.removeEventListener('wheel', onWheel)
55
+ el.removeEventListener('scroll', onScroll)
56
+ if (scrollTimer) clearTimeout(scrollTimer)
57
+ })
58
+
59
+ return { isScrolling: readonly(isScrolling), scrollToStart, scrollToEnd }
60
+ }
@@ -0,0 +1,40 @@
1
+ import { ref, computed, type Ref, type ComputedRef } from 'vue'
2
+ import type { LibraryIndex, LibraryScale, BookMeta } from '../types'
3
+
4
+ const library: Ref<LibraryIndex | null> = ref(null)
5
+ const loaded = ref(false)
6
+
7
+ async function loadLibrary(): Promise<void> {
8
+ if (loaded.value) return
9
+
10
+ if (import.meta.env.SSR) {
11
+ const { readFileSync } = await import('fs')
12
+ const { resolve, join } = await import('path')
13
+ const base = process.env.CHAM_DATA_DIR || resolve('public/data')
14
+ library.value = JSON.parse(readFileSync(join(base, 'library.json'), 'utf-8'))
15
+ } else {
16
+ const res = await fetch('/data/library.json')
17
+ library.value = await res.json()
18
+ }
19
+
20
+ loaded.value = true
21
+ }
22
+
23
+ export function useLibrary(): {
24
+ library: Ref<LibraryIndex | null>
25
+ loaded: Ref<boolean>
26
+ scale: ComputedRef<LibraryScale>
27
+ books: ComputedRef<BookMeta[]>
28
+ singleBook: ComputedRef<BookMeta | null>
29
+ isSinglePiece: ComputedRef<boolean>
30
+ loadLibrary: () => Promise<void>
31
+ } {
32
+ const scale = computed<LibraryScale>(() => library.value?.scale ?? 'library')
33
+ const books = computed<BookMeta[]>(() => library.value?.books ?? [])
34
+ const singleBook = computed(() => books.value.length === 1 ? books.value[0] : null)
35
+ const isSinglePiece = computed(() =>
36
+ scale.value === 'single-piece'
37
+ )
38
+
39
+ return { library, loaded, scale, books, singleBook, isSinglePiece, loadLibrary }
40
+ }
@@ -0,0 +1,25 @@
1
+ import { computed, ref, type Ref } from 'vue'
2
+ import { useReadingMode } from './useReadingMode'
3
+ import { useHorizontalScroll } from './useHorizontalScroll'
4
+
5
+ export interface PageLayoutConfig {
6
+ mode: 'vertical' | 'horizontal'
7
+ navSide: 'top' | 'right'
8
+ contentDirection: 'ltr' | 'rtl'
9
+ isVertical: boolean
10
+ }
11
+
12
+ export function usePageLayout() {
13
+ const { layout } = useReadingMode()
14
+ const scrollRef = ref<HTMLElement | null>(null)
15
+ const scroll = useHorizontalScroll(scrollRef)
16
+
17
+ const config = computed<PageLayoutConfig>(() => ({
18
+ mode: layout.value,
19
+ navSide: layout.value === 'vertical' ? 'right' : 'top',
20
+ contentDirection: layout.value === 'vertical' ? 'rtl' : 'ltr',
21
+ isVertical: layout.value === 'vertical',
22
+ }))
23
+
24
+ return { config, scrollRef, ...scroll }
25
+ }
@@ -0,0 +1,70 @@
1
+ import { ref, watch } from 'vue'
2
+
3
+ export type Theme = 'light' | 'sepia' | 'dark' | 'oled'
4
+ export type LayoutMode = 'horizontal' | 'vertical'
5
+
6
+ export const THEMES: Theme[] = ['light', 'sepia', 'dark', 'oled']
7
+
8
+ export const THEME_LABELS: Record<Theme, string> = {
9
+ light: '亮',
10
+ sepia: '暖',
11
+ dark: '暗',
12
+ oled: '黑',
13
+ }
14
+
15
+ export const FONT_SIZES = [12, 14, 16, 18, 20, 22, 24, 28, 32] as const
16
+ export type FontSize = typeof FONT_SIZES[number]
17
+
18
+ const theme = ref<Theme>('light')
19
+ const layout = ref<LayoutMode>('vertical')
20
+ const mainFontSize = ref<FontSize>(24)
21
+ const bodyFontSize = ref<FontSize>(16)
22
+
23
+ if (!import.meta.env.SSR) {
24
+ const savedTheme = localStorage.getItem('theme') as Theme | null
25
+ if (savedTheme && THEMES.includes(savedTheme)) theme.value = savedTheme
26
+
27
+ const savedLayout = localStorage.getItem('layout') as LayoutMode | null
28
+ if (savedLayout === 'vertical' || savedLayout === 'horizontal') layout.value = savedLayout
29
+
30
+ const savedMain = parseInt(localStorage.getItem('mainFontSize') || '', 10)
31
+ if (FONT_SIZES.includes(savedMain as any)) mainFontSize.value = savedMain as FontSize
32
+
33
+ const savedBody = parseInt(localStorage.getItem('bodyFontSize') || '', 10)
34
+ if (FONT_SIZES.includes(savedBody as any)) bodyFontSize.value = savedBody as FontSize
35
+
36
+ watch(theme, t => {
37
+ document.documentElement.setAttribute('data-theme', t)
38
+ localStorage.setItem('theme', t)
39
+ }, { immediate: true })
40
+
41
+ watch(layout, l => {
42
+ document.documentElement.setAttribute('data-layout', l)
43
+ localStorage.setItem('layout', l)
44
+ }, { immediate: true })
45
+
46
+ watch(mainFontSize, s => {
47
+ document.documentElement.style.setProperty('--main-font-size', s + 'px')
48
+ localStorage.setItem('mainFontSize', String(s))
49
+ }, { immediate: true })
50
+
51
+ watch(bodyFontSize, s => {
52
+ document.documentElement.style.setProperty('--body-font-size', s + 'px')
53
+ localStorage.setItem('bodyFontSize', String(s))
54
+ }, { immediate: true })
55
+ }
56
+
57
+ export function useReadingMode() {
58
+ function setTheme(t: Theme) { theme.value = t }
59
+ function cycleTheme() {
60
+ const idx = THEMES.indexOf(theme.value)
61
+ theme.value = THEMES[(idx + 1) % THEMES.length]
62
+ }
63
+ function setLayout(l: LayoutMode) { layout.value = l }
64
+ function toggleLayout() {
65
+ layout.value = layout.value === 'horizontal' ? 'vertical' : 'horizontal'
66
+ }
67
+ function setMainFontSize(s: FontSize) { mainFontSize.value = s }
68
+ function setBodyFontSize(s: FontSize) { bodyFontSize.value = s }
69
+ return { theme, layout, mainFontSize, bodyFontSize, setTheme, cycleTheme, setLayout, toggleLayout, setMainFontSize, setBodyFontSize }
70
+ }
@@ -0,0 +1,5 @@
1
+ import { useHead } from '@unhead/vue'
2
+
3
+ export function useTitle(title: string) {
4
+ useHead({ title })
5
+ }
@@ -0,0 +1,22 @@
1
+ import { createApp as _createApp } from 'vue'
2
+ import { createHead as createHeadSSR } from '@unhead/vue/server'
3
+ import { createHead as createHeadClient } from '@unhead/vue/client'
4
+ import { createRouterInstance, routes } from './router'
5
+ import App from './App.vue'
6
+ import './styles/main.css'
7
+
8
+ export async function createApp() {
9
+ const router = createRouterInstance()
10
+ const head = import.meta.env.SSR ? createHeadSSR() : createHeadClient()
11
+ const app = _createApp(App)
12
+ app.use(head)
13
+ app.use(router)
14
+ return { app, router, routes, head }
15
+ }
16
+
17
+ if (!import.meta.env.SSR) {
18
+ createApp().then(({ app, router }) => {
19
+ app.mount('#app')
20
+ document.getElementById('app-loading')?.remove()
21
+ })
22
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ createRouter,
3
+ createWebHistory,
4
+ createMemoryHistory,
5
+ type RouteRecordRaw,
6
+ } from 'vue-router'
7
+ import LibraryHome from './views/LibraryHome.vue'
8
+ import BookHome from './views/BookHome.vue'
9
+ import PieceView from './views/PieceView.vue'
10
+ import AuthorView from './views/AuthorView.vue'
11
+
12
+ export const routes: RouteRecordRaw[] = [
13
+ { path: '/', component: LibraryHome },
14
+ { path: '/author/:name', component: AuthorView, props: true },
15
+ { path: '/:bookId', component: BookHome, props: true },
16
+ { path: '/:bookId/:num', component: PieceView, props: true },
17
+ ]
18
+
19
+ export function createRouterInstance() {
20
+ return createRouter({
21
+ history: typeof window !== 'undefined'
22
+ ? createWebHistory()
23
+ : createMemoryHistory(),
24
+ routes,
25
+ scrollBehavior() {
26
+ return { top: 0 }
27
+ },
28
+ })
29
+ }
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<object, object, unknown>
6
+ export default component
7
+ }