@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.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +191 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts +14 -0
- package/dist/pipeline.js +377 -0
- package/dist/pipeline.js.map +1 -0
- package/package.json +22 -3
- package/template/index.html +29 -0
- package/template/src/App.vue +29 -0
- package/template/src/components/AnnotationLayerSelector.vue +66 -0
- package/template/src/components/AnnotationTooltip.vue +189 -0
- package/template/src/components/BookCard.vue +85 -0
- package/template/src/components/HorizontalDisplay.vue +100 -0
- package/template/src/components/PoemCard.vue +131 -0
- package/template/src/components/PronunciationGroup.vue +45 -0
- package/template/src/components/ReadingToolbar.vue +131 -0
- package/template/src/components/SectionBlock.vue +142 -0
- package/template/src/components/SideNav.vue +291 -0
- package/template/src/components/VerticalScroll.vue +120 -0
- package/template/src/composables/useAnnotationRenderer.ts +158 -0
- package/template/src/composables/useBook.ts +93 -0
- package/template/src/composables/useData.ts +41 -0
- package/template/src/composables/useHorizontalScroll.ts +60 -0
- package/template/src/composables/useLibrary.ts +40 -0
- package/template/src/composables/usePageLayout.ts +25 -0
- package/template/src/composables/useReadingMode.ts +70 -0
- package/template/src/composables/useTitle.ts +5 -0
- package/template/src/main.ts +22 -0
- package/template/src/router.ts +29 -0
- package/template/src/shims-vue.d.ts +7 -0
- package/template/src/styles/main.css +136 -0
- package/template/src/types.ts +164 -0
- package/template/src/utils/annotationParser.ts +58 -0
- package/template/src/utils/chineseNumber.ts +41 -0
- package/template/src/views/AuthorView.vue +338 -0
- package/template/src/views/BookHome.vue +375 -0
- package/template/src/views/LibraryHome.vue +419 -0
- package/template/src/views/PieceView.vue +793 -0
- package/src/index.ts +0 -20
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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,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
|
+
}
|