@hanology/cham-browser 0.3.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.3.3",
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",
@@ -15,14 +15,23 @@
15
15
  })();
16
16
  </script>
17
17
  <style>
18
- #app-loading{position:fixed;inset:0;background:var(--paper,#faf6ee);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;font-family:'Noto Serif TC',serif}
19
- #app-loading .char{font-size:64px;font-weight:900;color:var(--ink,#1a1a1a);animation:pulse 1.2s ease-in-out infinite}
20
- #app-loading .text{font-size:13px;color:var(--ink-faint,#a89b8a);letter-spacing:4px;margin-top:16px;font-family:'Noto Sans TC',sans-serif}
21
- @keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
18
+ #app-loading{position:fixed;inset:0;background:var(--paper,#faf6ee);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;font-family:'Noto Serif TC',serif;transition:opacity .4s ease}
19
+ #app-loading.fade-out{opacity:0;pointer-events:none}
20
+ #app-loading .seal{width:72px;height:72px;border:2px solid var(--vermillion,#c23a2b);border-radius:4px;display:flex;align-items:center;justify-content:center;animation:sealReveal .8s cubic-bezier(.16,1,.3,1) forwards;opacity:0}
21
+ #app-loading .char{font-size:36px;font-weight:900;color:var(--vermillion,#c23a2b);line-height:1}
22
+ #app-loading .line{width:1px;height:40px;background:linear-gradient(180deg,var(--vermillion,#c23a2b),transparent);margin-top:24px;animation:lineGrow .6s .3s cubic-bezier(.16,1,.3,1) forwards;transform:scaleY(0);transform-origin:top}
23
+ #app-loading .text{font-size:11px;color:var(--ink-faint,#a89b8a);letter-spacing:6px;margin-top:16px;animation:fadeIn .5s .5s ease forwards;opacity:0;font-family:'Noto Sans TC',sans-serif}
24
+ @keyframes sealReveal{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}
25
+ @keyframes lineGrow{to{transform:scaleY(1)}}
26
+ @keyframes fadeIn{to{opacity:1}}
22
27
  </style>
23
28
  </head>
24
29
  <body>
25
- <div id="app-loading"><div class="char">詩</div><div class="text">載入中</div></div>
30
+ <div id="app-loading">
31
+ <div class="seal"><div class="char">詩</div></div>
32
+ <div class="line"></div>
33
+ <div class="text">載 入 中</div>
34
+ </div>
26
35
  <div id="app"></div>
27
36
  <script type="module" src="/src/main.ts"></script>
28
37
  </body>
@@ -2,7 +2,7 @@
2
2
  import { useRouter } from 'vue-router'
3
3
  import { useReadingMode } from './composables/useReadingMode'
4
4
  import ReadingToolbar from './components/ReadingToolbar.vue'
5
- import { computed } from 'vue'
5
+ import { computed, ref } from 'vue'
6
6
 
7
7
  const router = useRouter()
8
8
  const { toggleLayout, cycleTheme, layout } = useReadingMode()
