@hanology/cham-browser 0.3.3 → 0.3.4

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.3",
3
+ "version": "0.3.4",
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,160 @@
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
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ annotationHover: [event: MouseEvent, annotations: Annotation[]]
15
+ annotationLeave: []
16
+ annotationTap: [event: MouseEvent, annotations: Annotation[]]
17
+ }>()
18
+
19
+ function verseHtml(index: number): string {
20
+ const useRuby = props.vertical
21
+ let offset = 0
22
+ for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
23
+ const spans = buildVerseAnnotations(props.annotations, index)
24
+ return renderAnnotatedText(props.verses[index].text, spans, useRuby, offset)
25
+ }
26
+
27
+ function onHover(event: MouseEvent) {
28
+ const matched = resolveHoveredAnnotations(event, props.annotations)
29
+ if (matched) emit('annotationHover', event, matched)
30
+ }
31
+
32
+ function onLeave() { emit('annotationLeave') }
33
+
34
+ function onTap(event: MouseEvent) {
35
+ const matched = resolveHoveredAnnotations(event, props.annotations)
36
+ if (matched) emit('annotationTap', event, matched)
37
+ }
38
+
39
+ const sourceLabel = (() => {
40
+ const r = props.source?.range as Record<string, string> | undefined
41
+ return r?.chapter || ''
42
+ })()
43
+ </script>
44
+
45
+ <template>
46
+ <div class="part-block" :class="{ 'part-block--vertical': vertical }">
47
+ <div v-if="sourceLabel" class="part-source">
48
+ {{ sourceLabel }}
49
+ </div>
50
+ <div class="part-text" @mouseover="onHover" @mouseleave="onLeave" @click="onTap">
51
+ <span
52
+ v-for="(_, i) in verses"
53
+ :key="i"
54
+ :class="vertical ? 'part-line-v' : 'part-line-h'"
55
+ v-html="verseHtml(i)"
56
+ />
57
+ </div>
58
+ </div>
59
+ </template>
60
+
61
+ <style scoped>
62
+ .part-block {
63
+ padding: 20px 0;
64
+ border-bottom: 1px solid var(--border-light);
65
+ }
66
+
67
+ .part-block:last-child {
68
+ border-bottom: none;
69
+ }
70
+
71
+ .part-block--vertical {
72
+ writing-mode: vertical-rl;
73
+ text-orientation: mixed;
74
+ }
75
+
76
+ .part-source {
77
+ font-family: var(--sans);
78
+ font-size: 12px;
79
+ letter-spacing: 1px;
80
+ color: var(--ink-faint);
81
+ background: var(--surface);
82
+ display: inline-block;
83
+ padding: 3px 10px;
84
+ border-radius: 3px;
85
+ margin-bottom: 12px;
86
+ border: 1px solid var(--border-light);
87
+ }
88
+
89
+ .part-text {
90
+ line-height: 1;
91
+ }
92
+
93
+ .part-line-h {
94
+ font-size: var(--main-font-size, 22px);
95
+ line-height: 2.4;
96
+ letter-spacing: 3px;
97
+ color: var(--ink);
98
+ display: block;
99
+ }
100
+
101
+ .part-line-v {
102
+ font-size: var(--main-font-size, 22px);
103
+ line-height: 2.4;
104
+ letter-spacing: 6px;
105
+ color: var(--ink);
106
+ display: block;
107
+ }
108
+
109
+ :deep(.ann-target) {
110
+ border-bottom: 2px solid var(--vermillion);
111
+ cursor: help;
112
+ transition: background 0.15s;
113
+ }
114
+
115
+ :deep(.ann-target:hover) {
116
+ background: rgba(194, 58, 43, 0.08);
117
+ }
118
+
119
+ :deep(.ann-num) {
120
+ font-size: 10px;
121
+ color: var(--vermillion);
122
+ font-family: var(--sans);
123
+ font-weight: 600;
124
+ vertical-align: super;
125
+ margin-right: 1px;
126
+ letter-spacing: 0;
127
+ }
128
+
129
+ :deep(.ann-target.pronunciation) {
130
+ border-bottom-color: var(--jade);
131
+ }
132
+
133
+ :deep(.ann-target.pronunciation:hover) {
134
+ background: rgba(58, 107, 94, 0.08);
135
+ }
136
+
137
+ /* Vertical mode overrides */
138
+ .part-block--vertical :deep(.ann-target) {
139
+ border-bottom: none;
140
+ border-left: 2px solid var(--vermillion);
141
+ padding-left: 2px;
142
+ }
143
+
144
+ .part-block--vertical :deep(.ann-target.pronunciation) {
145
+ border-left-color: var(--jade);
146
+ }
147
+
148
+ .part-block--vertical :deep(.ann-num) {
149
+ font-size: 0.45em;
150
+ text-combine-upright: all;
151
+ text-align: end;
152
+ letter-spacing: 0;
153
+ vertical-align: baseline;
154
+ }
155
+
156
+ .part-block--vertical .part-source {
157
+ margin-bottom: 0;
158
+ margin-left: 8px;
159
+ }
160
+ </style>
@@ -0,0 +1,72 @@
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
+ :vertical="vertical"
28
+ :source="part.source"
29
+ @annotation-hover="(e, a) => emit('annotationHover', e, a)"
30
+ @annotation-leave="emit('annotationLeave')"
31
+ @annotation-tap="(e, a) => emit('annotationTap', e, a)"
32
+ />
33
+ </div>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .part-group {
38
+ margin-bottom: 40px;
39
+ }
40
+
41
+ .part-group:last-child {
42
+ margin-bottom: 0;
43
+ }
44
+
45
+ .part-group-label {
46
+ font-size: 28px;
47
+ font-weight: 900;
48
+ letter-spacing: 6px;
49
+ color: var(--ink);
50
+ padding-bottom: 12px;
51
+ margin-bottom: 8px;
52
+ border-bottom: 3px solid var(--vermillion);
53
+ display: inline-block;
54
+ }
55
+
56
+ .part-group--vertical {
57
+ writing-mode: vertical-rl;
58
+ text-orientation: mixed;
59
+ }
60
+
61
+ .part-group--vertical .part-group-label {
62
+ font-size: 28px;
63
+ letter-spacing: 10px;
64
+ border-bottom: none;
65
+ border-left: 3px solid var(--vermillion);
66
+ padding-bottom: 0;
67
+ padding-left: 16px;
68
+ margin-bottom: 0;
69
+ margin-left: 12px;
70
+ display: block;
71
+ }
72
+ </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; }
@@ -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">
@@ -79,13 +95,14 @@ function close() { open.value = false }
79
95
  font-size: 16px;
