@hanology/cham-browser 0.3.4 → 0.3.6

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "CHAM — browser-compatible parser, serializer, and site generator for Classical Han Annotated Markdown",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -198,10 +198,18 @@ onBeforeUnmount(() => {
198
198
  }
199
199
 
200
200
  /* ─── Transition ─── */
201
- .ann-fade-enter-active, .ann-fade-leave-active {
202
- transition: opacity 0.15s ease;
201
+ .ann-fade-enter-active {
202
+ transition: opacity var(--dur-fast, 0.15s) ease, transform var(--dur-mid, 0.25s) cubic-bezier(0.34, 1.56, 0.64, 1);
203
203
  }
204
- .ann-fade-enter-from, .ann-fade-leave-to {
204
+ .ann-fade-leave-active {
205
+ transition: opacity var(--dur-fast, 0.15s) ease, transform var(--dur-fast, 0.15s) ease;
206
+ }
207
+ .ann-fade-enter-from {
208
+ opacity: 0;
209
+ transform: scale(0.92) translateY(4px);
210
+ }
211
+ .ann-fade-leave-to {
205
212
  opacity: 0;
213
+ transform: scale(0.96);
206
214
  }
207
215
  </style>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted } from 'vue'
3
+
4
+ const visible = ref(false)
5
+ let ticking = false
6
+
7
+ function onScroll() {
8
+ if (ticking) return
9
+ ticking = true
10
+ requestAnimationFrame(() => {
11
+ visible.value = window.scrollY > 400
12
+ ticking = false
13
+ })
14
+ }
15
+
16
+ function scrollToTop() {
17
+ window.scrollTo({ top: 0, behavior: 'smooth' })
18
+ }
19
+
20
+ onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
21
+ onUnmounted(() => window.removeEventListener('scroll', onScroll))
22
+ </script>
23
+
24
+ <template>
25
+ <Transition name="btt">
26
+ <button v-if="visible" class="btt" @click="scrollToTop" aria-label="回到頂部">
27
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
28
+ <path d="M12 19V5M5 12l7-7 7 7"/>
29
+ </svg>
30
+ </button>
31
+ </Transition>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .btt {
36
+ position: fixed;
37
+ bottom: 80px;
38
+ right: 24px;
39
+ width: 40px;
40
+ height: 40px;
41
+ border-radius: 50%;
42
+ border: 1px solid var(--border);
43
+ background: var(--surface);
44
+ color: var(--ink-light);
45
+ cursor: pointer;
46
+ z-index: 400;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.1);
51
+ transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
52
+ }
53
+ .btt:hover {
54
+ background: var(--ink);
55
+ color: var(--paper);
56
+ border-color: var(--ink);
57
+ transform: translateY(-2px);
58
+ box-shadow: 0 8px 24px rgba(var(--shadow-rgb), 0.16);
59
+ }
60
+
61
+ .btt-enter-active { transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
62
+ .btt-leave-active { transition: all 0.15s ease; }
63
+ .btt-enter-from { opacity: 0; transform: translateY(12px) scale(0.8); }
64
+ .btt-leave-to { opacity: 0; transform: translateY(8px) scale(0.9); }
65
+ </style>
@@ -8,6 +8,7 @@ const props = defineProps<{
8
8
  annotations: Annotation[]
9
9
  vertical?: boolean
10
10
  source?: PieceSource
11
+ annotationText?: string
11
12
  }>()
12
13
 
13
14
  const emit = defineEmits<{
@@ -55,6 +56,9 @@ const sourceLabel = (() => {
55
56
  v-html="verseHtml(i)"
56
57
  />
57
58
  </div>
59
+ <div v-if="annotationText" class="part-annotations">
60
+ <div v-for="line in annotationText.split('\n')" :key="line" class="part-ann-line">{{ line }}</div>
61
+ </div>
58
62
  </div>
59
63
  </template>
60
64
 
@@ -90,6 +94,20 @@ const sourceLabel = (() => {
90
94
  line-height: 1;
91
95
  }
92
96
 
97
+ .part-annotations {
98
+ margin-top: 16px;
99
+ padding-top: 12px;
100
+ border-top: 1px dashed var(--border-light);
101
+ }
102
+
103
+ .part-ann-line {
104
+ font-family: var(--sans);
105
+ font-size: 14px;
106
+ line-height: 2;
107
+ color: var(--ink-mid);
108
+ letter-spacing: 0.5px;
109
+ }
110
+
93
111
  .part-line-h {
94
112
  font-size: var(--main-font-size, 22px);
95
113
  line-height: 2.4;
@@ -134,7 +152,6 @@ const sourceLabel = (() => {
134
152
  background: rgba(58, 107, 94, 0.08);
135
153
  }
136
154
 
137
- /* Vertical mode overrides */
138
155
  .part-block--vertical :deep(.ann-target) {
139
156
  border-bottom: none;
140
157
  border-left: 2px solid var(--vermillion);
@@ -157,4 +174,13 @@ const sourceLabel = (() => {
157
174
  margin-bottom: 0;
158
175
  margin-left: 8px;
159
176
  }
177
+
178
+ .part-block--vertical .part-annotations {
179
+ margin-top: 0;
180
+ margin-left: 12px;
181
+ padding-top: 0;
182
+ padding-left: 12px;
183
+ border-top: none;
184
+ border-left: 1px dashed var(--border-light);
185
+ }
160
186
  </style>
@@ -24,6 +24,7 @@ const emit = defineEmits<{
24
24
  :num="part.num"
25
25
  :verses="part.verses"
26
26
  :annotations="part.annotations"
27
+ :annotation-text="part.annotationText"
27
28
  :vertical="vertical"
28
29
  :source="part.source"
29
30
  @annotation-hover="(e, a) => emit('annotationHover', e, a)"
@@ -54,6 +54,13 @@ const preview = computed(() => {
54
54
  font-size: 11px; color: var(--ink-faint);
55
55
  font-family: var(--sans); letter-spacing: 2px;
56
56
  margin-bottom: 10px;
57
+ display: inline-flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ padding: 2px 6px;
61
+ border: 1px solid var(--border-light);
62
+ border-radius: 2px;
63
+ background: var(--surface-warm);
57
64
  }
58
65
  .pc-title {
59
66
  font-size: 20px; font-weight: 700;
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted, watch } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ vertical?: boolean
6
+ scrollContainer?: HTMLElement | null
7
+ }>()
8
+
9
+ const progress = ref(0)
10
+ let raf = 0
11
+
12
+ function updateProgress() {
13
+ if (props.vertical && props.scrollContainer) {
14
+ const el = props.scrollContainer
15
+ const max = el.scrollWidth - el.clientWidth
16
+ progress.value = max > 0 ? Math.min((el.scrollLeft / max) * 100, 100) : 0
17
+ } else if (!props.vertical) {
18
+ const max = document.documentElement.scrollHeight - window.innerHeight
19
+ progress.value = max > 0 ? Math.min((window.scrollY / max) * 100, 100) : 0
20
+ }
21
+ }
22
+
23
+ function onScroll() {
24
+ cancelAnimationFrame(raf)
25
+ raf = requestAnimationFrame(updateProgress)
26
+ }
27
+
28
+ function attach() {
29
+ if (props.vertical && props.scrollContainer) {
30
+ props.scrollContainer.addEventListener('scroll', onScroll, { passive: true })
31
+ } else if (!props.vertical) {
32
+ window.addEventListener('scroll', onScroll, { passive: true })
33
+ }
34
+ updateProgress()
35
+ }
36
+
37
+ function detach() {
38
+ if (props.vertical && props.scrollContainer) {
39
+ props.scrollContainer.removeEventListener('scroll', onScroll)
40
+ } else {
41
+ window.removeEventListener('scroll', onScroll)
42
+ }
43
+ cancelAnimationFrame(raf)
44
+ }
45
+
46
+ watch(() => props.scrollContainer, (el, old) => {
47
+ detach()
48
+ if (old) old.removeEventListener('scroll', onScroll)
49
+ attach()
50
+ })
51
+
52
+ onMounted(attach)
53
+ onUnmounted(detach)
54
+ </script>
55
+
56
+ <template>
57
+ <div
58
+ class="rp"
59
+ :class="{ 'rp-v': vertical }"
60
+ :style="vertical
61
+ ? { height: progress + '%' }
62
+ : { width: progress + '%' }"
63
+ />
64
+ </template>
65
+
66
+ <style scoped>
67
+ .rp {
68
+ position: fixed;
69
+ z-index: 1001;
70
+ background: linear-gradient(90deg, var(--vermillion), var(--vermillion-light));
71
+ pointer-events: none;
72
+ will-change: width, height;
73
+ }
74
+ .rp:not(.rp-v) {
75
+ top: 0; left: 0;
76
+ height: 2px;
77
+ }
78
+ .rp-v {
79
+ top: 0; left: 0;
80
+ width: 2px;
81
+ background: linear-gradient(180deg, var(--vermillion), var(--vermillion-light));
82
+ }
83
+ </style>
@@ -74,6 +74,11 @@ function close() { open.value = false }
74
74
  >{{ localeLabels[loc] }}</button>
75
75
  </div>
76
76
  </div>
77
+ <div class="rt-shortcuts">
78
+ <div class="rt-sc"><kbd>V</kbd> 直/橫</div>
79
+ <div class="rt-sc"><kbd>T</kbd> 主題</div>
80
+ <div class="rt-sc"><kbd>Esc</kbd> 首頁</div>
81
+ </div>
77
82
  </div>
78
83
  <div v-if="open" class="rt-backdrop" @click="close" />
79
84
  </div>
@@ -150,6 +155,25 @@ function close() { open.value = false }
150
155
  transition: all 0.15s;
151
156
  }
152
157
  .rt-opt:hover { border-color: var(--ink); color: var(--ink); }
158
+ .rt-opt.rt-theme {
159
+ position: relative;
160
+ padding-left: 22px;
161
+ }
162
+ .rt-opt.rt-theme::before {
163
+ content: '';
164
+ position: absolute;
165
+ left: 8px;
166
+ top: 50%;
167
+ transform: translateY(-50%);
168
+ width: 8px;
169
+ height: 8px;
170
+ border-radius: 50%;
171
+ border: 1px solid var(--border);
172
+ }
173
+ .rt-opt.theme-light::before { background: #faf6ee; }
174
+ .rt-opt.theme-sepia::before { background: #f0e4c8; }
175
+ .rt-opt.theme-dark::before { background: #1c1c1e; border-color: #48484a; }
176
+ .rt-opt.theme-oled::before { background: #000; border-color: #333; }
153
177
  .rt-opt.active {
154
178
  background: var(--ink);
155
179
  color: var(--paper);
@@ -182,4 +206,35 @@ function close() { open.value = false }
182
206
  position: fixed; inset: 0;
183
207
  z-index: -1;
184
208
  }
209
+ .rt-shortcuts {
210
+ display: flex;
211
+ flex-wrap: wrap;
212
+ gap: 6px;
213
+ padding-top: 10px;
214
+ border-top: 1px solid var(--border-light);
215
+ margin-top: 2px;
216
+ }
217
+ .rt-sc {
218
+ font-family: var(--sans);
219
+ font-size: 10px;
220
+ color: var(--ink-faint);
221
+ letter-spacing: 1px;
222
+ display: flex;
223
+ align-items: center;
224
+ gap: 3px;
225
+ }
226
+ .rt-sc kbd {
227
+ display: inline-flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ min-width: 18px;
231
+ height: 16px;
232
+ padding: 0 3px;
233
+ border: 1px solid var(--border);
234
+ border-radius: 2px;
235
+ font-family: var(--sans);
236
+ font-size: 9px;
237
+ color: var(--ink-light);
238
+ background: var(--surface);
239
+ }
185
240
  </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, ref, onMounted, onUnmounted } from 'vue'
3
3
  import { parseAnnotationBlock } from '../utils/annotationParser'
4
4
  import PronunciationGroup from './PronunciationGroup.vue'
5
5
 
@@ -12,6 +12,27 @@ const props = defineProps<{
12
12
  vertical?: boolean
13
13
  }>()
14
14
 
15
+ const rootRef = ref<HTMLElement | null>(null)
16
+ const visible = ref(false)
17
+
18
+ onMounted(() => {
19
+ if (props.vertical || !rootRef.value) {
20
+ visible.value = true
21
+ return
22
+ }
23
+ const observer = new IntersectionObserver(
24
+ ([entry]) => {
25
+ if (entry.isIntersecting) {
26
+ visible.value = true
27
+ observer.disconnect()
28
+ }
29
+ },
30
+ { rootMargin: '0px 0px -40px 0px', threshold: 0 }
31
+ )
32
+ observer.observe(rootRef.value)
33
+ onUnmounted(() => observer.disconnect())
34
+ })
35
+
15
36
  function esc(str: string): string {
16
37
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
17
38
  }
@@ -40,7 +61,7 @@ const paragraphsHtml = computed(() => {
40
61
  </script>
41
62
 
42
63
  <template>
43
- <div v-if="text" class="sb-root" :class="{ 'sb-vertical': vertical }">
64
+ <div v-if="text" ref="rootRef" class="sb-root" :class="{ 'sb-vertical': vertical, 'sb-visible': visible }">
44
65
  <div class="sb-header">
45
66
  <span v-if="displayNum" class="sb-num" :class="{ special }">{{ displayNum }}</span>
46
67
  <h3>{{ special ? '【' + label + '】' : label }}</h3>
@@ -64,11 +85,13 @@ const paragraphsHtml = computed(() => {
64
85
  <style scoped>
65
86
  .sb-root {
66
87
  margin-bottom: 40px;
67
- animation: sb-fade-up 0.5s ease forwards;
88
+ opacity: 0;
89
+ transform: translateY(12px);
90
+ transition: opacity 0.5s ease, transform 0.5s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
68
91
  }
69
- @keyframes sb-fade-up {
70
- from { opacity: 0; transform: translateY(16px); }
71
- to { opacity: 1; transform: translateY(0); }
92
+ .sb-root.sb-visible {
93
+ opacity: 1;
94
+ transform: translateY(0);
72
95
  }
73
96
  .sb-header {
74
97
  display: flex; align-items: center; gap: 12px;
@@ -107,7 +130,9 @@ const paragraphsHtml = computed(() => {
107
130
  border-right: 1px solid var(--border);
108
131
  overflow-x: auto;
109
132
  overflow-y: hidden;
110
- animation: none;
133
+ opacity: 1;
134
+ transform: none;
135
+ transition: none;
111
136
  }
112
137
  .sb-vertical .sb-header {
113
138
  flex-direction: column;
@@ -113,6 +113,11 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
113
113
  >{{ localeLabels[loc] }}</button>
114
114
  </div>
115
115
  </div>
116
+ <div class="ss-shortcuts">
117
+ <span class="ss-sc"><kbd>V</kbd> 直/橫</span>
118
+ <span class="ss-sc"><kbd>T</kbd> 主題</span>
119
+ <span class="ss-sc"><kbd>Esc</kbd> 首頁</span>
120
+ </div>
116
121
  </div>
117
122
  </Transition>
118
123
 
@@ -308,6 +313,37 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
308
313
  position: fixed; inset: 0;
309
314
  z-index: -1;
310
315
  }
316
+ .ss-shortcuts {
317
+ display: flex;
318
+ flex-wrap: wrap;
319
+ gap: 4px 8px;
320
+ padding-top: 10px;
321
+ border-top: 1px solid var(--border-light);
322
+ margin-top: 2px;
323
+ }
324
+ .ss-sc {
325
+ font-family: var(--sans);
326
+ font-size: 10px;
327
+ color: var(--ink-faint);
328
+ letter-spacing: 1px;
329
+ display: inline-flex;
330
+ align-items: center;
331
+ gap: 3px;
332
+ }
333
+ .ss-sc kbd {
334
+ display: inline-flex;
335
+ align-items: center;
336
+ justify-content: center;
337
+ min-width: 18px;
338
+ height: 16px;
339
+ padding: 0 3px;
340
+ border: 1px solid var(--border);
341
+ border-radius: 2px;
342
+ font-family: var(--sans);
343
+ font-size: 9px;
344
+ color: var(--ink-light);
345
+ background: var(--surface);
346
+ }
311
347
 
312
348
  @media (max-width: 768px) {
313
349
  .sidenav { width: 44px; padding: 8px 0; gap: 6px; }
@@ -90,23 +90,36 @@
90
90
  --sans: 'Noto Sans TC', 'PingFang TC', sans-serif;
91
91
  --nav-width: 56px;
92
92
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
93
+ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
94
+ --dur-fast: 0.15s;
95
+ --dur-mid: 0.25s;
96
+ --dur-slow: 0.4s;
93
97
  }
94
98
  *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
95
- html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
99
+ html {
100
+ scroll-behavior: smooth;
101
+ -webkit-font-smoothing: antialiased;
102
+ -moz-osx-font-smoothing: grayscale;
103
+ text-rendering: optimizeLegibility;
104
+ hanging-punctuation: first last;
105
+ }
96
106
  body {
97
107
  font-family: var(--serif);
98
108
  background: var(--paper);
99
109
  color: var(--ink);
100
110
  line-height: 1.8;
101
111
  min-height: 100vh;
112
+ overflow-x: hidden;
102
113
  }
103
114
  ::selection { background: var(--vermillion); color: var(--selection-text); }
104
115
  ::-webkit-scrollbar { width: 6px; height: 6px; }
105
116
  ::-webkit-scrollbar-track { background: transparent; }
106
117
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
107
118
  ::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
119
+ html[dir="rtl"] ::-webkit-scrollbar-thumb { background: var(--gold); }
108
120
 
109
121
  a { color: inherit; text-decoration: none; }
122
+ button { font-family: inherit; }
110
123
 
111
124
  /* ===== LOADING SCREEN ===== */
112
125
  #app-loading {
@@ -166,6 +179,18 @@ a { color: inherit; text-decoration: none; }
166
179
  from { opacity: 0; transform: translateY(16px); }
167
180
  to { opacity: 1; transform: translateY(0); }
168
181
  }
182
+ @keyframes cardEnter {
183
+ from { opacity: 0; transform: translateY(12px); }
184
+ to { opacity: 1; transform: translateY(0); }
185
+ }
186
+ @keyframes fadeIn {
187
+ from { opacity: 0; }
188
+ to { opacity: 1; }
189
+ }
190
+ @keyframes scaleIn {
191
+ from { opacity: 0; transform: scale(0.92); }
192
+ to { opacity: 1; transform: scale(1); }
193
+ }
169
194
 
170
195
  /* ===== RESPONSIVE ===== */
171
196
  @media (max-width: 768px) {
@@ -108,11 +108,12 @@ export interface PieceContributor {
108
108
  title?: string
109
109
  }
110
110
 
111
- export interface PieceSource { text?: string
111
+ export interface PieceSource {
112
+ text?: string
112
113
  textRef?: string
113
114
  pieceRef?: number
114
115
  relation: 'section' | 'excerpt' | 'standalone'
115
- range?: { start: string; end: string }
116
+ range?: { start?: string; end?: string; chapter?: string; [key: string]: string | undefined }
116
117
  }
117
118
 
118
119
  export interface ProseSection {
@@ -130,6 +131,7 @@ export interface Part {
130
131
  source?: PieceSource
131
132
  verses: VerseLine[]
132
133
  annotations: Annotation[]
134
+ annotationText?: string
133
135
  }
134
136
 
135
137
  export interface Piece {
@@ -5,6 +5,7 @@ import { useReadingMode } from '../composables/useReadingMode'
5
5
  import { useHorizontalScroll } from '../composables/useHorizontalScroll'
6
6
  import SideNav from '../components/SideNav.vue'
7
7
  import ReadingToolbar from '../components/ReadingToolbar.vue'
8
+ import BackToTop from '../components/BackToTop.vue'
8
9
  import { useSiteConfig } from '../composables/useSiteConfig'
9
10
  import { ref, computed } from 'vue'
10
11
  import { useRouter } from 'vue-router'
@@ -63,6 +64,7 @@ function goHome() { router.push('/') }
63
64
  <p>Hanology is a digital library for classical Chinese texts. We believe that the wisdom of antiquity should not be locked behind impenetrable editions or forgotten in dusty shelves. By combining rigorous scholarship with thoughtful design, we make the classics accessible, beautiful, and alive for every reader.</p>
64
65
  </div>
65
66
  </div>
67
+ <BackToTop />
66
68
  <ReadingToolbar />
67
69
  </div>
68
70
  </template>
@@ -80,6 +82,7 @@ function goHome() { router.push('/') }
80
82
  background: var(--paper);
81
83
  scrollbar-width: thin;
82
84
  scrollbar-color: var(--gold) transparent;
85
+ scroll-snap-type: x proximity;
83
86
  }
84
87
  .v-page::-webkit-scrollbar { height: 4px; }
85
88
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -161,6 +164,11 @@ function goHome() { router.push('/') }
161
164
  background: var(--surface);
162
165
  border: 1px solid var(--border-light);
163
166
  border-radius: 8px;
167
+ animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
168
+ }
169
+ @keyframes cardEnter {
170
+ from { opacity: 0; transform: translateY(12px); }
171
+ to { opacity: 1; transform: translateY(0); }
164
172
  }
165
173
  .h-about-block:last-child { margin-bottom: 0; }
166
174
  .h-about-block h2 {
@@ -8,6 +8,7 @@ import { useHorizontalScroll } from '../composables/useHorizontalScroll'
8
8
  import PoemCard from '../components/PoemCard.vue'
9
9
  import SideNav from '../components/SideNav.vue'
10
10
  import ReadingToolbar from '../components/ReadingToolbar.vue'
11
+ import BackToTop from '../components/BackToTop.vue'
11
12
 
12
13
  const props = defineProps<{ bookId: string }>()
13
14
  const router = useRouter()
@@ -128,14 +129,17 @@ function scrollToCatalog() {
128
129
  </div>
129
130
  <div class="h-grid">
130
131
  <PoemCard
131
- v-for="piece in filtered"
132
+ v-for="(piece, idx) in filtered"
132
133
  :key="piece.num"
133
134
  :poem="piece"
135
+ :style="{ animationDelay: Math.min(idx * 0.04, 0.8) + 's' }"
136
+ class="h-card-anim"
134
137
  @click="openPiece(piece.num)"
135
138
  />
136
139
  </div>
137
140
  </section>
138
141
 
142
+ <BackToTop />
139
143
  <ReadingToolbar />
140
144
  </div>
141
145
  </template>
@@ -154,6 +158,7 @@ function scrollToCatalog() {
154
158
  background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
155
159
  scrollbar-width: thin;
156
160
  scrollbar-color: var(--gold) transparent;
161
+ scroll-snap-type: x proximity;
157
162
  }
158
163
  .v-page::-webkit-scrollbar { height: 4px; }
159
164
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -358,6 +363,9 @@ function scrollToCatalog() {
358
363
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
359
364
  gap: 16px;
360
365
  }
366
+ .h-card-anim {
367
+ animation: cardEnter 0.4s var(--ease-out-expo) both;
368
+ }
361
369
 
362
370
  @media (max-width: 768px) {
363
371
  .h-stats { gap: 24px; }
@@ -9,6 +9,7 @@ import { useHorizontalScroll } from '../composables/useHorizontalScroll'
9
9
  import BookCard from '../components/BookCard.vue'
10
10
  import SideNav from '../components/SideNav.vue'
11
11
  import ReadingToolbar from '../components/ReadingToolbar.vue'
12
+ import BackToTop from '../components/BackToTop.vue'
12
13
  import { useSiteConfig } from '../composables/useSiteConfig'
13
14
  import type { BookMeta } from '../types'
14
15
 
@@ -125,9 +126,10 @@ function openBook(bookId: string) {
125
126
  <h2 class="lib-group-title">{{ group.category }}</h2>
126
127
  <div class="lib-grid">
127
128
  <div
128
- v-for="book in group.books"
129
+ v-for="(book, bi) in group.books"
129
130
  :key="book.id"
130
- class="lib-card"
131
+ class="lib-card lib-card-anim"
132
+ :style="{ animationDelay: bi * 0.06 + 's' }"
131
133
  @click="openBook(book.id)"
132
134
  >
133
135
  <div class="lib-card-accent"></div>
@@ -145,6 +147,7 @@ function openBook(bookId: string) {
145
147
  </div>
146
148
  </div>
147
149
  <ReadingToolbar />
150
+ <BackToTop />
148
151
  </div>
149
152
  </div>
150
153
  </template>
@@ -163,6 +166,7 @@ function openBook(bookId: string) {
163
166
  background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
164
167
  scrollbar-width: thin;
165
168
  scrollbar-color: var(--gold) transparent;
169
+ scroll-snap-type: x proximity;
166
170
  }
167
171
  .v-page::-webkit-scrollbar { height: 4px; }
168
172
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -383,6 +387,10 @@ function openBook(bookId: string) {
383
387
  gap: 12px;
384
388
  }
385
389
 
390
+ .lib-card-anim {
391
+ animation: cardEnter 0.4s var(--ease-out-expo) both;
392
+ }
393
+
386
394
  .lib-card {
387
395
  display: flex;
388
396
  flex-direction: column;
@@ -391,9 +399,14 @@ function openBook(bookId: string) {
391
399
  border: 1px solid var(--border-light);
392
400
  border-radius: 8px;
393
401
  cursor: pointer;
394
- transition: all 0.3s ease;
402
+ transition: all 0.3s var(--ease-out-expo, ease);
395
403
  position: relative;
396
404
  background: var(--surface);
405
+ animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
406
+ }
407
+ @keyframes cardEnter {
408
+ from { opacity: 0; transform: translateY(12px); }
409
+ to { opacity: 1; transform: translateY(0); }
397
410
  }
398
411
  .lib-card:hover { border-color: var(--gold); box-shadow: 0 4px 20px rgba(var(--shadow-rgb), 0.1); }
399
412
  .lib-card-accent {
@@ -14,6 +14,8 @@ import AnnotationTooltip from '../components/AnnotationTooltip.vue'
14
14
  import AnnotationControlBar from '../components/AnnotationControlBar.vue'
15
15
  import SideNav from '../components/SideNav.vue'
16
16
  import PartGroup from '../components/PartGroup.vue'
17
+ import ReadingProgress from '../components/ReadingProgress.vue'
18
+ import BackToTop from '../components/BackToTop.vue'
17
19
  import type { Piece, Annotation, AnnotationLayer, Part } from '../types'
18
20
 
19
21
  const props = defineProps<{ bookId: string; num: string | number }>()
@@ -59,6 +61,17 @@ useTitle(pageTitle.value)
59
61
 
60
62
  const isVertical = computed(() => layout.value === 'vertical')
61
63
 
64
+ const totalAnnotationCount = computed(() => {
65
+ if (!piece.value) return 0
66
+ let count = piece.value.annotations.length
67
+ if (piece.value.annotationLayers) {
68
+ for (const layer of piece.value.annotationLayers) {
69
+ count += layer.annotations.length
70
+ }
71
+ }
72
+ return count
73
+ })
74
+
62
75
  const annotationLayers = computed<AnnotationLayer[]>(() => piece.value?.annotationLayers || [])
63
76
  const hasLayers = computed(() => annotationLayers.value.length > 1)
64
77
  const activeLayerIds = ref<string[]>([])
@@ -242,6 +255,7 @@ function tcy(n: number): string {
242
255
  @back="goBack"
243
256
  @home="goHome"
244
257
  />
258
+ <ReadingProgress vertical :scroll-container="vPageRef" />
245
259
  <div ref="vPageRef" class="v-page">
246
260
  <section ref="vTitleRef" class="v-title-col">
247
261
  <h1 class="v-poem-title">{{ piece.title }}</h1>
@@ -262,7 +276,7 @@ function tcy(n: number): string {
262
276
  </template>
263
277
  <template v-else>
264
278
  <span class="v-meta-item" v-html="tcy(piece.verses.length) + ' 段'" />
265
- <span class="v-meta-item" v-html="piece.annotations.length > 0 ? tcy(piece.annotations.length) + ' 注' : '無注'" />
279
+ <span class="v-meta-item" v-html="totalAnnotationCount > 0 ? tcy(totalAnnotationCount) + ' 注' : '無注'" />
266
280
  </template>
267
281
  </div>
268
282
  </section>
@@ -374,24 +388,27 @@ function tcy(n: number): string {
374
388
  />
375
389
 
376
390
  <Teleport to="body">
377
- <div v-if="authorPaneOpen" class="v-overlay" @click="closeAuthorPane">
378
- <div class="v-author-pane" @click.stop>
379
- <button class="v-pane-close" @click="closeAuthorPane">✕</button>
380
- <div class="v-pane-header">
381
- <div class="v-pane-name">{{ selectedAuthorName }}</div>
382
- </div>
383
- <div v-if="selectedAuthorBio" class="v-pane-bio">
384
- <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="v-pane-p">
385
- {{ p.trim() }}
391
+ <Transition name="overlay">
392
+ <div v-if="authorPaneOpen" class="v-overlay" @click="closeAuthorPane">
393
+ <div class="v-author-pane" @click.stop>
394
+ <button class="v-pane-close" @click="closeAuthorPane">✕</button>
395
+ <div class="v-pane-header">
396
+ <div class="v-pane-name">{{ selectedAuthorName }}</div>
397
+ </div>
398
+ <div v-if="selectedAuthorBio" class="v-pane-bio">
399
+ <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="v-pane-p">
400
+ {{ p.trim() }}
401
+ </div>
386
402
  </div>
387
403
  </div>
388
404
  </div>
389
- </div>
405
+ </Transition>
390
406
  </Teleport>
391
407
  </div>
392
408
 
393
409
  <!-- ═══════ 橫排模式 ═══════ -->
394
410
  <div v-else class="h-root">
411
+ <ReadingProgress />
395
412
  <div class="h-page">
396
413
  <nav class="h-nav">
397
414
  <div class="h-nav-inner">
@@ -419,7 +436,7 @@ function tcy(n: number): string {
419
436
  </template>
420
437
  <template v-else>
421
438
  <span class="h-tag">{{ piece.verses.length }} 段</span>
422
- <span class="h-tag">{{ piece.annotations.length > 0 ? piece.annotations.length + ' 注' : '無注' }}</span>
439
+ <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' 注' : '無注' }}</span>
423
440
  </template>
424
441
  </div>
425
442
  </div>
@@ -516,8 +533,11 @@ function tcy(n: number): string {
516
533
  @tooltip-leave="interaction.onTooltipLeave"
517
534
  />
518
535
 
536
+ <BackToTop />
537
+
519
538
  <Teleport to="body">
520
- <div v-if="authorPaneOpen" class="h-overlay" @click="closeAuthorPane">
539
+ <Transition name="overlay">
540
+ <div v-if="authorPaneOpen" class="h-overlay" @click="closeAuthorPane">
521
541
  <div class="h-pane" @click.stop>
522
542
  <button class="h-pane-close" @click="closeAuthorPane">✕</button>
523
543
  <div class="h-pane-header">
@@ -532,13 +552,13 @@ function tcy(n: number): string {
532
552
  </div>
533
553
  </div>
534
554
  </div>
535
- </div>
555
+ </Transition>
536
556
  </Teleport>
537
557
  </div>
538
558
  </div>
539
559
 
540
- <div v-else style="text-align:center;padding-top:120px">
541
- <p style="font-size:18px;color:var(--ink-faint)">載入中…</p>
560
+ <div v-else class="loading">
561
+ <div class="loading-seal">詩</div>
542
562
  </div>
543
563
  </template>
544
564
 
@@ -556,6 +576,7 @@ function tcy(n: number): string {
556
576
  background: var(--paper);
557
577
  scrollbar-width: thin;
558
578
  scrollbar-color: var(--gold) transparent;
579
+ scroll-snap-type: x proximity;
559
580
  }
560
581
  .v-page::-webkit-scrollbar { height: 4px; }
561
582
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -571,6 +592,7 @@ function tcy(n: number): string {
571
592
  gap: 16px;
572
593
  padding: 40px 24px;
573
594
  border-right: 1px solid var(--border);
595
+ scroll-snap-align: start;
574
596
  }
575
597
  .v-poem-title {
576
598
  font-size: 40px; font-weight: 900;
@@ -659,6 +681,7 @@ function tcy(n: number): string {
659
681
  justify-content: center;
660
682
  padding: 24px 12px;
661
683
  gap: 32px;
684
+ scroll-snap-align: start;
662
685
  }
663
686
  .v-nav-spacer { flex: 1; }
664
687
  .v-nav-btn {
@@ -774,38 +797,63 @@ function tcy(n: number): string {
774
797
  .h-nav-bottom {
775
798
  max-width: min(680px, calc(100vw - 80px));
776
799
  margin: 0 auto 60px;
777
- display: flex; justify-content: space-between; gap: 16px;
800
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
778
801
  }
779
802
  .h-nav-btn {
780
- flex: 1; padding: 16px 24px;
803
+ padding: 20px 24px;
781
804
  background: var(--surface); border: 1px solid var(--border-light);
782
805
  border-radius: 8px; cursor: pointer;
783
- transition: all 0.2s ease; font-family: var(--serif);
806
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
807
+ font-family: var(--serif);
784
808
  text-align: left;
809
+ position: relative;
810
+ overflow: hidden;
811
+ }
812
+ .h-nav-btn::after {
813
+ content: '';
814
+ position: absolute;
815
+ bottom: 0; left: 0; right: 0;
816
+ height: 2px;
817
+ background: var(--vermillion);
818
+ transform: scaleX(0);
819
+ transition: transform 0.3s ease;
820
+ }
821
+ .h-nav-btn:hover {
822
+ border-color: var(--gold);
823
+ box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.1);
824
+ transform: translateY(-2px);
785
825
  }
786
- .h-nav-btn:hover { border-color: var(--gold); box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08); }
826
+ .h-nav-btn:hover::after { transform: scaleX(1); }
787
827
  .h-nav-btn.h-nav-next { text-align: right; }
788
828
  .h-nav-label { font-size: 11px; color: var(--ink-faint); font-family: var(--sans); letter-spacing: 2px; margin-bottom: 4px; }
789
829
  .h-nav-title { font-size: 16px; font-weight: 600; letter-spacing: 1px; color: var(--ink); }
790
830
 
791
831
  .h-overlay {
792
832
  position: fixed; inset: 0;
793
- background: rgba(var(--shadow-rgb), 0.3);
833
+ background: rgba(var(--shadow-rgb), 0.24);
834
+ backdrop-filter: blur(12px);
835
+ -webkit-backdrop-filter: blur(12px);
794
836
  z-index: 200;
795
837
  display: flex; justify-content: flex-end;
796
- animation: fadeIn 0.2s ease;
797
838
  }
798
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
799
839
  .h-pane {
800
840
  width: min(420px, 90vw);
801
841
  height: 100vh;
802
842
  background: var(--paper);
803
843
  padding: 32px;
804
844
  overflow-y: auto;
805
- animation: slideIn 0.25s ease;
806
845
  box-shadow: -8px 0 32px rgba(var(--shadow-rgb), 0.1);
807
846
  }
808
- @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
847
+
848
+ /* Overlay transition */
849
+ .overlay-enter-active { transition: opacity var(--dur-mid, 0.25s) ease; }
850
+ .overlay-enter-active .h-pane { transition: transform var(--dur-mid, 0.25s) cubic-bezier(0.34, 1.56, 0.64, 1); }
851
+ .overlay-leave-active { transition: opacity var(--dur-fast, 0.15s) ease; }
852
+ .overlay-leave-active .h-pane { transition: transform var(--dur-fast, 0.15s) ease; }
853
+ .overlay-enter-from { opacity: 0; }
854
+ .overlay-enter-from .h-pane { transform: translateX(100%); }
855
+ .overlay-leave-to { opacity: 0; }
856
+ .overlay-leave-to .h-pane { transform: translateX(40px); }
809
857
  .h-pane-close {
810
858
  display: block; margin-left: auto;
811
859
  width: 36px; height: 36px;
@@ -837,10 +885,11 @@ function tcy(n: number): string {
837
885
 
838
886
  .v-overlay {
839
887
  position: fixed; inset: 0;
840
- background: rgba(var(--shadow-rgb), 0.3);
888
+ background: rgba(var(--shadow-rgb), 0.24);
889
+ backdrop-filter: blur(12px);
890
+ -webkit-backdrop-filter: blur(12px);
841
891
  z-index: 200;
842
892
  display: flex; justify-content: flex-start;
843
- animation: fadeIn 0.2s ease;
844
893
  }
845
894
  .v-author-pane {
846
895
  writing-mode: vertical-rl;
@@ -850,9 +899,7 @@ function tcy(n: number): string {
850
899
  padding: 32px 24px;
851
900
  overflow-x: auto;
852
901
  box-shadow: 8px 0 32px rgba(var(--shadow-rgb), 0.1);
853
- animation: slideInV 0.25s ease;
854
902
  }
855
- @keyframes slideInV { from { transform: translateX(-100%); } to { transform: translateX(0); } }
856
903
  .v-pane-close {
857
904
  display: block;
858
905
  width: 32px; height: 32px;
@@ -894,6 +941,25 @@ function tcy(n: number): string {
894
941
  margin-left: 12px;
895
942
  }
896
943
 
944
+ .loading {
945
+ display: flex; flex-direction: column;
946
+ align-items: center; justify-content: center;
947
+ height: 100vh;
948
+ }
949
+ .loading-seal {
950
+ width: 56px; height: 56px;
951
+ border: 2px solid var(--vermillion);
952
+ border-radius: 4px;
953
+ display: flex; align-items: center; justify-content: center;
954
+ font-size: 28px; font-weight: 900;
955
+ color: var(--vermillion);
956
+ animation: pulse 1.2s ease-in-out infinite;
957
+ }
958
+ @keyframes pulse {
959
+ 0%, 100% { opacity: 0.3; }
960
+ 50% { opacity: 1; }
961
+ }
962
+
897
963
  @media (max-width: 768px) {
898
964
  .h-content { padding: 30px 20px; }
899
965
  }