@@ -19,11 +19,28 @@ function onKey(event: KeyboardEvent) {
19
19
  <template>
20
20
  <div @keydown="onKey">
21
21
  <router-view v-slot="{ Component }">
22
- <Suspense>
23
- <component :is="Component" :key="$route.fullPath" />
24
- </Suspense>
22
+ <Transition name="page" mode="out-in">
23
+ <Suspense>
24
+ <component :is="Component" :key="$route.fullPath" />
25
+ </Suspense>
26
+ </Transition>
25
27
  </router-view>
26
28
  <!-- 橫排模式才顯示浮動設定鈕 -->
27
29
  <ReadingToolbar v-if="!isVertical" />
28
30
  </div>
29
31
  </template>
32
+
33
+ <style>
34
+ .page-enter-active,
35
+ .page-leave-active {
36
+ transition: opacity 0.25s ease, transform 0.25s ease;
37
+ }
38
+ .page-enter-from {
39
+ opacity: 0;
40
+ transform: translateY(8px);
41
+ }
42
+ .page-leave-to {
43
+ opacity: 0;
44
+ transform: translateY(-4px);
45
+ }
46
+ </style>
@@ -0,0 +1,186 @@
1
+ <script setup lang="ts">
2
+ import type { Annotation, VerseLine, PieceSource } from '../types'
3
+ import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations, countVerseSpans } from '../composables/useAnnotationRenderer'
4
+
5
+ const props = defineProps<{
6
+ num: number
7
+ verses: VerseLine[]
8
+ annotations: Annotation[]
9
+ vertical?: boolean
10
+ source?: PieceSource
11
+ annotationText?: string
12
+ }>()
13
+
14
+ const emit = defineEmits<{
15
+ annotationHover: [event: MouseEvent, annotations: Annotation[]]
16
+ annotationLeave: []
17
+ annotationTap: [event: MouseEvent, annotations: Annotation[]]
18
+ }>()
19
+
20
+ function verseHtml(index: number): string {
21
+ const useRuby = props.vertical
22
+ let offset = 0
23
+ for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
24
+ const spans = buildVerseAnnotations(props.annotations, index)
25
+ return renderAnnotatedText(props.verses[index].text, spans, useRuby, offset)
26
+ }
27
+
28
+ function onHover(event: MouseEvent) {
29
+ const matched = resolveHoveredAnnotations(event, props.annotations)
30
+ if (matched) emit('annotationHover', event, matched)
31
+ }
32
+
33
+ function onLeave() { emit('annotationLeave') }
34
+
35
+ function onTap(event: MouseEvent) {
36
+ const matched = resolveHoveredAnnotations(event, props.annotations)
37
+ if (matched) emit('annotationTap', event, matched)
38
+ }
39
+
40
+ const sourceLabel = (() => {
41
+ const r = props.source?.range as Record<string, string> | undefined
42
+ return r?.chapter || ''
43
+ })()
44
+ </script>
45
+
46
+ <template>
47
+ <div class="part-block" :class="{ 'part-block--vertical': vertical }">
48
+ <div v-if="sourceLabel" class="part-source">
49
+ {{ sourceLabel }}
50
+ </div>
51
+ <div class="part-text" @mouseover="onHover" @mouseleave="onLeave" @click="onTap">
52
+ <span
53
+ v-for="(_, i) in verses"
54
+ :key="i"
55
+ :class="vertical ? 'part-line-v' : 'part-line-h'"
56
+ v-html="verseHtml(i)"
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>
62
+ </div>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .part-block {
67
+ padding: 20px 0;
68
+ border-bottom: 1px solid var(--border-light);
69
+ }
70
+
71
+ .part-block:last-child {
72
+ border-bottom: none;
73
+ }
74
+
75
+ .part-block--vertical {
76
+ writing-mode: vertical-rl;
77
+ text-orientation: mixed;
78
+ }
79
+
80
+ .part-source {
81
+ font-family: var(--sans);
82
+ font-size: 12px;
83
+ letter-spacing: 1px;
84
+ color: var(--ink-faint);
85
+ background: var(--surface);
86
+ display: inline-block;
87
+ padding: 3px 10px;
88
+ border-radius: 3px;
89
+ margin-bottom: 12px;
90
+ border: 1px solid var(--border-light);
91
+ }
92
+
93
+ .part-text {
94
+ line-height: 1;
95
+ }
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
+
111
+ .part-line-h {
112
+ font-size: var(--main-font-size, 22px);
113
+ line-height: 2.4;
114
+ letter-spacing: 3px;
115
+ color: var(--ink);
116
+ display: block;
117
+ }
118
+
119
+ .part-line-v {
120
+ font-size: var(--main-font-size, 22px);
121
+ line-height: 2.4;
122
+ letter-spacing: 6px;
123
+ color: var(--ink);
124
+ display: block;
125
+ }
126
+
127
+ :deep(.ann-target) {
128
+ border-bottom: 2px solid var(--vermillion);
129
+ cursor: help;
130
+ transition: background 0.15s;
131
+ }
132
+
133
+ :deep(.ann-target:hover) {
134
+ background: rgba(194, 58, 43, 0.08);
135
+ }
136
+
137
+ :deep(.ann-num) {
138
+ font-size: 10px;
139
+ color: var(--vermillion);
140
+ font-family: var(--sans);
141
+ font-weight: 600;
142
+ vertical-align: super;
143
+ margin-right: 1px;
144
+ letter-spacing: 0;
145
+ }
146
+
147
+ :deep(.ann-target.pronunciation) {
148
+ border-bottom-color: var(--jade);
149
+ }
150
+
151
+ :deep(.ann-target.pronunciation:hover) {
152
+ background: rgba(58, 107, 94, 0.08);
153
+ }
154
+
155
+ .part-block--vertical :deep(.ann-target) {
156
+ border-bottom: none;
157
+ border-left: 2px solid var(--vermillion);
158
+ padding-left: 2px;
159
+ }
160
+
161
+ .part-block--vertical :deep(.ann-target.pronunciation) {
162
+ border-left-color: var(--jade);
163
+ }
164
+
165
+ .part-block--vertical :deep(.ann-num) {
166
+ font-size: 0.45em;
167
+ text-combine-upright: all;
168
+ text-align: end;
169
+ letter-spacing: 0;
170
+ vertical-align: baseline;
171
+ }
172
+
173
+ .part-block--vertical .part-source {
174
+ margin-bottom: 0;
175
+ margin-left: 8px;
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
+ }
186
+ </style>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import type { Annotation, Part } from '../types'
3
+ import PartBlock from './PartBlock.vue'
4
+
5
+ defineProps<{
6
+ label: string
7
+ parts: Part[]
8
+ vertical?: boolean
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ annotationHover: [event: MouseEvent, annotations: Annotation[]]
13
+ annotationLeave: []
14
+ annotationTap: [event: MouseEvent, annotations: Annotation[]]
15
+ }>()
16
+ </script>
17
+
18
+ <template>
19
+ <div class="part-group" :class="{ 'part-group--vertical': vertical }">
20
+ <div class="part-group-label">{{ label }}</div>
21
+ <PartBlock
22
+ v-for="part in parts"
23
+ :key="part.num"
24
+ :num="part.num"
25
+ :verses="part.verses"
26
+ :annotations="part.annotations"
27
+ :annotation-text="part.annotationText"
28
+ :vertical="vertical"
29
+ :source="part.source"
30
+ @annotation-hover="(e, a) => emit('annotationHover', e, a)"
31
+ @annotation-leave="emit('annotationLeave')"
32
+ @annotation-tap="(e, a) => emit('annotationTap', e, a)"
33
+ />
34
+ </div>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .part-group {
39
+ margin-bottom: 40px;
40
+ }
41
+
42
+ .part-group:last-child {
43
+ margin-bottom: 0;
44
+ }
45
+
46
+ .part-group-label {
47
+ font-size: 28px;
48
+ font-weight: 900;
49
+ letter-spacing: 6px;
50
+ color: var(--ink);
51
+ padding-bottom: 12px;
52
+ margin-bottom: 8px;
53
+ border-bottom: 3px solid var(--vermillion);
54
+ display: inline-block;
55
+ }
56
+
57
+ .part-group--vertical {
58
+ writing-mode: vertical-rl;
59
+ text-orientation: mixed;
60
+ }
61
+
62
+ .part-group--vertical .part-group-label {
63
+ font-size: 28px;
64
+ letter-spacing: 10px;
65
+ border-bottom: none;
66
+ border-left: 3px solid var(--vermillion);
67
+ padding-bottom: 0;
68
+ padding-left: 16px;
69
+ margin-bottom: 0;
70
+ margin-left: 12px;
71
+ display: block;
72
+ }
73
+ </style>
@@ -9,7 +9,7 @@ const props = defineProps<{
9
9
  defineEmits<{ click: [] }>()
10
10
 
11
11
  const preview = computed(() => {
12
- const max = props.vertical ? 2 : 2
12
+ const max = 2
13
13
  return props.poem.verses.slice(0, max).map(v => v.text).join('\n')
14
14
  })
15
15
  </script>
@@ -33,7 +33,7 @@ const preview = computed(() => {
33
33
  border: 1px solid var(--border-light);
34
34
  border-radius: 8px;
35
35
  cursor: pointer;
36
- transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
36
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
37
37
  overflow: hidden;
38
38
  }
39
39
  .pc-accent {
@@ -41,12 +41,12 @@ const preview = computed(() => {
41
41
  top: 0; left: 0;
42
42
  width: 3px; height: 0;
43
43
  background: var(--vermillion);
44
- transition: height 0.35s ease;
44
+ transition: height 0.3s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
45
45
  }
46
46
  .pc-root:hover {
47
- transform: translateY(-4px);
48
- box-shadow: 0 16px 48px rgba(var(--shadow-rgb), 0.1);
49
- border-color: var(--gold-light);
47
+ transform: translateY(-2px);
48
+ box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.08);
49
+ border-color: var(--gold);
50
50
  }
51
51
  .pc-root:hover .pc-accent { height: 100%; }
52
52
  .pc-body { padding: 24px; }
@@ -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>
@@ -1,10 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { ref } from 'vue'
3
- import { useReadingMode, THEMES, THEME_LABELS } from '../composables/useReadingMode'
4
- import type { LayoutMode } from '../composables/useReadingMode'
3
+ import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables/useReadingMode'
4
+ import type { LayoutMode, FontSize } from '../composables/useReadingMode'
5
5
  import { useI18n, LOCALE_LABELS, type Locale } from '../composables/useI18n'
6
6
 
7
- const { theme, layout, setTheme, setLayout } = useReadingMode()
7
+ const { theme, layout, mainFontSize, bodyFontSize, setTheme, setLayout, setMainFontSize, setBodyFontSize } = useReadingMode()
8
8
  const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
9
9
  const open = ref(false)
10
10
 
@@ -46,6 +46,22 @@ function close() { open.value = false }
46
46
  >{{ THEME_LABELS[t] }}</button>
47
47
  </div>
48
48
  </div>
49
+ <div class="rt-group">
50
+ <div class="rt-label">{{ t('settings.mainFontSize') }}</div>
51
+ <div class="rt-size-row">
52
+ <button class="rt-size-btn" @click="setMainFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(mainFontSize) - 1)] as FontSize)">−</button>
53
+ <span class="rt-size-val">{{ mainFontSize }}</span>
54
+ <button class="rt-size-btn" @click="setMainFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(mainFontSize) + 1)] as FontSize)">+</button>
55
+ </div>
56
+ </div>
57
+ <div class="rt-group">
58
+ <div class="rt-label">{{ t('settings.bodyFontSize') }}</div>
59
+ <div class="rt-size-row">
60
+ <button class="rt-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(bodyFontSize) - 1)] as FontSize)">−</button>
61
+ <span class="rt-size-val">{{ bodyFontSize }}</span>
62
+ <button class="rt-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(bodyFontSize) + 1)] as FontSize)">+</button>
63
+ </div>
64
+ </div>
49
65
  <div class="rt-group">
50
66
  <div class="rt-label">{{ t('settings.language') }}</div>
51
67
  <div class="rt-options">
@@ -58,6 +74,11 @@ function close() { open.value = false }
58
74
  >{{ localeLabels[loc] }}</button>
59
75
  </div>
60
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>
61
82
  </div>
62
83
  <div v-if="open" class="rt-backdrop" @click="close" />
63
84
  </div>
@@ -79,13 +100,14 @@ function close() { open.value = false }
79
100
  font-size: 16px;
80
101
  cursor: pointer;
81
102
  box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.12);
82
- transition: all 0.2s;
103
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
83
104
  display: flex; align-items: center; justify-content: center;
84
105
  }
85
106
  .rt-fab:hover {
86
107
  background: var(--ink);
87
108
  color: var(--paper);
88
109
  border-color: var(--ink);
110
+ transform: scale(1.05);
89
111
  }
90
112
  .rt-icon {
91
113
  font-family: var(--sans);
@@ -102,7 +124,7 @@ function close() { open.value = false }
102
124
  border-radius: 8px;
103
125
  box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.16);
104
126
  padding: 16px;
105
- animation: slideUp 0.2s ease;
127
+ animation: slideUp 0.25s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
106
128
  }
