@hanology/cham-browser 0.3.4 → 0.3.5

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 CHANGED
File without changes
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.5",
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",
@@ -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)"
@@ -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>
@@ -182,4 +187,35 @@ function close() { open.value = false }
182
187
  position: fixed; inset: 0;
183
188
  z-index: -1;
184
189
  }
190
+ .rt-shortcuts {
191
+ display: flex;
192
+ flex-wrap: wrap;
193
+ gap: 6px;
194
+ padding-top: 10px;
195
+ border-top: 1px solid var(--border-light);
196
+ margin-top: 2px;
197
+ }
198
+ .rt-sc {
199
+ font-family: var(--sans);
200
+ font-size: 10px;
201
+ color: var(--ink-faint);
202
+ letter-spacing: 1px;
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 3px;
206
+ }
207
+ .rt-sc kbd {
208
+ display: inline-flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ min-width: 18px;
212
+ height: 16px;
213
+ padding: 0 3px;
214
+ border: 1px solid var(--border);
215
+ border-radius: 2px;
216
+ font-family: var(--sans);
217
+ font-size: 9px;
218
+ color: var(--ink-light);
219
+ background: var(--surface);
220
+ }
185
221
  </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; }
@@ -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 {
@@ -80,6 +80,7 @@ function goHome() { router.push('/') }
80
80
  background: var(--paper);
81
81
  scrollbar-width: thin;
82
82
  scrollbar-color: var(--gold) transparent;
83
+ scroll-snap-type: x proximity;
83
84
  }
84
85
  .v-page::-webkit-scrollbar { height: 4px; }
85
86
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -161,6 +162,11 @@ function goHome() { router.push('/') }
161
162
  background: var(--surface);
162
163
  border: 1px solid var(--border-light);
163
164
  border-radius: 8px;
165
+ animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
166
+ }
167
+ @keyframes cardEnter {
168
+ from { opacity: 0; transform: translateY(12px); }
169
+ to { opacity: 1; transform: translateY(0); }
164
170
  }
165
171
  .h-about-block:last-child { margin-bottom: 0; }
