@hanology/cham-browser 0.3.9 → 0.4.2
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.js +303 -32
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/template/index.html +4 -8
- package/template/src/App.vue +101 -17
- package/template/src/components/AnnotationControlBar.vue +119 -49
- package/template/src/components/AnnotationTooltip.vue +319 -95
- package/template/src/components/BackToTop.vue +4 -0
- package/template/src/components/BookCard.vue +10 -11
- package/template/src/components/HorizontalDisplay.vue +56 -0
- package/template/src/components/PartBlock.vue +9 -0
- package/template/src/components/PoemCard.vue +1 -0
- package/template/src/components/PronunciationGroup.vue +27 -18
- package/template/src/components/ReadingToolbar.vue +20 -0
- package/template/src/components/SectionBlock.vue +91 -12
- package/template/src/components/SideNav.vue +5 -4
- package/template/src/components/VerticalScroll.vue +35 -0
- package/template/src/composables/useAnnotationRenderer.ts +57 -25
- package/template/src/composables/useData.ts +6 -1
- package/template/src/composables/useI18n.ts +36 -3
- package/template/src/composables/useReadingMode.ts +9 -4
- package/template/src/composables/useSiteConfig.ts +12 -1
- package/template/src/router.ts +0 -2
- package/template/src/styles/main.css +88 -0
- package/template/src/types.ts +12 -4
- package/template/src/views/AuthorView.vue +5 -5
- package/template/src/views/BookHome.vue +45 -21
- package/template/src/views/LibraryHome.vue +39 -41
- package/template/src/views/PieceView.vue +436 -71
- package/template/src/views/AboutView.vue +0 -191
|
@@ -8,38 +8,47 @@ defineProps<{
|
|
|
8
8
|
|
|
9
9
|
<template>
|
|
10
10
|
<span class="pron-group">
|
|
11
|
-
<span class="
|
|
11
|
+
<span class="pron-badge" :class="segment.lang === 'yue' ? 'pron-yue' : 'pron-cmn'">
|
|
12
12
|
{{ segment.label }}
|
|
13
13
|
</span>
|
|
14
|
-
<span
|
|
15
|
-
v-for="(part, i) in segment.parts"
|
|
16
|
-
:key="i"
|
|
17
|
-
class="ann-phonetic"
|
|
18
|
-
>{{ part }}</span>
|
|
14
|
+
<span class="pron-text">{{ segment.parts.join(' ') }}</span>
|
|
19
15
|
</span>
|
|
20
16
|
</template>
|
|
21
17
|
|
|
22
18
|
<style scoped>
|
|
23
19
|
.pron-group {
|
|
24
20
|
display: inline-flex;
|
|
25
|
-
align-items:
|
|
26
|
-
gap:
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 5px;
|
|
27
23
|
white-space: nowrap;
|
|
28
24
|
}
|
|
29
|
-
.
|
|
30
|
-
display: inline-
|
|
31
|
-
|
|
25
|
+
.pron-badge {
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
min-width: 22px;
|
|
30
|
+
height: 18px;
|
|
31
|
+
padding: 0 5px;
|
|
32
|
+
border-radius: 9px;
|
|
33
|
+
font-size: 11px;
|
|
32
34
|
font-family: var(--sans);
|
|
33
|
-
font-weight:
|
|
34
|
-
|
|
35
|
-
border-radius: 2px;
|
|
36
|
-
vertical-align: middle;
|
|
35
|
+
font-weight: 700;
|
|
36
|
+
letter-spacing: 0.5px;
|
|
37
37
|
line-height: 1;
|
|
38
|
+
flex-shrink: 0;
|
|
39
|
+
}
|
|
40
|
+
.pron-yue {
|
|
41
|
+
background: var(--jade);
|
|
42
|
+
color: #fff;
|
|
43
|
+
}
|
|
44
|
+
.pron-cmn {
|
|
45
|
+
background: var(--ink);
|
|
46
|
+
color: var(--paper);
|
|
38
47
|
}
|
|
39
|
-
.
|
|
40
|
-
.ann-cmn { background: var(--ink); color: var(--paper); }
|
|
41
|
-
.ann-phonetic {
|
|
48
|
+
.pron-text {
|
|
42
49
|
font-family: var(--sans);
|
|
50
|
+
font-size: 13px;
|
|
43
51
|
color: var(--ink-light);
|
|
52
|
+
letter-spacing: 0.5px;
|
|
44
53
|
}
|
|
45
54
|
</style>
|
|
@@ -91,6 +91,26 @@ function close() { open.value = false }
|
|
|
91
91
|
right: 24px;
|
|
92
92
|
z-index: 500;
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
@media (max-width: 768px) {
|
|
96
|
+
.rt { bottom: 16px; right: 16px; }
|
|
97
|
+
.rt-panel {
|
|
98
|
+
position: fixed;
|
|
99
|
+
bottom: 0;
|
|
100
|
+
right: 0;
|
|
101
|
+
left: 0;
|
|
102
|
+
width: auto;
|
|
103
|
+
border-radius: 16px 16px 0 0;
|
|
104
|
+
max-height: 60vh;
|
|
105
|
+
overflow-y: auto;
|
|
106
|
+
overscroll-behavior: contain;
|
|
107
|
+
animation: slideUpMobile 0.3s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
108
|
+
}
|
|
109
|
+
@keyframes slideUpMobile {
|
|
110
|
+
from { opacity: 0; transform: translateY(100%); }
|
|
111
|
+
to { opacity: 1; transform: translateY(0); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
94
114
|
.rt-fab {
|
|
95
115
|
width: 44px; height: 44px;
|
|
96
116
|
border-radius: 50%;
|
|
@@ -68,14 +68,17 @@ const paragraphsHtml = computed(() => {
|
|
|
68
68
|
</div>
|
|
69
69
|
<div v-if="isAnnotations" class="sb-text sb-ann-list">
|
|
70
70
|
<div v-for="entry in entries" :key="entry.num" class="sb-ann-entry">
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
<div class="sb-ann-head">
|
|
72
|
+
<span class="sb-ann-num">{{ entry.numDisplay }}</span>
|
|
73
|
+
<span class="sb-ann-term">{{ entry.term }}</span>
|
|
74
|
+
<PronunciationGroup
|
|
75
|
+
v-for="seg in entry.pronSegments"
|
|
76
|
+
:key="seg.lang"
|
|
77
|
+
:segment="seg"
|
|
78
|
+
class="sb-ann-pron"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<div v-if="entry.definition" class="sb-ann-def">{{ entry.definition }}</div>
|
|
79
82
|
</div>
|
|
80
83
|
</div>
|
|
81
84
|
<div v-else class="sb-text" v-html="paragraphsHtml" />
|
|
@@ -114,11 +117,54 @@ const paragraphsHtml = computed(() => {
|
|
|
114
117
|
.sb-text :deep(p) { margin-bottom: 16px; text-indent: 2em; }
|
|
115
118
|
.sb-text :deep(p:last-child) { margin-bottom: 0; }
|
|
116
119
|
|
|
120
|
+
/* ─── Annotation list ─── */
|
|
117
121
|
.sb-ann-list { text-align: start; }
|
|
118
|
-
.sb-ann-entry {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
.sb-ann-entry {
|
|
123
|
+
padding: 12px 0;
|
|
124
|
+
border-bottom: 1px solid var(--border-light);
|
|
125
|
+
}
|
|
126
|
+
.sb-ann-entry:last-child {
|
|
127
|
+
border-bottom: none;
|
|
128
|
+
padding-bottom: 0;
|
|
129
|
+
}
|
|
130
|
+
.sb-ann-head {
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-wrap: wrap;
|
|
133
|
+
align-items: baseline;
|
|
134
|
+
gap: 6px 10px;
|
|
135
|
+
margin-bottom: 4px;
|
|
136
|
+
}
|
|
137
|
+
.sb-ann-num {
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
justify-content: center;
|
|
141
|
+
width: 22px;
|
|
142
|
+
height: 22px;
|
|
143
|
+
border-radius: 4px;
|
|
144
|
+
background: var(--vermillion);
|
|
145
|
+
color: #fff;
|
|
146
|
+
font-family: var(--sans);
|
|
147
|
+
font-size: 12px;
|
|
148
|
+
font-weight: 700;
|
|
149
|
+
flex-shrink: 0;
|
|
150
|
+
}
|
|
151
|
+
.sb-ann-term {
|
|
152
|
+
font-weight: 700;
|
|
153
|
+
font-size: 1.05em;
|
|
154
|
+
color: var(--ink);
|
|
155
|
+
padding: 2px 8px;
|
|
156
|
+
background: var(--surface-warm);
|
|
157
|
+
border-radius: 3px;
|
|
158
|
+
}
|
|
159
|
+
.sb-ann-pron {
|
|
160
|
+
margin-left: 2px;
|
|
161
|
+
}
|
|
162
|
+
.sb-ann-def {
|
|
163
|
+
color: var(--ink-mid);
|
|
164
|
+
line-height: 2;
|
|
165
|
+
white-space: pre-line;
|
|
166
|
+
padding-left: 32px;
|
|
167
|
+
}
|
|
122
168
|
|
|
123
169
|
/* ─── 直排模式 ─── */
|
|
124
170
|
.sb-vertical {
|
|
@@ -163,5 +209,38 @@ const paragraphsHtml = computed(() => {
|
|
|
163
209
|
.sb-vertical .sb-ann-entry {
|
|
164
210
|
margin-bottom: 0;
|
|
165
211
|
margin-left: 16px;
|
|
212
|
+
padding: 0;
|
|
213
|
+
border-bottom: none;
|
|
214
|
+
}
|
|
215
|
+
.sb-vertical .sb-ann-head {
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
align-items: flex-start;
|
|
218
|
+
gap: 4px;
|
|
219
|
+
}
|
|
220
|
+
.sb-vertical .sb-ann-num {
|
|
221
|
+
width: auto;
|
|
222
|
+
height: auto;
|
|
223
|
+
border-radius: 0;
|
|
224
|
+
background: none;
|
|
225
|
+
color: var(--vermillion);
|
|
226
|
+
font-size: inherit;
|
|
227
|
+
}
|
|
228
|
+
.sb-vertical .sb-ann-term {
|
|
229
|
+
background: none;
|
|
230
|
+
padding: 0;
|
|
231
|
+
font-size: inherit;
|
|
232
|
+
}
|
|
233
|
+
.sb-vertical .sb-ann-def {
|
|
234
|
+
padding-left: 0;
|
|
235
|
+
margin-left: 12px;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@media (max-width: 768px) {
|
|
239
|
+
.sb-ann-entry {
|
|
240
|
+
padding: 10px 0;
|
|
241
|
+
}
|
|
242
|
+
.sb-ann-def {
|
|
243
|
+
padding-left: 0;
|
|
244
|
+
}
|
|
166
245
|
}
|
|
167
246
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref } from 'vue'
|
|
2
|
+
import { ref, inject } from 'vue'
|
|
3
3
|
import { useRouter } from 'vue-router'
|
|
4
4
|
import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables/useReadingMode'
|
|
5
5
|
import type { LayoutMode, FontSize } from '../composables/useReadingMode'
|
|
@@ -20,9 +20,10 @@ const emit = defineEmits<{
|
|
|
20
20
|
|
|
21
21
|
const { theme, layout, mainFontSize, bodyFontSize, setTheme, setLayout, setMainFontSize, setBodyFontSize } = useReadingMode()
|
|
22
22
|
const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
|
|
23
|
-
const { logoUrl } = useSiteConfig()
|
|
23
|
+
const { logoUrl, aboutHtml } = useSiteConfig()
|
|
24
24
|
const router = useRouter()
|
|
25
25
|
const settingsOpen = ref(false)
|
|
26
|
+
const aboutPane = inject<{ toggleAbout: () => void }>('aboutPane')
|
|
26
27
|
|
|
27
28
|
function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
28
29
|
</script>
|
|
@@ -31,7 +32,7 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
31
32
|
<nav class="sidenav">
|
|
32
33
|
<button class="sn-brand" @click="emit('home')" title="首頁">
|
|
33
34
|
<img v-if="logoUrl" :src="logoUrl" alt="" class="sn-logo" />
|
|
34
|
-
<span v-else class="sn-seal"
|
|
35
|
+
<span v-else class="sn-seal">文</span>
|
|
35
36
|
</button>
|
|
36
37
|
|
|
37
38
|
<button class="sn-btn" @click="emit('back')" title="返回">
|
|
@@ -49,7 +50,7 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
49
50
|
|
|
50
51
|
<div class="sn-spacer" />
|
|
51
52
|
|
|
52
|
-
<button class="sn-btn" @click="
|
|
53
|
+
<button v-if="aboutHtml" class="sn-btn" @click="aboutPane?.toggleAbout()" title="關於">
|
|
53
54
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
|
54
55
|
</button>
|
|
55
56
|
|
|
@@ -96,6 +96,11 @@ function onTap(event: MouseEvent) {
|
|
|
96
96
|
cursor: help;
|
|
97
97
|
transition: background 0.15s;
|
|
98
98
|
}
|
|
99
|
+
:deep(.ann-target.ann-overlap) {
|
|
100
|
+
border-left-width: 3px;
|
|
101
|
+
border-left-style: double;
|
|
102
|
+
padding-left: 3px;
|
|
103
|
+
}
|
|
99
104
|
:deep(.ann-num) {
|
|
100
105
|
font-size: 0.45em;
|
|
101
106
|
font-family: var(--sans);
|
|
@@ -121,4 +126,34 @@ function onTap(event: MouseEvent) {
|
|
|
121
126
|
:deep(.ann-target.pronunciation.semantic) {
|
|
122
127
|
border-left-color: var(--gold);
|
|
123
128
|
}
|
|
129
|
+
:deep(.ann-target.person) {
|
|
130
|
+
border-left-color: var(--ann-person);
|
|
131
|
+
}
|
|
132
|
+
:deep(.ann-target.place) {
|
|
133
|
+
border-left-color: var(--ann-place);
|
|
134
|
+
}
|
|
135
|
+
:deep(.ann-target.event) {
|
|
136
|
+
border-left-color: var(--ann-event);
|
|
137
|
+
}
|
|
138
|
+
:deep(.ann-target.date) {
|
|
139
|
+
border-left-color: var(--ann-date);
|
|
140
|
+
}
|
|
141
|
+
:deep(.ann-target.allusion) {
|
|
142
|
+
border-left-color: var(--ann-allusion);
|
|
143
|
+
}
|
|
144
|
+
:deep(.ann-target.person:hover) {
|
|
145
|
+
background: rgba(58, 90, 140, 0.08);
|
|
146
|
+
}
|
|
147
|
+
:deep(.ann-target.place:hover) {
|
|
148
|
+
background: rgba(139, 105, 20, 0.08);
|
|
149
|
+
}
|
|
150
|
+
:deep(.ann-target.event:hover) {
|
|
151
|
+
background: rgba(107, 76, 138, 0.08);
|
|
152
|
+
}
|
|
153
|
+
:deep(.ann-target.date:hover) {
|
|
154
|
+
background: rgba(42, 122, 122, 0.08);
|
|
155
|
+
}
|
|
156
|
+
:deep(.ann-target.allusion:hover) {
|
|
157
|
+
background: rgba(181, 101, 29, 0.08);
|
|
158
|
+
}
|
|
124
159
|
</style>
|
|
@@ -7,6 +7,7 @@ export interface AnnSpan {
|
|
|
7
7
|
start: number
|
|
8
8
|
end: number
|
|
9
9
|
annotations: Annotation[]
|
|
10
|
+
overlapping: boolean
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function esc(s: string): string {
|
|
@@ -17,27 +18,41 @@ export function buildVerseAnnotations(annotations: Annotation[], verseIndex: num
|
|
|
17
18
|
const anns = annotations.filter(a =>
|
|
18
19
|
a.range.scope === 'verse' && a.range.verseIndex === verseIndex
|
|
19
20
|
)
|
|
20
|
-
|
|
21
|
+
if (!anns.length) return []
|
|
22
|
+
|
|
23
|
+
const points = new Set<number>()
|
|
21
24
|
for (const a of anns) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
points.add(a.range.start ?? 0)
|
|
26
|
+
points.add(a.range.end ?? 0)
|
|
27
|
+
}
|
|
28
|
+
const sorted = [...points].sort((a, b) => a - b)
|
|
29
|
+
|
|
30
|
+
const segments: AnnSpan[] = []
|
|
31
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
32
|
+
const start = sorted[i]
|
|
33
|
+
const end = sorted[i + 1]
|
|
34
|
+
const covering = anns.filter(a =>
|
|
35
|
+
(a.range.start ?? 0) <= start && (a.range.end ?? 0) >= end
|
|
36
|
+
)
|
|
37
|
+
if (covering.length === 0) continue
|
|
38
|
+
|
|
39
|
+
const rangeKeys = new Set(
|
|
40
|
+
covering.map(a => `${a.range.start ?? 0}:${a.range.end ?? 0}`)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
segments.push({
|
|
44
|
+
start,
|
|
45
|
+
end,
|
|
46
|
+
annotations: covering,
|
|
47
|
+
overlapping: rangeKeys.size > 1,
|
|
48
|
+
})
|
|
25
49
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { start, end, annotations: matched }
|
|
29
|
-
}).sort((a, b) => a.start - b.start)
|
|
50
|
+
|
|
51
|
+
return segments
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
export function countVerseSpans(annotations: Annotation[], verseIndex: number): number {
|
|
33
|
-
|
|
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
|
|
55
|
+
return buildVerseAnnotations(annotations, verseIndex).length
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
export function renderAnnotatedText(text: string, spans: AnnSpan[], useRuby = false, startNum = 0): string {
|
|
@@ -53,13 +68,14 @@ export function renderAnnotatedText(text: string, spans: AnnSpan[], useRuby = fa
|
|
|
53
68
|
annCounter++
|
|
54
69
|
const ids = span.annotations.map(a => a.id).join(',')
|
|
55
70
|
const kinds = [...new Set(span.annotations.map(a => a.kind))].join(' ')
|
|
71
|
+
const overlapCls = span.overlapping ? ' ann-overlap' : ''
|
|
56
72
|
const numText = toChineseNumber(annCounter)
|
|
57
73
|
const body = esc(text.slice(span.start, span.end))
|
|
58
74
|
if (useRuby) {
|
|
59
75
|
const rtCls = numText.length > 1 ? 'ann-num ann-num-long' : 'ann-num'
|
|
60
|
-
html += `<ruby class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="${rtCls}">${numText}</rt><rp></rp></ruby>`
|
|
76
|
+
html += `<ruby class="ann-target${overlapCls} ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="${rtCls}">${numText}</rt><rp></rp></ruby>`
|
|
61
77
|
} else {
|
|
62
|
-
html += `<span class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<sup class="ann-num">${numText}</sup></span>`
|
|
78
|
+
html += `<span class="ann-target${overlapCls} ${kinds}" data-ann-ids="${ids}">${body}<sup class="ann-num">${numText}</sup></span>`
|
|
63
79
|
}
|
|
64
80
|
cursor = span.end
|
|
65
81
|
}
|
|
@@ -89,7 +105,8 @@ export function renderVerseGutter(text: string, spans: AnnSpan[], startNum = 0):
|
|
|
89
105
|
annCounter++
|
|
90
106
|
const ids = span.annotations.map(a => a.id).join(',')
|
|
91
107
|
const kinds = [...new Set(span.annotations.map(a => a.kind))].join(' ')
|
|
92
|
-
|
|
108
|
+
const overlapCls = span.overlapping ? ' ann-overlap' : ''
|
|
109
|
+
textHtml += `<span class="ann-target${overlapCls} ${kinds}" data-ann-ids="${ids}">${esc(text.slice(span.start, span.end))}</span>`
|
|
93
110
|
gutter[span.start] = `<span class="ann-gutter-num ${kinds}" data-ann-ids="${ids}">${toChineseNumber(annCounter)}</span>`
|
|
94
111
|
cursor = span.end
|
|
95
112
|
}
|
|
@@ -123,11 +140,27 @@ export function useAnnotationTooltip() {
|
|
|
123
140
|
const rect = (el ?? event.target as HTMLElement).getBoundingClientRect()
|
|
124
141
|
|
|
125
142
|
if (layout.value === 'vertical') {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
const isMobile = window.innerWidth < 768
|
|
144
|
+
if (isMobile) {
|
|
145
|
+
style.value = {
|
|
146
|
+
left: '4vw',
|
|
147
|
+
right: '4vw',
|
|
148
|
+
bottom: '0',
|
|
149
|
+
maxWidth: 'none',
|
|
150
|
+
}
|
|
151
|
+
} else if (window.innerWidth >= 1024) {
|
|
152
|
+
style.value = {
|
|
153
|
+
right: '20px',
|
|
154
|
+
top: '72px',
|
|
155
|
+
maxHeight: 'calc(100vh - 100px)',
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
const right = window.innerWidth - rect.left + 8
|
|
159
|
+
style.value = {
|
|
160
|
+
right: Math.min(right, window.innerWidth - 40) + 'px',
|
|
161
|
+
top: '50%',
|
|
162
|
+
transform: 'translateY(-50%)',
|
|
163
|
+
}
|
|
131
164
|
}
|
|
132
165
|
} else {
|
|
133
166
|
const isMobile = window.innerWidth < 768
|
|
@@ -156,7 +189,6 @@ export function useAnnotationTooltip() {
|
|
|
156
189
|
const currentIds = items.value.map(a => a.id).sort().join(',')
|
|
157
190
|
const newIds = annotations.map(a => a.id).sort().join(',')
|
|
158
191
|
if (currentIds === newIds) {
|
|
159
|
-
// Same annotation: dismiss on mobile only (desktop uses hover to manage)
|
|
160
192
|
if (window.innerWidth < 768) hide()
|
|
161
193
|
} else {
|
|
162
194
|
show(event, annotations)
|
|
@@ -28,7 +28,12 @@ async function loadShared(): Promise<void> {
|
|
|
28
28
|
|
|
29
29
|
const authorByName = computed(() => {
|
|
30
30
|
const map = new Map<string, Author>()
|
|
31
|
-
for (const a of authors.value)
|
|
31
|
+
for (const a of authors.value) {
|
|
32
|
+
const existing = map.get(a.name)
|
|
33
|
+
if (!existing || (!existing.bio && a.bio)) {
|
|
34
|
+
map.set(a.name, a)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
32
37
|
return map
|
|
33
38
|
})
|
|
34
39
|
|
|
@@ -14,6 +14,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
14
14
|
'site.subtitle': 'Classical Chinese Text Library',
|
|
15
15
|
'nav.back': '返回',
|
|
16
16
|
'nav.home': '首頁',
|
|
17
|
+
'nav.about': '關 於',
|
|
17
18
|
'settings.layout': '版面',
|
|
18
19
|
'settings.vertical': '直排',
|
|
19
20
|
'settings.horizontal': '橫排',
|
|
@@ -36,7 +37,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
36
37
|
'author.biography': '作者簡介',
|
|
37
38
|
'author.collectedWorks': '收錄作品',
|
|
38
39
|
'author.worksCount': '{count} 篇收錄作品',
|
|
39
|
-
'author.
|
|
40
|
+
'author.unknownEra': '未知朝代',
|
|
40
41
|
'annotation.pronunciation': '音',
|
|
41
42
|
'annotation.semantic': '義',
|
|
42
43
|
'annotation.notes': '注釋',
|
|
@@ -52,12 +53,23 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
52
53
|
'stat.pieceCount': '{count} 篇',
|
|
53
54
|
'stat.authorCount': '{count} 位作者',
|
|
54
55
|
'stat.piecePoems': '篇詩文',
|
|
56
|
+
'stat.authorsLabel': '位作者',
|
|
57
|
+
'catalog.noResults': '未找到符合「{query}」的篇章',
|
|
58
|
+
'catalog.enterLibrary': '進 入 文 庫 ↓',
|
|
59
|
+
'piece.noAuthorData': '暫無作者資料',
|
|
60
|
+
'piece.collected': '{count} 篇收錄',
|
|
61
|
+
'role.author': '作者',
|
|
62
|
+
'role.commentator': '註者',
|
|
63
|
+
'role.editor': '編者',
|
|
64
|
+
'role.translator': '譯者',
|
|
65
|
+
'role.annotator': '注者',
|
|
55
66
|
},
|
|
56
67
|
'zh-Hans': {
|
|
57
68
|
'site.title': '古典诗文图书馆',
|
|
58
69
|
'site.subtitle': 'Classical Chinese Text Library',
|
|
59
70
|
'nav.back': '返回',
|
|
60
71
|
'nav.home': '首页',
|
|
72
|
+
'nav.about': '关 于',
|
|
61
73
|
'settings.layout': '版面',
|
|
62
74
|
'settings.vertical': '直排',
|
|
63
75
|
'settings.horizontal': '横排',
|
|
@@ -80,7 +92,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
80
92
|
'author.biography': '作者简介',
|
|
81
93
|
'author.collectedWorks': '收录作品',
|
|
82
94
|
'author.worksCount': '{count} 篇收录作品',
|
|
83
|
-
'author.
|
|
95
|
+
'author.unknownEra': '未知朝代',
|
|
84
96
|
'annotation.pronunciation': '音',
|
|
85
97
|
'annotation.semantic': '义',
|
|
86
98
|
'annotation.notes': '注释',
|
|
@@ -96,12 +108,23 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
96
108
|
'stat.pieceCount': '{count} 篇',
|
|
97
109
|
'stat.authorCount': '{count} 位作者',
|
|
98
110
|
'stat.piecePoems': '篇诗文',
|
|
111
|
+
'stat.authorsLabel': '位作者',
|
|
112
|
+
'catalog.noResults': '未找到符合「{query}」的篇章',
|
|
113
|
+
'catalog.enterLibrary': '进 入 文 库 ↓',
|
|
114
|
+
'piece.noAuthorData': '暂无作者资料',
|
|
115
|
+
'piece.collected': '{count} 篇收录',
|
|
116
|
+
'role.author': '作者',
|
|
117
|
+
'role.commentator': '注者',
|
|
118
|
+
'role.editor': '编者',
|
|
119
|
+
'role.translator': '译者',
|
|
120
|
+
'role.annotator': '注者',
|
|
99
121
|
},
|
|
100
122
|
'en': {
|
|
101
123
|
'site.title': 'Classical Chinese Text Library',
|
|
102
124
|
'site.subtitle': 'Classical Chinese Text Library',
|
|
103
125
|
'nav.back': 'Back',
|
|
104
126
|
'nav.home': 'Home',
|
|
127
|
+
'nav.about': 'About',
|
|
105
128
|
'settings.layout': 'Layout',
|
|
106
129
|
'settings.vertical': 'Vertical',
|
|
107
130
|
'settings.horizontal': 'Horizontal',
|
|
@@ -124,7 +147,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
124
147
|
'author.biography': 'Biography',
|
|
125
148
|
'author.collectedWorks': 'Collected Works',
|
|
126
149
|
'author.worksCount': '{count} collected works',
|
|
127
|
-
'author.
|
|
150
|
+
'author.unknownEra': 'Unknown era',
|
|
128
151
|
'annotation.pronunciation': 'Pron',
|
|
129
152
|
'annotation.semantic': 'Def',
|
|
130
153
|
'annotation.notes': 'Notes',
|
|
@@ -140,6 +163,16 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
140
163
|
'stat.pieceCount': '{count} works',
|
|
141
164
|
'stat.authorCount': '{count} authors',
|
|
142
165
|
'stat.piecePoems': 'works',
|
|
166
|
+
'stat.authorsLabel': 'authors',
|
|
167
|
+
'catalog.noResults': 'No results for "{query}"',
|
|
168
|
+
'catalog.enterLibrary': 'Enter Library ↓',
|
|
169
|
+
'piece.noAuthorData': 'No author data available',
|
|
170
|
+
'piece.collected': '{count} works collected',
|
|
171
|
+
'role.author': 'Author',
|
|
172
|
+
'role.commentator': 'Commentator',
|
|
173
|
+
'role.editor': 'Editor',
|
|
174
|
+
'role.translator': 'Translator',
|
|
175
|
+
'role.annotator': 'Annotator',
|
|
143
176
|
},
|
|
144
177
|
}
|
|
145
178
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ref, watch } from 'vue'
|
|
1
|
+
import { ref, watch, nextTick } from 'vue'
|
|
2
2
|
|
|
3
3
|
export type Theme = 'light' | 'sepia' | 'dark' | 'oled'
|
|
4
4
|
export type LayoutMode = 'horizontal' | 'vertical'
|
|
@@ -21,18 +21,23 @@ const mainFontSize = ref<FontSize>(24)
|
|
|
21
21
|
const bodyFontSize = ref<FontSize>(16)
|
|
22
22
|
|
|
23
23
|
if (!import.meta.env.SSR) {
|
|
24
|
+
// Theme and font sizes only affect CSS, safe to apply before hydration
|
|
24
25
|
const savedTheme = localStorage.getItem('theme') as Theme | null
|
|
25
26
|
if (savedTheme && THEMES.includes(savedTheme)) theme.value = savedTheme
|
|
26
27
|
|
|
27
|
-
const savedLayout = localStorage.getItem('layout') as LayoutMode | null
|
|
28
|
-
if (savedLayout === 'vertical' || savedLayout === 'horizontal') layout.value = savedLayout
|
|
29
|
-
|
|
30
28
|
const savedMain = parseInt(localStorage.getItem('mainFontSize') || '', 10)
|
|
31
29
|
if (FONT_SIZES.includes(savedMain as any)) mainFontSize.value = savedMain as FontSize
|
|
32
30
|
|
|
33
31
|
const savedBody = parseInt(localStorage.getItem('bodyFontSize') || '', 10)
|
|
34
32
|
if (FONT_SIZES.includes(savedBody as any)) bodyFontSize.value = savedBody as FontSize
|
|
35
33
|
|
|
34
|
+
// Layout controls v-if/v-else DOM structure — must defer to after hydration
|
|
35
|
+
// to avoid SSR/client mismatch (SSR always renders vertical)
|
|
36
|
+
nextTick(() => {
|
|
37
|
+
const savedLayout = localStorage.getItem('layout') as LayoutMode | null
|
|
38
|
+
if (savedLayout === 'vertical' || savedLayout === 'horizontal') layout.value = savedLayout
|
|
39
|
+
})
|
|
40
|
+
|
|
36
41
|
watch(theme, t => {
|
|
37
42
|
document.documentElement.setAttribute('data-theme', t)
|
|
38
43
|
localStorage.setItem('theme', t)
|
|
@@ -4,10 +4,16 @@ import type { Theme } from './useReadingMode'
|
|
|
4
4
|
|
|
5
5
|
const LOGO_LIGHT = import.meta.env.CHAM_LOGO_URL || undefined
|
|
6
6
|
const LOGO_DARK = import.meta.env.CHAM_LOGO_DARK_URL || undefined
|
|
7
|
+
const SITE_TITLE = import.meta.env.CHAM_SITE_TITLE || ''
|
|
8
|
+
const SITE_SUBTITLE = import.meta.env.CHAM_SITE_SUBTITLE || ''
|
|
9
|
+
const ABOUT_HTML = import.meta.env.CHAM_ABOUT_HTML || ''
|
|
7
10
|
|
|
8
11
|
const DARK_THEMES: Theme[] = ['dark', 'oled']
|
|
9
12
|
|
|
10
13
|
export interface SiteConfig {
|
|
14
|
+
siteTitle: string
|
|
15
|
+
siteSubtitle: string
|
|
16
|
+
aboutHtml: string
|
|
11
17
|
logoUrl: ReturnType<typeof computed<string | undefined>>
|
|
12
18
|
}
|
|
13
19
|
|
|
@@ -17,5 +23,10 @@ export function useSiteConfig(): SiteConfig {
|
|
|
17
23
|
if (DARK_THEMES.includes(theme.value) && LOGO_DARK) return LOGO_DARK
|
|
18
24
|
return LOGO_LIGHT
|
|
19
25
|
})
|
|
20
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
siteTitle: SITE_TITLE,
|
|
28
|
+
siteSubtitle: SITE_SUBTITLE,
|
|
29
|
+
aboutHtml: ABOUT_HTML,
|
|
30
|
+
logoUrl,
|
|
31
|
+
}
|
|
21
32
|
}
|
package/template/src/router.ts
CHANGED
|
@@ -8,11 +8,9 @@ import LibraryHome from './views/LibraryHome.vue'
|
|
|
8
8
|
import BookHome from './views/BookHome.vue'
|
|
9
9
|
import PieceView from './views/PieceView.vue'
|
|
10
10
|
import AuthorView from './views/AuthorView.vue'
|
|
11
|
-
import AboutView from './views/AboutView.vue'
|
|
12
11
|
|
|
13
12
|
export const routes: RouteRecordRaw[] = [
|
|
14
13
|
{ path: '/', component: LibraryHome },
|
|
15
|
-
{ path: '/about', component: AboutView },
|
|
16
14
|
{ path: '/author/:name', component: AuthorView, props: true },
|
|
17
15
|
{ path: '/:bookId', component: BookHome, props: true },
|
|
18
16
|
{ path: '/:bookId/:num', component: PieceView, props: true },
|