107
129
  @keyframes slideUp {
108
130
  from { opacity: 0; transform: translateY(8px); }
@@ -138,8 +160,62 @@ function close() { open.value = false }
138
160
  color: var(--paper);
139
161
  border-color: var(--ink);
140
162
  }
163
+ .rt-size-row {
164
+ display: flex; align-items: center; gap: 6px; justify-content: center;
165
+ }
166
+ .rt-size-btn {
167
+ width: 28px; height: 28px;
168
+ border: 1px solid var(--border);
169
+ border-radius: 4px;
170
+ background: none;
171
+ font-family: var(--sans);
172
+ font-size: 14px;
173
+ color: var(--ink-mid);
174
+ cursor: pointer;
175
+ display: flex; align-items: center; justify-content: center;
176
+ transition: all 0.15s;
177
+ }
178
+ .rt-size-btn:hover { border-color: var(--ink); color: var(--ink); }
179
+ .rt-size-val {
180
+ font-family: var(--sans);
181
+ font-size: 13px;
182
+ color: var(--ink);
183
+ min-width: 32px;
184
+ text-align: center;
185
+ }
141
186
  .rt-backdrop {
142
187
  position: fixed; inset: 0;
143
188
  z-index: -1;
144
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
+ }
145
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; }
@@ -17,6 +17,10 @@ export async function createApp() {
17
17
  if (!import.meta.env.SSR) {
18
18
  createApp().then(({ app, router }) => {
19
19
  app.mount('#app')
20
- document.getElementById('app-loading')?.remove()
20
+ const loader = document.getElementById('app-loading')
21
+ if (loader) {
22
+ loader.classList.add('fade-out')
23
+ loader.addEventListener('transitionend', () => loader.remove())
24
+ }
21
25
  })
22
26
  }
@@ -89,6 +89,7 @@
89
89
  --serif: 'Noto Serif TC', '宋體-繁', serif;
90
90
  --sans: 'Noto Sans TC', 'PingFang TC', sans-serif;
91
91
  --nav-width: 56px;
92
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
92
93
  }