166
172
  .h-about-block h2 {
@@ -154,6 +154,7 @@ function scrollToCatalog() {
154
154
  background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
155
155
  scrollbar-width: thin;
156
156
  scrollbar-color: var(--gold) transparent;
157
+ scroll-snap-type: x proximity;
157
158
  }
158
159
  .v-page::-webkit-scrollbar { height: 4px; }
159
160
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -125,9 +125,10 @@ function openBook(bookId: string) {
125
125
  <h2 class="lib-group-title">{{ group.category }}</h2>
126
126
  <div class="lib-grid">
127
127
  <div
128
- v-for="book in group.books"
128
+ v-for="(book, bi) in group.books"
129
129
  :key="book.id"
130
130
  class="lib-card"
131
+ :style="{ animationDelay: bi * 0.06 + 's' }"
131
132
  @click="openBook(book.id)"
132
133
  >
133
134
  <div class="lib-card-accent"></div>
@@ -163,6 +164,7 @@ function openBook(bookId: string) {
163
164
  background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
164
165
  scrollbar-width: thin;
165
166
  scrollbar-color: var(--gold) transparent;
167
+ scroll-snap-type: x proximity;
166
168
  }
167
169
  .v-page::-webkit-scrollbar { height: 4px; }
168
170
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -391,9 +393,14 @@ function openBook(bookId: string) {
391
393
  border: 1px solid var(--border-light);
392
394
  border-radius: 8px;
393
395
  cursor: pointer;
394
- transition: all 0.3s ease;
396
+ transition: all 0.3s var(--ease-out-expo, ease);
395
397
  position: relative;
396
398
  background: var(--surface);
399
+ animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
400
+ }
401
+ @keyframes cardEnter {
402
+ from { opacity: 0; transform: translateY(12px); }
403
+ to { opacity: 1; transform: translateY(0); }
397
404
  }
398
405
  .lib-card:hover { border-color: var(--gold); box-shadow: 0 4px 20px rgba(var(--shadow-rgb), 0.1); }
399
406
  .lib-card-accent {
@@ -14,6 +14,7 @@ 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'
17
18
  import type { Piece, Annotation, AnnotationLayer, Part } from '../types'
18
19
 
19
20
  const props = defineProps<{ bookId: string; num: string | number }>()
@@ -59,6 +60,17 @@ useTitle(pageTitle.value)
59
60
 
60
61
  const isVertical = computed(() => layout.value === 'vertical')
61
62
 
63
+ const totalAnnotationCount = computed(() => {
64
+ if (!piece.value) return 0
65
+ let count = piece.value.annotations.length
66
+ if (piece.value.annotationLayers) {
67
+ for (const layer of piece.value.annotationLayers) {
68
+ count += layer.annotations.length
69
+ }
70
+ }
71
+ return count
72
+ })
73
+
62
74
  const annotationLayers = computed<AnnotationLayer[]>(() => piece.value?.annotationLayers || [])
63
75
  const hasLayers = computed(() => annotationLayers.value.length > 1)
64
76
  const activeLayerIds = ref<string[]>([])
@@ -242,6 +254,7 @@ function tcy(n: number): string {
242
254
  @back="goBack"
243
255
  @home="goHome"
244
256
  />
257
+ <ReadingProgress vertical :scroll-container="vPageRef" />
245
258
  <div ref="vPageRef" class="v-page">
246
259
  <section ref="vTitleRef" class="v-title-col">
247
260
  <h1 class="v-poem-title">{{ piece.title }}</h1>
@@ -262,7 +275,7 @@ function tcy(n: number): string {
262
275
  </template>
263
276
  <template v-else>
264
277
  <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) + ' 注' : '無注'" />
278
+ <span class="v-meta-item" v-html="totalAnnotationCount > 0 ? tcy(totalAnnotationCount) + ' 注' : '無注'" />
266
279
  </template>
267
280
  </div>
268
281
  </section>
@@ -392,6 +405,7 @@ function tcy(n: number): string {
392
405
 
393
406
  <!-- ═══════ 橫排模式 ═══════ -->
394
407
  <div v-else class="h-root">
408
+ <ReadingProgress />
395
409
  <div class="h-page">
396
410
  <nav class="h-nav">
397
411
  <div class="h-nav-inner">
@@ -419,7 +433,7 @@ function tcy(n: number): string {
419
433
  </template>
420
434
  <template v-else>
421
435
  <span class="h-tag">{{ piece.verses.length }} 段</span>
422
- <span class="h-tag">{{ piece.annotations.length > 0 ? piece.annotations.length + ' 注' : '無注' }}</span>
436
+ <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' 注' : '無注' }}</span>
423
437
  </template>
424
438
  </div>
425
439
  </div>
@@ -537,8 +551,8 @@ function tcy(n: number): string {
537
551
  </div>
538
552
  </div>
539
553
 
540
- <div v-else style="text-align:center;padding-top:120px">
541
- <p style="font-size:18px;color:var(--ink-faint)">載入中…</p>
554
+ <div v-else class="loading">
555
+ <div class="loading-seal">詩</div>
542
556
  </div>
543
557
  </template>
544
558
 
@@ -556,6 +570,7 @@ function tcy(n: number): string {
556
570
  background: var(--paper);
557
571
  scrollbar-width: thin;
558
572
  scrollbar-color: var(--gold) transparent;
573
+ scroll-snap-type: x proximity;
559
574
  }
560
575
  .v-page::-webkit-scrollbar { height: 4px; }
561
576
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -571,6 +586,7 @@ function tcy(n: number): string {
571
586
  gap: 16px;
572
587
  padding: 40px 24px;
573
588
  border-right: 1px solid var(--border);
589
+ scroll-snap-align: start;
574
590
  }
575
591
  .v-poem-title {
576
592
  font-size: 40px; font-weight: 900;
@@ -659,6 +675,7 @@ function tcy(n: number): string {
659
675
  justify-content: center;
660
676
  padding: 24px 12px;
661
677
  gap: 32px;
678
+ scroll-snap-align: start;
662
679
  }
663
680
  .v-nav-spacer { flex: 1; }
664
681
  .v-nav-btn {
@@ -790,7 +807,9 @@ function tcy(n: number): string {
790
807
 
791
808
  .h-overlay {
792
809
  position: fixed; inset: 0;
793
- background: rgba(var(--shadow-rgb), 0.3);
810
+ background: rgba(var(--shadow-rgb), 0.2);
811
+ backdrop-filter: blur(8px);
812
+ -webkit-backdrop-filter: blur(8px);
794
813
  z-index: 200;
795
814
  display: flex; justify-content: flex-end;
796
815
  animation: fadeIn 0.2s ease;
@@ -837,7 +856,9 @@ function tcy(n: number): string {
837
856
 
838
857
  .v-overlay {
839
858
  position: fixed; inset: 0;
840
- background: rgba(var(--shadow-rgb), 0.3);
859
+ background: rgba(var(--shadow-rgb), 0.2);
860
+ backdrop-filter: blur(8px);
861
+ -webkit-backdrop-filter: blur(8px);
841
862
  z-index: 200;
842
863
  display: flex; justify-content: flex-start;
843
864
  animation: fadeIn 0.2s ease;
@@ -894,6 +915,25 @@ function tcy(n: number): string {
894
915
  margin-left: 12px;
895
916
  }
896
917
 
918
+ .loading {
919
+ display: flex; flex-direction: column;
920
+ align-items: center; justify-content: center;
921
+ height: 100vh;
922
+ }
923
+ .loading-seal {
924
+ width: 56px; height: 56px;
925
+ border: 2px solid var(--vermillion);
926
+ border-radius: 4px;
927
+ display: flex; align-items: center; justify-content: center;
928
+ font-size: 28px; font-weight: 900;
929
+ color: var(--vermillion);
930
+ animation: pulse 1.2s ease-in-out infinite;
931
+ }
932
+ @keyframes pulse {
933
+ 0%, 100% { opacity: 0.3; }
934
+ 50% { opacity: 1; }
935
+ }
936
+
897
937
  @media (max-width: 768px) {
898
938
  .h-content { padding: 30px 20px; }
899
939
  }