80
96
  cursor: pointer;
81
97
  box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.12);
82
- transition: all 0.2s;
98
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
83
99
  display: flex; align-items: center; justify-content: center;
84
100
  }
85
101
  .rt-fab:hover {
86
102
  background: var(--ink);
87
103
  color: var(--paper);
88
104
  border-color: var(--ink);
105
+ transform: scale(1.05);
89
106
  }
90
107
  .rt-icon {
91
108
  font-family: var(--sans);
@@ -102,7 +119,7 @@ function close() { open.value = false }
102
119
  border-radius: 8px;
103
120
  box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.16);
104
121
  padding: 16px;
105
- animation: slideUp 0.2s ease;
122
+ animation: slideUp 0.25s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
106
123
  }
107
124
  @keyframes slideUp {
108
125
  from { opacity: 0; transform: translateY(8px); }
@@ -138,6 +155,29 @@ function close() { open.value = false }
138
155
  color: var(--paper);
139
156
  border-color: var(--ink);
140
157
  }
158
+ .rt-size-row {
159
+ display: flex; align-items: center; gap: 6px; justify-content: center;
160
+ }
161
+ .rt-size-btn {
162
+ width: 28px; height: 28px;
163
+ border: 1px solid var(--border);
164
+ border-radius: 4px;
165
+ background: none;
166
+ font-family: var(--sans);
167
+ font-size: 14px;
168
+ color: var(--ink-mid);
169
+ cursor: pointer;
170
+ display: flex; align-items: center; justify-content: center;
171
+ transition: all 0.15s;
172
+ }
173
+ .rt-size-btn:hover { border-color: var(--ink); color: var(--ink); }
174
+ .rt-size-val {
175
+ font-family: var(--sans);
176
+ font-size: 13px;
177
+ color: var(--ink);
178
+ min-width: 32px;
179
+ text-align: center;
180
+ }
141
181
  .rt-backdrop {
142
182
  position: fixed; inset: 0;
143
183
  z-index: -1;
@@ -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 ===== */
@@ -123,6 +123,15 @@ export interface ProseSection {
123
123
  order: number
124
124
  }
125
125
 
126
+ export interface Part {
127
+ num: number
128
+ group?: string
129
+ title?: string
130
+ source?: PieceSource
131
+ verses: VerseLine[]
132
+ annotations: Annotation[]
133
+ }
134
+
126
135
  export interface Piece {
127
136
  bookId: string
128
137
  num: number
@@ -139,6 +148,7 @@ export interface Piece {
139
148
  annotationLayers?: AnnotationLayer[]
140
149
  source?: PieceSource
141
150
  contributors?: PieceContributor[]
151
+ parts?: Part[]
142
152
  }
143
153
 
144
154
  // Backward compatibility alias
@@ -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>
@@ -175,6 +178,31 @@ function openBook(bookId: string) {
175
178
  justify-content: center;
176
179
  padding: 40px 20px;
177
180
  }
181
+ .v-about-col {
182
+ writing-mode: vertical-rl;
183
+ text-orientation: mixed;
184
+ flex-shrink: 0;
185
+ height: 100vh;
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ padding: 0 12px;
190
+ border-right: 1px solid var(--border-light);
191
+ }
192
+ .v-about-link {
193
+ font-size: 14px;
194
+ color: var(--ink-faint);
195
+ letter-spacing: 6px;
196
+ font-family: var(--sans);
197
+ padding: 12px 8px;
198
+ border: 1px solid var(--border-light);
199
+ border-radius: 2px;
200
+ transition: all 0.2s;
201
+ }
202
+ .v-about-link:hover {
203
+ color: var(--ink);
204
+ border-color: var(--ink);
205
+ }
178
206
  .v-title {
179
207
  font-size: 48px; font-weight: 900;
180
208
  letter-spacing: 16px; color: var(--ink);
@@ -13,7 +13,8 @@ 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 type { Piece, Annotation, AnnotationLayer, Part } from '../types'
17
18
 
18
19
  const props = defineProps<{ bookId: string; num: string | number }>()
19
20
  const router = useRouter()
@@ -128,6 +129,29 @@ function getHeadword(ann: Annotation): string {
128
129
  // Initialize layers when piece loads
129
130
  watch(() => piece.value, () => initLayers(), { immediate: true })
130
131
 
132
+ // ─── Multi-part ───────────────────────────────────────────────
133
+ const isMultiPart = computed(() => (piece.value?.parts?.length ?? 0) > 0)
134
+
135
+ const partGroups = computed<{ label: string; parts: Part[] }[]>(() => {
136
+ if (!piece.value?.parts?.length) return []
137
+ const groupMap = new Map<string, Part[]>()
138
+ for (const part of piece.value.parts) {
139
+ const key = part.group || ''
140
+ if (!groupMap.has(key)) groupMap.set(key, [])
141
+ groupMap.get(key)!.push(part)
142
+ }
143
+ return [...groupMap.entries()].map(([label, parts]) => ({ label, parts }))
144
+ })
145
+
146
+ const allPartAnnotations = computed<Annotation[]>(() => {
147
+ if (!piece.value?.parts) return []
148
+ return piece.value.parts.flatMap(p => p.annotations)
149
+ })
150
+
151
+ const totalPartAnnotationCount = computed(() => {
152
+ return piece.value?.parts?.reduce((sum, p) => sum + p.annotations.length, 0) ?? 0
153
+ })
154
+
131
155
  const SECTION_META: Record<string, { label: string; special: boolean }> = {
132
156
  background: { label: '背景資料', special: false },
133
157
  analysis: { label: '賞析重點', special: false },
@@ -232,12 +256,31 @@ function tcy(n: number): string {
232
256
  ← {{ meta?.title }}
233
257
  </div>
234
258
  <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) + ' ' : '無注'" />
259
+ <template v-if="isMultiPart">
260
+ <span class="v-meta-item" v-html="tcy(piece.parts!.length) + ' '" />
261
+ <span class="v-meta-item" v-html="totalPartAnnotationCount > 0 ? tcy(totalPartAnnotationCount) + ' 注' : '無注'" />
262
+ </template>
263
+ <template v-else>
264
+ <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) + ' 注' : '無注'" />
266
+ </template>
237
267
  </div>
238
268
  </section>
239
269
 
240
- <section class="v-poem-col">
270
+ <section v-if="isMultiPart" class="v-poem-col v-multipart">
271
+ <PartGroup
272
+ v-for="group in partGroups"
273
+ :key="group.label"
274
+ :label="group.label"
275
+ :parts="group.parts"
276
+ :vertical="true"
277
+ @annotation-hover="interaction.onHover"
278
+ @annotation-leave="interaction.onLeave"
279
+ @annotation-tap="interaction.onTap"
280
+ />
281
+ </section>
282
+
283
+ <section v-else class="v-poem-col">
241
284
  <VerticalScroll
242
285
  :title="''"
243
286
  :author="''"
@@ -252,7 +295,7 @@ function tcy(n: number): string {
252
295
  </section>
253
296
 
254
297
  <SectionBlock
255
- v-if="annotationsVisible && piece.sections.annotations"
298
+ v-if="!isMultiPart && annotationsVisible && piece.sections.annotations"
256
299
  num=""
257
300
  label="注釋"
258
301
  :special="false"
@@ -370,14 +413,32 @@ function tcy(n: number): string {
370
413
  <span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
371
414
  </div>
372
415
  <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>
416
+ <template v-if="isMultiPart">
417
+ <span class="h-tag">{{ piece.parts!.length }} 段</span>
418
+ <span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' 注' : '無注' }}</span>
419
+ </template>
420
+ <template v-else>
421
+ <span class="h-tag">{{ piece.verses.length }} 段</span>
422
+ <span class="h-tag">{{ piece.annotations.length > 0 ? piece.annotations.length + ' 注' : '無注' }}</span>
423
+ </template>
375
424
  </div>
376
425
  </div>
377
426
  </nav>
378
427
 
379
428
  <div class="h-content">
380
- <div class="h-poem-block">
429
+ <div v-if="isMultiPart" class="h-multipart">
430
+ <PartGroup
431
+ v-for="group in partGroups"
432
+ :key="group.label"
433
+ :label="group.label"
434
+ :parts="group.parts"
435
+ @annotation-hover="interaction.onHover"
436
+ @annotation-leave="interaction.onLeave"
437
+ @annotation-tap="interaction.onTap"
438
+ />
439
+ </div>
440
+
441
+ <div v-else class="h-poem-block">
381
442
  <HorizontalDisplay
382
443
  :title="piece.title"
383
444
  :author="piece.author"
@@ -551,6 +612,22 @@ function tcy(n: number): string {
551
612
  padding: 24px;
552
613
  }
553
614
 
615
+ .v-multipart {
616
+ display: flex;
617
+ flex-direction: row-reverse;
618
+ align-items: flex-start;
619
+ gap: 0;
620
+ max-height: calc(100vh - 120px);
621
+ overflow-x: auto;
622
+ overflow-y: hidden;
623
+ padding: 24px 16px;
624
+ scrollbar-width: thin;
625
+ scrollbar-color: var(--gold) var(--paper);
626
+ }
627
+
628
+ .v-multipart::-webkit-scrollbar { height: 4px; }
629
+ .v-multipart::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
630
+
554
631
  .v-section {
555
632
  flex-shrink: 0;
556
633
  }
@@ -564,7 +641,7 @@ function tcy(n: number): string {
564
641
 
565
642
  .v-source-link {
566
643
  font-size: 12px;
567
- color: var(--c-brand);
644
+ color: var(--vermillion);
568
645
  cursor: pointer;
569
646
  margin-top: 4px;
570
647
  opacity: 0.8;
@@ -663,6 +740,16 @@ function tcy(n: number): string {
663
740
  .h-poem-block {
664
741
  margin-bottom: 60px; display: flex; justify-content: center;
665
742
  }
743
+
744
+ .h-multipart {
745
+ max-width: min(680px, calc(100vw - 80px));
746
+ margin: 0 auto 60px;
747
+ background: var(--surface);
748
+ border: 1px solid var(--border);
749
+ border-radius: 8px;
750
+ padding: 32px 40px;
751
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
752
+ }
666
753
  .h-sections {
667
754
  max-width: min(680px, calc(100vw - 80px));
668
755
  margin: 0 auto; padding-bottom: 80px;
@@ -678,7 +765,7 @@ function tcy(n: number): string {
678
765
  }
679
766
 
680
767
  .h-source-link {
681
- color: var(--c-brand);
768
+ color: var(--vermillion);
682
769
  cursor: pointer;
683
770
  font-size: 13px;
684
771
  }
@@ -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
- }