93
94
  *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
94
95
  html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
@@ -103,6 +104,7 @@ body {
103
104
  ::-webkit-scrollbar { width: 6px; height: 6px; }
104
105
  ::-webkit-scrollbar-track { background: transparent; }
105
106
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
107
+ ::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
106
108
 
107
109
  a { color: inherit; text-decoration: none; }
108
110
 
@@ -113,21 +115,56 @@ a { color: inherit; text-decoration: none; }
113
115
  display: flex; flex-direction: column;
114
116
  align-items: center; justify-content: center;
115
117
  z-index: 9999;
118
+ transition: opacity 0.4s ease;
119
+ }
120
+ #app-loading.fade-out {
121
+ opacity: 0;
122
+ pointer-events: none;
123
+ }
124
+ #app-loading .seal {
125
+ width: 72px; height: 72px;
126
+ border: 2px solid var(--vermillion);
127
+ border-radius: 4px;
128
+ display: flex; align-items: center; justify-content: center;
129
+ animation: sealReveal 0.8s var(--ease-out-expo) forwards;
130
+ opacity: 0;
116
131
  }
117
132
  #app-loading .char {
118
133
  font-family: var(--serif);
119
- font-size: 64px; font-weight: 900;
120
- color: var(--ink);
121
- animation: pulse 1.2s ease-in-out infinite;
134
+ font-size: 36px; font-weight: 900;
135
+ color: var(--vermillion);
136
+ line-height: 1;
137
+ }
138
+ #app-loading .line {
139
+ width: 1px; height: 40px;
140
+ background: linear-gradient(180deg, var(--vermillion), transparent);
141
+ margin-top: 24px;
142
+ animation: lineGrow 0.6s 0.3s var(--ease-out-expo) forwards;
143
+ transform: scaleY(0);
144
+ transform-origin: top;
122
145
  }
