@hanology/cham-browser 0.3.5 → 0.3.7

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.5",
3
+ "version": "0.3.7",
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>
@@ -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;
@@ -155,6 +155,25 @@ function close() { open.value = false }
155
155
  transition: all 0.15s;
156
156
  }
157
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; }
158
177
  .rt-opt.active {
159
178
  background: var(--ink);
160
179
  color: var(--paper);
@@ -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) {
@@ -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>
@@ -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>
@@ -359,6 +363,9 @@ function scrollToCatalog() {
359
363
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
360
364
  gap: 16px;
361
365
  }
366
+ .h-card-anim {
367
+ animation: cardEnter 0.4s var(--ease-out-expo) both;
368
+ }
362
369
 
363
370
  @media (max-width: 768px) {
364
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
 
@@ -127,7 +128,7 @@ function openBook(bookId: string) {
127
128
  <div
128
129
  v-for="(book, bi) in group.books"
129
130
  :key="book.id"
130
- class="lib-card"
131
+ class="lib-card lib-card-anim"
131
132
  :style="{ animationDelay: bi * 0.06 + 's' }"
132
133
  @click="openBook(book.id)"
133
134
  >
@@ -146,6 +147,7 @@ function openBook(bookId: string) {
146
147
  </div>
147
148
  </div>
148
149
  <ReadingToolbar />
150
+ <BackToTop />
149
151
  </div>
150
152
  </div>
151
153
  </template>
@@ -385,6 +387,10 @@ function openBook(bookId: string) {
385
387
  gap: 12px;
386
388
  }
387
389
 
390
+ .lib-card-anim {
391
+ animation: cardEnter 0.4s var(--ease-out-expo) both;
392
+ }
393
+
388
394
  .lib-card {
389
395
  display: flex;
390
396
  flex-direction: column;
@@ -15,6 +15,7 @@ import AnnotationControlBar from '../components/AnnotationControlBar.vue'
15
15
  import SideNav from '../components/SideNav.vue'
16
16
  import PartGroup from '../components/PartGroup.vue'
17
17
  import ReadingProgress from '../components/ReadingProgress.vue'
18
+ import BackToTop from '../components/BackToTop.vue'
18
19
  import type { Piece, Annotation, AnnotationLayer, Part } from '../types'
19
20
 
20
21
  const props = defineProps<{ bookId: string; num: string | number }>()
@@ -387,19 +388,21 @@ function tcy(n: number): string {
387
388
  />
388
389
 
389
390
  <Teleport to="body">
390
- <div v-if="authorPaneOpen" class="v-overlay" @click="closeAuthorPane">
391
- <div class="v-author-pane" @click.stop>
392
- <button class="v-pane-close" @click="closeAuthorPane">✕</button>
393
- <div class="v-pane-header">
394
- <div class="v-pane-name">{{ selectedAuthorName }}</div>
395
- </div>
396
- <div v-if="selectedAuthorBio" class="v-pane-bio">
397
- <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="v-pane-p">
398
- {{ 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>
399
402
  </div>
400
403
  </div>
401
404
  </div>
402
- </div>
405
+ </Transition>
403
406
  </Teleport>
404
407
  </div>
405
408
 
@@ -530,23 +533,27 @@ function tcy(n: number): string {
530
533
  @tooltip-leave="interaction.onTooltipLeave"
531
534
  />
532
535
 
536
+ <BackToTop />
537
+
533
538
  <Teleport to="body">
534
- <div v-if="authorPaneOpen" class="h-overlay" @click="closeAuthorPane">
535
- <div class="h-pane" @click.stop>
536
- <button class="h-pane-close" @click="closeAuthorPane">✕</button>
537
- <div class="h-pane-header">
538
- <div>
539
- <div class="h-pane-name">{{ selectedAuthorName }}</div>
540
- <div class="h-pane-meta">{{ piece.title }} 等</div>
539
+ <Transition name="overlay">
540
+ <div v-if="authorPaneOpen" class="h-overlay" @click="closeAuthorPane">
541
+ <div class="h-pane" @click.stop>
542
+ <button class="h-pane-close" @click="closeAuthorPane">✕</button>
543
+ <div class="h-pane-header">
544
+ <div>
545
+ <div class="h-pane-name">{{ selectedAuthorName }}</div>
546
+ <div class="h-pane-meta">{{ piece.title }} 等</div>
547
+ </div>
541
548
  </div>
542
- </div>
543
- <div v-if="selectedAuthorBio" class="h-pane-bio">
544
- <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="h-pane-p">
545
- {{ p.trim() }}
549
+ <div v-if="selectedAuthorBio" class="h-pane-bio">
550
+ <div v-for="p in selectedAuthorBio.split('\n').filter(l => l.trim())" :key="p" class="h-pane-p">
551
+ {{ p.trim() }}
552
+ </div>
546
553
  </div>
547
554
  </div>
548
555
  </div>
549
- </div>
556
+ </Transition>
550
557
  </Teleport>
551
558
  </div>
552
559
  </div>
@@ -791,40 +798,63 @@ function tcy(n: number): string {
791
798
  .h-nav-bottom {
792
799
  max-width: min(680px, calc(100vw - 80px));
793
800
  margin: 0 auto 60px;
794
- display: flex; justify-content: space-between; gap: 16px;
801
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
795
802
  }
796
803
  .h-nav-btn {
797
- flex: 1; padding: 16px 24px;
804
+ padding: 20px 24px;
798
805
  background: var(--surface); border: 1px solid var(--border-light);
799
806
  border-radius: 8px; cursor: pointer;
800
- transition: all 0.2s ease; font-family: var(--serif);
807
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
808
+ font-family: var(--serif);
801
809
  text-align: left;
810
+ position: relative;
811
+ overflow: hidden;
812
+ }
813
+ .h-nav-btn::after {
814
+ content: '';
815
+ position: absolute;
816
+ bottom: 0; left: 0; right: 0;
817
+ height: 2px;
818
+ background: var(--vermillion);
819
+ transform: scaleX(0);
820
+ transition: transform 0.3s ease;
821
+ }
822
+ .h-nav-btn:hover {
823
+ border-color: var(--gold);
824
+ box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.1);
825
+ transform: translateY(-2px);
802
826
  }
803
- .h-nav-btn:hover { border-color: var(--gold); box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08); }
827
+ .h-nav-btn:hover::after { transform: scaleX(1); }
804
828
  .h-nav-btn.h-nav-next { text-align: right; }
805
829
  .h-nav-label { font-size: 11px; color: var(--ink-faint); font-family: var(--sans); letter-spacing: 2px; margin-bottom: 4px; }
806
830
  .h-nav-title { font-size: 16px; font-weight: 600; letter-spacing: 1px; color: var(--ink); }
807
831
 
808
832
  .h-overlay {
809
833
  position: fixed; inset: 0;
810
- background: rgba(var(--shadow-rgb), 0.2);
811
- backdrop-filter: blur(8px);
812
- -webkit-backdrop-filter: blur(8px);
834
+ background: rgba(var(--shadow-rgb), 0.24);
835
+ backdrop-filter: blur(12px);
836
+ -webkit-backdrop-filter: blur(12px);
813
837
  z-index: 200;
814
838
  display: flex; justify-content: flex-end;
815
- animation: fadeIn 0.2s ease;
816
839
  }
817
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
818
840
  .h-pane {
819
841
  width: min(420px, 90vw);
820
842
  height: 100vh;
821
843
  background: var(--paper);
822
844
  padding: 32px;
823
845
  overflow-y: auto;
824
- animation: slideIn 0.25s ease;
825
846
  box-shadow: -8px 0 32px rgba(var(--shadow-rgb), 0.1);
826
847
  }
827
- @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
848
+
849
+ /* Overlay transition */
850
+ .overlay-enter-active { transition: opacity var(--dur-mid, 0.25s) ease; }
851
+ .overlay-enter-active .h-pane { transition: transform var(--dur-mid, 0.25s) cubic-bezier(0.34, 1.56, 0.64, 1); }
852
+ .overlay-leave-active { transition: opacity var(--dur-fast, 0.15s) ease; }
853
+ .overlay-leave-active .h-pane { transition: transform var(--dur-fast, 0.15s) ease; }
854
+ .overlay-enter-from { opacity: 0; }
855
+ .overlay-enter-from .h-pane { transform: translateX(100%); }
856
+ .overlay-leave-to { opacity: 0; }
857
+ .overlay-leave-to .h-pane { transform: translateX(40px); }
828
858
  .h-pane-close {
829
859
  display: block; margin-left: auto;
830
860
  width: 36px; height: 36px;
@@ -856,12 +886,11 @@ function tcy(n: number): string {
856
886
 
857
887
  .v-overlay {
858
888
  position: fixed; inset: 0;
859
- background: rgba(var(--shadow-rgb), 0.2);
860
- backdrop-filter: blur(8px);
861
- -webkit-backdrop-filter: blur(8px);
889
+ background: rgba(var(--shadow-rgb), 0.24);
890
+ backdrop-filter: blur(12px);
891
+ -webkit-backdrop-filter: blur(12px);
862
892
  z-index: 200;
863
893
  display: flex; justify-content: flex-start;
864
- animation: fadeIn 0.2s ease;
865
894
  }
866
895
  .v-author-pane {
867
896
  writing-mode: vertical-rl;
@@ -871,9 +900,7 @@ function tcy(n: number): string {
871
900
  padding: 32px 24px;
872
901
  overflow-x: auto;
873
902
  box-shadow: 8px 0 32px rgba(var(--shadow-rgb), 0.1);
874
- animation: slideInV 0.25s ease;
875
903
  }
876
- @keyframes slideInV { from { transform: translateX(-100%); } to { transform: translateX(0); } }
877
904
  .v-pane-close {
878
905
  display: block;
879
906
  width: 32px; height: 32px;