123
146
  #app-loading .text {
124
147
  font-family: var(--sans);
125
- font-size: 13px; color: var(--ink-faint);
126
- letter-spacing: 4px; margin-top: 16px;
148
+ font-size: 11px; color: var(--ink-faint);
149
+ letter-spacing: 6px; margin-top: 16px;
150
+ animation: fadeIn 0.5s 0.5s ease forwards;
151
+ opacity: 0;
127
152
  }
128
- @keyframes pulse {
129
- 0%, 100% { opacity: 0.3; }
130
- 50% { opacity: 1; }
153
+ @keyframes sealReveal {
154
+ from { opacity: 0; transform: scale(0.8); }
155
+ to { opacity: 1; transform: scale(1); }
156
+ }
157
+ @keyframes lineGrow {
158
+ to { transform: scaleY(1); }
159
+ }
160
+ @keyframes fadeIn {
161
+ to { opacity: 1; }
162
+ }
163
+
164
+ /* ===== CONTENT ENTRANCE ANIMATION ===== */
165
+ @keyframes enterUp {
166
+ from { opacity: 0; transform: translateY(16px); }
167
+ to { opacity: 1; transform: translateY(0); }
131
168
  }
132
169
 
133
170
  /* ===== RESPONSIVE ===== */
@@ -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 {
@@ -123,6 +124,16 @@ export interface ProseSection {
123
124
  order: number
124
125
  }
125
126
 
127
+ export interface Part {
128
+ num: number
129
+ group?: string
130
+ title?: string
131
+ source?: PieceSource
132
+ verses: VerseLine[]
133
+ annotations: Annotation[]
134
+ annotationText?: string
135
+ }
136
+
126
137
  export interface Piece {
127
138
  bookId: string
128
139
  num: number
@@ -139,6 +150,7 @@ export interface Piece {
139
150
  annotationLayers?: AnnotationLayer[]
140
151
  source?: PieceSource
141
152
  contributors?: PieceContributor[]
153
+ parts?: Part[]
142
154
  }
143
155
 
144
156
  // Backward compatibility alias
@@ -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; }
@@ -80,6 +80,9 @@ function openBook(bookId: string) {
80
80
  <div v-if="isVertical" class="v-root">
81
81
  <SideNav @home="router.push('/')" @back="router.push('/')" />
82
82
  <div ref="vPageRef" class="v-page">
83
+ <div class="v-about-col">
84
+ <router-link to="/about" class="v-about-link">關 於</router-link>
85
+ </div>
83
86
  <section class="v-hero">
84
87
  <h1 class="v-title">古 典 詩 文 圖 書 館</h1>
85
88
  <p class="v-subtitle">Classical Chinese Text Library</p>
@@ -122,9 +125,10 @@ function openBook(bookId: string) {
122
125
  <h2 class="lib-group-title">{{ group.category }}</h2>
123
126
  <div class="lib-grid">
124
127
  <div
125
- v-for="book in group.books"
128
+ v-for="(book, bi) in group.books"
126
129
  :key="book.id"
127
130
  class="lib-card"
131
+ :style="{ animationDelay: bi * 0.06 + 's' }"
128
132
  @click="openBook(book.id)"
129
133
  >
130
134
  <div class="lib-card-accent"></div>
@@ -160,6 +164,7 @@ function openBook(bookId: string) {
160
164
  background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
161
165
  scrollbar-width: thin;
162
166
  scrollbar-color: var(--gold) transparent;
167
+ scroll-snap-type: x proximity;
163
168
  }
164
169
  .v-page::-webkit-scrollbar { height: 4px; }
165
170
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -175,6 +180,31 @@ function openBook(bookId: string) {
175
180
  justify-content: center;
176
181
  padding: 40px 20px;
177
182
  }
183
+ .v-about-col {
184
+ writing-mode: vertical-rl;
185
+ text-orientation: mixed;
186
+ flex-shrink: 0;
187
+ height: 100vh;
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ padding: 0 12px;
192
+ border-right: 1px solid var(--border-light);
193
+ }
194
+ .v-about-link {
195
+ font-size: 14px;
196
+ color: var(--ink-faint);
197
+ letter-spacing: 6px;
198
+ font-family: var(--sans);
199
+ padding: 12px 8px;
200
+ border: 1px solid var(--border-light);
201
+ border-radius: 2px;
202
+ transition: all 0.2s;
203
+ }
204
+ .v-about-link:hover {
205
+ color: var(--ink);
206
+ border-color: var(--ink);
207
+ }
178
208
  .v-title {
179
209
  font-size: 48px; font-weight: 900;
180
210
  letter-spacing: 16px; color: var(--ink);
@@ -363,9 +393,14 @@ function openBook(bookId: string) {
363
393
  border: 1px solid var(--border-light);
364
394
  border-radius: 8px;
365
395
  cursor: pointer;
366
- transition: all 0.3s ease;
396
+ transition: all 0.3s var(--ease-out-expo, ease);
367
397
  position: relative;
368
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); }
369
404
  }
370
405
  .lib-card:hover { border-color: var(--gold); box-shadow: 0 4px 20px rgba(var(--shadow-rgb), 0.1); }
371
406
  .lib-card-accent {
@@ -13,7 +13,9 @@ import SectionBlock from '../components/SectionBlock.vue'
13
13
  import AnnotationTooltip from '../components/AnnotationTooltip.vue'
14
14
  import AnnotationControlBar from '../components/AnnotationControlBar.vue'
15
15
  import SideNav from '../components/SideNav.vue'
16
- import type { Piece, Annotation, AnnotationLayer } from '../types'
16
+ import PartGroup from '../components/PartGroup.vue'
17
+ import ReadingProgress from '../components/ReadingProgress.vue'
18
+ import type { Piece, Annotation, AnnotationLayer, Part } from '../types'
17
19
 
18
20
  const props = defineProps<{ bookId: string; num: string | number }>()
19
21
  const router = useRouter()
@@ -58,6 +60,17 @@ useTitle(pageTitle.value)
58
60
 
59
61
  const isVertical = computed(() => layout.value === 'vertical')
60
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
+
61
74
  const annotationLayers = computed<AnnotationLayer[]>(() => piece.value?.annotationLayers || [])
62
75
  const hasLayers = computed(() => annotationLayers.value.length > 1)
63
76
  const activeLayerIds = ref<string[]>([])
@@ -128,6 +141,29 @@ function getHeadword(ann: Annotation): string {
128
141
  // Initialize layers when piece loads
129
142
  watch(() => piece.value, () => initLayers(), { immediate: true })
130
143
 
144
+ // ─── Multi-part ───────────────────────────────────────────────
145
+ const isMultiPart = computed(() => (piece.value?.parts?.length ?? 0) > 0)
146
+
147
+ const partGroups = computed<{ label: string; parts: Part[] }[]>(() => {
148
+ if (!piece.value?.parts?.length) return []
149
+ const groupMap = new Map<string, Part[]>()
150
+ for (const part of piece.value.parts) {
151
+ const key = part.group || ''
152
+ if (!groupMap.has(key)) groupMap.set(key, [])
153
+ groupMap.get(key)!.push(part)
154
+ }
155
+ return [...groupMap.entries()].map(([label, parts]) => ({ label, parts }))
156
+ })
157
+
158
+ const allPartAnnotations = computed<Annotation[]>(() => {
159
+ if (!piece.value?.parts) return []
160
+ return piece.value.parts.flatMap(p => p.annotations)
161
+ })
162
+
163
+ const totalPartAnnotationCount = computed(() => {
164
+ return piece.value?.parts?.reduce((sum, p) => sum + p.annotations.length, 0) ?? 0
165
+ })
166
+
131
167
  const SECTION_META: Record<string, { label: string; special: boolean }> = {
132
168
  background: { label: '背景資料', special: false },
133
169
  analysis: { label: '賞析重點', special: false },
@@ -218,6 +254,7 @@ function tcy(n: number): string {
218
254
  @back="goBack"
219
255
  @home="goHome"
220
256
  />
257
+ <ReadingProgress vertical :scroll-container="vPageRef" />
221
258
  <div ref="vPageRef" class="v-page">
222
259
  <section ref="vTitleRef" class="v-title-col">
223
260
  <h1 class="v-poem-title">{{ piece.title }}</h1>
@@ -232,12 +269,31 @@ function tcy(n: number): string {
232
269
  ← {{ meta?.title }}
233
270
  </div>
234
271
  <div class="v-poem-meta">
235
- <span class="v-meta-item" v-html="tcy(piece.verses.length) + ' 段'" />
236
- <span class="v-meta-item" v-html="piece.annotations.length > 0 ? tcy(piece.annotations.length) + ' ' : '無注'" />
272
+ <template v-if="isMultiPart">
273
+ <span class="v-meta-item" v-html="tcy(piece.parts!.length) + ' '" />
274
+ <span class="v-meta-item" v-html="totalPartAnnotationCount > 0 ? tcy(totalPartAnnotationCount) + ' 注' : '無注'" />
275
+ </template>
276
+ <template v-else>
277
+ <span class="v-meta-item" v-html="tcy(piece.verses.length) + ' 段'" />
278
+ <span class="v-meta-item" v-html="totalAnnotationCount > 0 ? tcy(totalAnnotationCount) + ' 注' : '無注'" />
279
+ </template>
237
280
  </div>
238
281
  </section>
239
282
 
240
- <section class="v-poem-col">
283
+ <section v-if="isMultiPart" class="v-poem-col v-multipart">
284
+ <PartGroup
285
+ v-for="group in partGroups"
286
+ :key="group.label"
287
+ :label="group.label"
288
+ :parts="group.parts"
289
+ :vertical="true"
290
+ @annotation-hover="interaction.onHover"
291
+ @annotation-leave="interaction.onLeave"
292
+ @annotation-tap="interaction.onTap"
293
+ />
294
+ </section>
295
+
296
+ <section v-else class="v-poem-col">
241
297
  <VerticalScroll
242
298
  :title="''"
243
299
  :author="''"
@@ -252,7 +308,7 @@ function tcy(n: number): string {
252
308
  </section>
253
309
 
254
310
  <SectionBlock
255
- v-if="annotationsVisible && piece.sections.annotations"
311
+ v-if="!isMultiPart && annotationsVisible && piece.sections.annotations"
256
312
  num=""
257
313
  label="注釋"
258
314
  :special="false"
@@ -349,6 +405,7 @@ function tcy(n: number): string {
349
405
 
350
406
  <!-- ═══════ 橫排模式 ═══════ -->
351
407
  <div v-else class="h-root">
408
+ <ReadingProgress />
352
409
  <div class="h-page">
353
410
  <nav class="h-nav">
354
411
  <div class="h-nav-inner">
@@ -370,14 +427,32 @@ function tcy(n: number): string {
370
427
  <span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
371
428
  </div>
372
429
  <div class="h-controls">
373
- <span class="h-tag">{{ piece.verses.length }} 段</span>
374
- <span class="h-tag">{{ piece.annotations.length > 0 ? piece.annotations.length + ' 注' : '無注' }}</span>
430
+ <template v-if="isMultiPart">
431
+ <span class="h-tag">{{ piece.parts!.length }} 段</span>
432
+ <span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' 注' : '無注' }}</span>
433
+ </template>
434
+ <template v-else>
435
+ <span class="h-tag">{{ piece.verses.length }} 段</span>
436
+ <span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' 注' : '無注' }}</span>
437
+ </template>
375
438
  </div>
376
439
  </div>
377
440
  </nav>
378
441
 
379
442
  <div class="h-content">
380
- <div class="h-poem-block">
443
+ <div v-if="isMultiPart" class="h-multipart">
444
+ <PartGroup
445
+ v-for="group in partGroups"
446
+ :key="group.label"
447
+ :label="group.label"
448
+ :parts="group.parts"
449
+ @annotation-hover="interaction.onHover"
450
+ @annotation-leave="interaction.onLeave"
451
+ @annotation-tap="interaction.onTap"
452
+ />
453
+ </div>
454
+
455
+ <div v-else class="h-poem-block">
381
456
  <HorizontalDisplay
382
457
  :title="piece.title"
383
458
  :author="piece.author"
@@ -476,8 +551,8 @@ function tcy(n: number): string {
476
551
  </div>
477
552
  </div>
478
553
 
479
- <div v-else style="text-align:center;padding-top:120px">
480
- <p style="font-size:18px;color:var(--ink-faint)">載入中…</p>
554
+ <div v-else class="loading">
555
+ <div class="loading-seal">詩</div>
481
556
  </div>
482
557
  </template>
483
558
 
@@ -495,6 +570,7 @@ function tcy(n: number): string {
495
570
  background: var(--paper);
496
571
  scrollbar-width: thin;
497
572
  scrollbar-color: var(--gold) transparent;
573
+ scroll-snap-type: x proximity;
498
574
  }
499
575
  .v-page::-webkit-scrollbar { height: 4px; }
500
576
  .v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
@@ -510,6 +586,7 @@ function tcy(n: number): string {
510
586
  gap: 16px;
511
587
  padding: 40px 24px;
512
588
  border-right: 1px solid var(--border);
589
+ scroll-snap-align: start;
513
590
  }
514
591
  .v-poem-title {
515
592
  font-size: 40px; font-weight: 900;
@@ -551,6 +628,22 @@ function tcy(n: number): string {
551
628
  padding: 24px;
552
629
  }
553
630
 
631
+ .v-multipart {
632
+ display: flex;
633
+ flex-direction: row-reverse;
634
+ align-items: flex-start;
635
+ gap: 0;
636
+ max-height: calc(100vh - 120px);
637
+ overflow-x: auto;
638
+ overflow-y: hidden;
639
+ padding: 24px 16px;
640
+ scrollbar-width: thin;
641
+ scrollbar-color: var(--gold) var(--paper);
642
+ }
643
+
644
+ .v-multipart::-webkit-scrollbar { height: 4px; }
645
+ .v-multipart::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
646
+
554
647
  .v-section {
555
648
  flex-shrink: 0;
556
649
  }
@@ -564,7 +657,7 @@ function tcy(n: number): string {
564
657
 
565
658
  .v-source-link {
566
659
  font-size: 12px;
567
- color: var(--c-brand);
660
+ color: var(--vermillion);
568
661
  cursor: pointer;
569
662
  margin-top: 4px;
570
663
  opacity: 0.8;
@@ -582,6 +675,7 @@ function tcy(n: number): string {
582
675
  justify-content: center;
583
676
  padding: 24px 12px;
584
677
  gap: 32px;
678
+ scroll-snap-align: start;
585
679
  }
586
680
  .v-nav-spacer { flex: 1; }
587
681
  .v-nav-btn {
@@ -663,6 +757,16 @@ function tcy(n: number): string {
663
757
  .h-poem-block {
664
758
  margin-bottom: 60px; display: flex; justify-content: center;
665
759
  }
760
+
761
+ .h-multipart {
762
+ max-width: min(680px, calc(100vw - 80px));
763
+ margin: 0 auto 60px;
764
+ background: var(--surface);
765
+ border: 1px solid var(--border);
766
+ border-radius: 8px;
767
+ padding: 32px 40px;
768
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
769
+ }
666
770
  .h-sections {
667
771
  max-width: min(680px, calc(100vw - 80px));
668
772
  margin: 0 auto; padding-bottom: 80px;
@@ -678,7 +782,7 @@ function tcy(n: number): string {
678
782
  }
679
783
 
680
784
  .h-source-link {
681
- color: var(--c-brand);
785
+ color: var(--vermillion);
682
786
  cursor: pointer;
683
787
  font-size: 13px;
684
788
  }
@@ -703,7 +807,9 @@ function tcy(n: number): string {
703
807
 
704
808
  .h-overlay {
705
809
  position: fixed; inset: 0;
706
- 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);
707
813
  z-index: 200;
708
814
  display: flex; justify-content: flex-end;
709
815
  animation: fadeIn 0.2s ease;
@@ -750,7 +856,9 @@ function tcy(n: number): string {
750
856
 
751
857
  .v-overlay {
752
858
  position: fixed; inset: 0;
753
- 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);
754
862
  z-index: 200;
755
863
  display: flex; justify-content: flex-start;
756
864
  animation: fadeIn 0.2s ease;
@@ -807,6 +915,25 @@ function tcy(n: number): string {
807
915
  margin-left: 12px;
808
916
  }
809
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
+
810
937
  @media (max-width: 768px) {
811
938
  .h-content { padding: 30px 20px; }
812
939
  }
@@ -1,66 +0,0 @@
1
- <script setup lang="ts">
2
- import type { AnnotationLayer } from '../types'
3
-
4
- const props = defineProps<{
5
- layers: AnnotationLayer[]
6
- activeIds: string[]
7
- }>()
8
-
9
- const emit = defineEmits<{
10
- 'update:activeIds': [ids: string[]]
11
- }>()
12
-
13
- function toggle(id: string) {
14
- const current = props.activeIds
15
- if (current.includes(id)) {
16
- emit('update:activeIds', current.filter(x => x !== id))
17
- } else {
18
- emit('update:activeIds', [...current, id])
19
- }
20
- }
21
- </script>
22
-
23
- <template>
24
- <div v-if="layers.length > 1" class="layer-selector">
25
- <button
26
- v-for="layer in layers"
27
- :key="layer.id"
28
- :class="['layer-btn', { active: activeIds.includes(layer.id) }]"
29
- :title="layer.label"
30
- @click="toggle(layer.id)"
31
- >
32
- {{ layer.shortLabel }}
33
- </button>
34
- </div>
35
- </template>
36
-
37
- <style scoped>
38
- .layer-selector {
39
- display: flex;
40
- gap: 6px;
41
- flex-wrap: wrap;
42
- }
43
-
44
- .layer-btn {
45
- border: 1px solid var(--border);
46
- border-radius: 4px;
47
- padding: 4px 12px;
48
- font-size: 13px;
49
- background: var(--surface);
50
- color: var(--ink-mid);
51
- cursor: pointer;
52
- transition: all 0.15s;
53
- font-family: var(--sans);
54
- letter-spacing: 1px;
55
- }
56
-
57
- .layer-btn:hover {
58
- border-color: var(--gold);
59
- }
60
-
61
- .layer-btn.active {
62
- background: var(--ink);
63
- color: var(--paper);
64
- border-color: var(--ink);
65
- }
66
- </style>
@@ -1,25 +0,0 @@
1
- import { computed, ref, type Ref } from 'vue'
2
- import { useReadingMode } from './useReadingMode'
3
- import { useHorizontalScroll } from './useHorizontalScroll'
4
-
5
- export interface PageLayoutConfig {
6
- mode: 'vertical' | 'horizontal'
7
- navSide: 'top' | 'right'
8
- contentDirection: 'ltr' | 'rtl'
9
- isVertical: boolean
10
- }
11
-
12
- export function usePageLayout() {
13
- const { layout } = useReadingMode()
14
- const scrollRef = ref<HTMLElement | null>(null)
15
- const scroll = useHorizontalScroll(scrollRef)
16
-
17
- const config = computed<PageLayoutConfig>(() => ({
18
- mode: layout.value,
19
- navSide: layout.value === 'vertical' ? 'right' : 'top',
20
- contentDirection: layout.value === 'vertical' ? 'rtl' : 'ltr',
21
- isVertical: layout.value === 'vertical',
22
- }))
23
-
24
- return { config, scrollRef, ...scroll }
25
- }