@hanology/cham-browser 0.4.15 → 0.4.17

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.4.15",
3
+ "version": "0.4.17",
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",
@@ -0,0 +1,308 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
3
+ import { annotationToPronSegment } from '../utils/annotationParser'
4
+ import { toChineseNumber } from '../utils/chineseNumber'
5
+ import PronunciationGroup from './PronunciationGroup.vue'
6
+ import type { Annotation } from '../types'
7
+
8
+ const props = defineProps<{
9
+ visible: boolean
10
+ annotations: Annotation[]
11
+ headwords: Record<string, string>
12
+ layerLabels?: Record<string, string>
13
+ activeId: string
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ close: []
18
+ select: [ann: Annotation]
19
+ }>()
20
+
21
+ const bodyRef = ref<HTMLElement | null>(null)
22
+
23
+ function getSegment(ann: Annotation) {
24
+ return annotationToPronSegment(ann)
25
+ }
26
+
27
+ function headword(ann: Annotation): string {
28
+ return props.headwords[ann.id] || ''
29
+ }
30
+
31
+ function kindLabel(ann: Annotation): string {
32
+ const map: Record<string, string> = {
33
+ pronunciation: '讀音',
34
+ semantic: '釋義',
35
+ etymology: '詞源',
36
+ note: '備注',
37
+ definition: '釋義',
38
+ commentary: '評註',
39
+ translation: '譯文',
40
+ person: '人名',
41
+ place: '地名',
42
+ event: '事件',
43
+ date: '紀年',
44
+ allusion: '典故',
45
+ }
46
+ return map[ann.kind] || ann.kind
47
+ }
48
+
49
+ function layerLabel(ann: Annotation): string {
50
+ if (!props.layerLabels || !ann.id) return ''
51
+ for (const [prefix, label] of Object.entries(props.layerLabels)) {
52
+ if (ann.id.startsWith(prefix)) return label
53
+ }
54
+ return ''
55
+ }
56
+
57
+ function onKeydown(e: KeyboardEvent) {
58
+ if (e.key === 'Escape' && props.visible) {
59
+ e.preventDefault()
60
+ emit('close')
61
+ }
62
+ }
63
+
64
+ watch(() => props.activeId, async (id) => {
65
+ if (!id || !props.visible) return
66
+ await nextTick()
67
+ const el = bodyRef.value?.querySelector(`[data-ann-id="${id}"]`)
68
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
69
+ })
70
+
71
+ onMounted(() => document.addEventListener('keydown', onKeydown))
72
+ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
73
+ </script>
74
+
75
+ <template>
76
+ <Teleport to="body">
77
+ <Transition name="ann-pane">
78
+ <div v-if="visible && annotations.length" class="ann-pane">
79
+ <div class="ann-pane-header">
80
+ <span class="ann-pane-title">注釋</span>
81
+ <span class="ann-pane-count">{{ annotations.length }}</span>
82
+ <button class="ann-pane-close" @click="emit('close')" aria-label="關閉">
83
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 6L6 18M6 6l12 12"/></svg>
84
+ </button>
85
+ </div>
86
+ <div ref="bodyRef" class="ann-pane-body">
87
+ <div
88
+ v-for="(ann, idx) in annotations"
89
+ :key="ann.id"
90
+ :data-ann-id="ann.id"
91
+ class="ann-pane-entry"
92
+ :class="{ active: activeId === ann.id, [ann.kind]: true }"
93
+ @click="emit('select', ann)"
94
+ >
95
+ <div class="ann-pane-entry-head">
96
+ <span class="ann-pane-idx">{{ toChineseNumber(idx + 1) }}</span>
97
+ <span v-if="headword(ann)" class="ann-pane-word">{{ headword(ann) }}</span>
98
+ <span class="ann-pane-kind" :class="ann.kind">{{ kindLabel(ann) }}</span>
99
+ <span v-if="layerLabel(ann)" class="ann-pane-layer">{{ layerLabel(ann) }}</span>
100
+ </div>
101
+ <div class="ann-pane-entry-body">
102
+ <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
103
+ <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-pane-text">{{ ann.text }}</div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </Transition>
109
+ </Teleport>
110
+ </template>
111
+
112
+ <style scoped>
113
+ .ann-pane {
114
+ position: fixed;
115
+ left: 0;
116
+ top: 0;
117
+ width: 320px;
118
+ height: 100vh;
119
+ background: var(--surface-warm);
120
+ border-right: 1px solid var(--border);
121
+ z-index: 300;
122
+ display: flex;
123
+ flex-direction: column;
124
+ writing-mode: horizontal-tb;
125
+ box-shadow: 4px 0 24px rgba(var(--shadow-rgb), 0.06);
126
+ }
127
+
128
+ .ann-pane-header {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 8px;
132
+ padding: 16px 20px;
133
+ border-bottom: 1px solid var(--border-light);
134
+ background: var(--surface);
135
+ flex-shrink: 0;
136
+ }
137
+
138
+ .ann-pane-title {
139
+ font-family: var(--serif);
140
+ font-size: 18px;
141
+ font-weight: 900;
142
+ letter-spacing: 4px;
143
+ color: var(--ink);
144
+ }
145
+
146
+ .ann-pane-count {
147
+ font-family: var(--sans);
148
+ font-size: 11px;
149
+ font-weight: 600;
150
+ color: var(--ink-faint);
151
+ background: var(--surface-warm);
152
+ border: 1px solid var(--border-light);
153
+ border-radius: 10px;
154
+ padding: 2px 8px;
155
+ }
156
+
157
+ .ann-pane-close {
158
+ margin-left: auto;
159
+ width: 24px;
160
+ height: 24px;
161
+ border: none;
162
+ border-radius: 4px;
163
+ background: transparent;
164
+ color: var(--ink-faint);
165
+ cursor: pointer;
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: center;
169
+ transition: all 0.15s;
170
+ }
171
+
172
+ .ann-pane-close:hover {
173
+ background: var(--ink);
174
+ color: var(--paper);
175
+ }
176
+
177
+ .ann-pane-body {
178
+ flex: 1;
179
+ overflow-y: auto;
180
+ overscroll-behavior: contain;
181
+ padding: 4px 0;
182
+ }
183
+
184
+ .ann-pane-entry {
185
+ padding: 12px 20px;
186
+ border-bottom: 1px solid var(--border-light);
187
+ border-left: 3px solid transparent;
188
+ cursor: pointer;
189
+ transition: all 0.15s;
190
+ }
191
+
192
+ .ann-pane-entry:hover {
193
+ background: var(--surface);
194
+ }
195
+
196
+ .ann-pane-entry.active {
197
+ border-left-color: var(--vermillion);
198
+ background: var(--surface);
199
+ }
200
+
201
+ .ann-pane-entry.active.pronunciation {
202
+ border-left-color: var(--jade);
203
+ }
204
+
205
+ .ann-pane-entry-head {
206
+ display: flex;
207
+ align-items: center;
208
+ gap: 6px;
209
+ margin-bottom: 4px;
210
+ flex-wrap: wrap;
211
+ }
212
+
213
+ .ann-pane-idx {
214
+ font-family: var(--serif);
215
+ font-size: 12px;
216
+ font-weight: 700;
217
+ color: var(--vermillion);
218
+ flex-shrink: 0;
219
+ }
220
+
221
+ .ann-pane-entry.active.pronunciation .ann-pane-idx {
222
+ color: var(--jade);
223
+ }
224
+
225
+ .ann-pane-word {
226
+ font-family: var(--serif);
227
+ font-size: 18px;
228
+ font-weight: 900;
229
+ letter-spacing: 2px;
230
+ color: var(--ink);
231
+ }
232
+
233
+ .ann-pane-kind {
234
+ display: inline-block;
235
+ padding: 1px 6px;
236
+ border-radius: 3px;
237
+ font-size: 10px;
238
+ font-family: var(--sans);
239
+ font-weight: 700;
240
+ letter-spacing: 1px;
241
+ line-height: 1.5;
242
+ }
243
+
244
+ .ann-pane-kind.pronunciation { background: var(--jade); color: #fff; }
245
+ .ann-pane-kind.semantic { background: var(--vermillion); color: #fff; }
246
+ .ann-pane-kind.etymology { background: #6b5b95; color: #fff; }
247
+ .ann-pane-kind.note,
248
+ .ann-pane-kind.definition { background: var(--ink); color: var(--paper); }
249
+ .ann-pane-kind.commentary { background: #c0392b; color: #fff; }
250
+ .ann-pane-kind.translation { background: #2c6e49; color: #fff; }
251
+ .ann-pane-kind.person { background: var(--ann-person); color: #fff; }
252
+ .ann-pane-kind.place { background: var(--ann-place); color: #fff; }
253
+ .ann-pane-kind.event { background: var(--ann-event); color: #fff; }
254
+ .ann-pane-kind.date { background: var(--ann-date); color: #fff; }
255
+ .ann-pane-kind.allusion { background: var(--ann-allusion); color: #fff; }
256
+
257
+ .ann-pane-layer {
258
+ font-size: 10px;
259
+ font-family: var(--sans);
260
+ color: var(--ink-faint);
261
+ padding: 1px 5px;
262
+ border: 1px solid var(--border-light);
263
+ border-radius: 2px;
264
+ }
265
+
266
+ .ann-pane-entry-body {
267
+ padding-left: 2px;
268
+ }
269
+
270
+ .ann-pane-text {
271
+ font-size: 14px;
272
+ color: var(--ink-mid);
273
+ line-height: 1.8;
274
+ letter-spacing: 0.5px;
275
+ white-space: pre-line;
276
+ }
277
+
278
+ /* Transition */
279
+ .ann-pane-enter-active {
280
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
281
+ }
282
+ .ann-pane-leave-active {
283
+ transition: transform 0.2s ease;
284
+ }
285
+ .ann-pane-enter-from,
286
+ .ann-pane-leave-to {
287
+ transform: translateX(-100%);
288
+ }
289
+
290
+ @media (max-width: 768px) {
291
+ .ann-pane {
292
+ width: 100%;
293
+ height: auto;
294
+ max-height: 55vh;
295
+ top: auto;
296
+ bottom: 0;
297
+ left: 0;
298
+ border-right: none;
299
+ border-top: 1px solid var(--border);
300
+ border-radius: 14px 14px 0 0;
301
+ box-shadow: 0 -4px 24px rgba(var(--shadow-rgb), 0.08);
302
+ }
303
+ .ann-pane-enter-from,
304
+ .ann-pane-leave-to {
305
+ transform: translateY(100%);
306
+ }
307
+ }
308
+ </style>
@@ -280,6 +280,7 @@ onBeforeUnmount(() => {
280
280
  overflow: hidden;
281
281
  backdrop-filter: blur(8px);
282
282
  -webkit-backdrop-filter: blur(8px);
283
+ writing-mode: horizontal-tb;
283
284
  }
284
285
 
285
286
  .ann-card-close {
@@ -347,6 +348,7 @@ onBeforeUnmount(() => {
347
348
  z-index: 1000;
348
349
  display: flex;
349
350
  flex-direction: column;
351
+ writing-mode: horizontal-tb;
350
352
  }
351
353
 
352
354
  .ann-sheet-handle {
@@ -4,7 +4,7 @@ import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables
4
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, mainFontSize, bodyFontSize, annotationsVisible, setTheme, setLayout, setMainFontSize, setBodyFontSize, setAnnotationsVisible } = useReadingMode()
7
+ const { theme, layout, mainFontSize, bodyFontSize, annotationsVisible, annotationPane, setTheme, setLayout, setMainFontSize, setBodyFontSize, setAnnotationsVisible, setAnnotationPane } = useReadingMode()
8
8
  const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
9
9
  const open = ref(false)
10
10
 
@@ -49,6 +49,21 @@ function close() { open.value = false }
49
49
  >{{ t('settings.hide') }}</button>
50
50
  </div>
51
51
  </div>
52
+ <div class="rt-group">
53
+ <div class="rt-label">{{ t('settings.annotationPane') }}</div>
54
+ <div class="rt-options">
55
+ <button
56
+ class="rt-opt"
57
+ :class="{ active: annotationPane }"
58
+ @click="setAnnotationPane(true)"
59
+ >{{ t('settings.show') }}</button>
60
+ <button
61
+ class="rt-opt"
62
+ :class="{ active: !annotationPane }"
63
+ @click="setAnnotationPane(false)"
64
+ >{{ t('settings.hide') }}</button>
65
+ </div>
66
+ </div>
52
67
  <div class="rt-group">
53
68
  <div class="rt-label">{{ t('settings.theme') }}</div>
54
69
  <div class="rt-options">
@@ -27,6 +27,8 @@ const messages: Record<Locale, Record<string, string>> = {
27
27
  'settings.annotations': '注釋',
28
28
  'settings.show': '顯示',
29
29
  'settings.hide': '隱藏',
30
+ 'settings.annotationPane': '注釋面板',
31
+ 'annotation.all': '注釋',
30
32
  'piece.stanzas': '段',
31
33
  'piece.notes': '注',
32
34
  'piece.noNotes': '無注',
@@ -85,6 +87,8 @@ const messages: Record<Locale, Record<string, string>> = {
85
87
  'settings.annotations': '注释',
86
88
  'settings.show': '显示',
87
89
  'settings.hide': '隐藏',
90
+ 'settings.annotationPane': '注释面板',
91
+ 'annotation.all': '注释',
88
92
  'piece.stanzas': '段',
89
93
  'piece.notes': '注',
90
94
  'piece.noNotes': '无注',
@@ -143,6 +147,8 @@ const messages: Record<Locale, Record<string, string>> = {
143
147
  'settings.annotations': 'Annotations',
144
148
  'settings.show': 'Show',
145
149
  'settings.hide': 'Hide',
150
+ 'settings.annotationPane': 'Annotation Panel',
151
+ 'annotation.all': 'Annotations',
146
152
  'piece.stanzas': 'stanzas',
147
153
  'piece.notes': 'notes',
148
154
  'piece.noNotes': 'No notes',
@@ -20,6 +20,7 @@ const layout = ref<LayoutMode>('vertical')
20
20
  const mainFontSize = ref<FontSize>(24)
21
21
  const bodyFontSize = ref<FontSize>(16)
22
22
  const annotationsVisible = ref(true)
23
+ const annotationPane = ref(false)
23
24
 
24
25
  if (!import.meta.env.SSR) {
25
26
  // Theme and font sizes only affect CSS, safe to apply before hydration
@@ -35,6 +36,9 @@ if (!import.meta.env.SSR) {
35
36
  const savedAnnVis = localStorage.getItem('annotationsVisible')
36
37
  if (savedAnnVis === 'false') annotationsVisible.value = false
37
38
 
39
+ const savedAnnPane = localStorage.getItem('annotationPane')
40
+ if (savedAnnPane === 'true') annotationPane.value = true
41
+
38
42
  // Layout controls v-if/v-else DOM structure — must defer to after hydration
39
43
  // to avoid SSR/client mismatch (SSR always renders vertical)
40
44
  nextTick(() => {
@@ -65,6 +69,10 @@ if (!import.meta.env.SSR) {
65
69
  watch(annotationsVisible, v => {
66
70
  localStorage.setItem('annotationsVisible', String(v))
67
71
  })
72
+
73
+ watch(annotationPane, v => {
74
+ localStorage.setItem('annotationPane', String(v))
75
+ })
68
76
  }
69
77
 
70
78
  export function useReadingMode() {
@@ -81,5 +89,7 @@ export function useReadingMode() {
81
89
  function setBodyFontSize(s: FontSize) { bodyFontSize.value = s }
82
90
  function setAnnotationsVisible(v: boolean) { annotationsVisible.value = v }
83
91
  function toggleAnnotationsVisible() { annotationsVisible.value = !annotationsVisible.value }
84
- return { theme, layout, mainFontSize, bodyFontSize, annotationsVisible, setTheme, cycleTheme, setLayout, toggleLayout, setMainFontSize, setBodyFontSize, setAnnotationsVisible, toggleAnnotationsVisible }
92
+ function setAnnotationPane(v: boolean) { annotationPane.value = v }
93
+ function toggleAnnotationPane() { annotationPane.value = !annotationPane.value }
94
+ return { theme, layout, mainFontSize, bodyFontSize, annotationsVisible, annotationPane, setTheme, cycleTheme, setLayout, toggleLayout, setMainFontSize, setBodyFontSize, setAnnotationsVisible, toggleAnnotationsVisible, setAnnotationPane, toggleAnnotationPane }
85
95
  }
@@ -12,6 +12,7 @@ import VerticalScroll from '../components/VerticalScroll.vue'
12
12
  import HorizontalDisplay from '../components/HorizontalDisplay.vue'
13
13
  import SectionBlock from '../components/SectionBlock.vue'
14
14
  import AnnotationTooltip from '../components/AnnotationTooltip.vue'
15
+ import AnnotationPane from '../components/AnnotationPane.vue'
15
16
  import AnnotationControlBar from '../components/AnnotationControlBar.vue'
16
17
  import SideNav from '../components/SideNav.vue'
17
18
  import PartGroup from '../components/PartGroup.vue'
@@ -24,7 +25,7 @@ const router = useRouter()
24
25
  const { getPiece, pieces, meta, load, getAdjacentNums } = useBook()
25
26
  await load(props.bookId)
26
27
 
27
- const { layout, annotationsVisible: prefAnnotationsVisible } = useReadingMode()
28
+ const { layout, annotationsVisible: prefAnnotationsVisible, annotationPane } = useReadingMode()
28
29
  const vPageRef = ref<HTMLElement | null>(null)
29
30
  const vScroll = useHorizontalScroll(vPageRef)
30
31
  const { t } = useI18n()
@@ -96,6 +97,51 @@ const annotationLayers = computed<AnnotationLayer[]>(() => piece.value?.annotati
96
97
  const hasLayers = computed(() => annotationLayers.value.length > 1)
97
98
  const activeLayerIds = ref<string[]>([])
98
99
  const annotationsVisible = prefAnnotationsVisible
100
+ const paneVisible = ref(false)
101
+ const paneActiveId = ref('')
102
+
103
+ function onAnnotationHover(event: MouseEvent, annotations: Annotation[]) {
104
+ if (annotationPane.value && isVertical.value) {
105
+ paneActiveId.value = annotations[0]?.id || ''
106
+ } else {
107
+ interaction.onHover(event, annotations)
108
+ }
109
+ }
110
+ function onAnnotationLeave() {
111
+ if (annotationPane.value && isVertical.value) return
112
+ interaction.onLeave()
113
+ }
114
+ function onAnnotationTap(event: MouseEvent, annotations: Annotation[]) {
115
+ if (annotationPane.value && isVertical.value) {
116
+ if (!paneVisible.value) paneVisible.value = true
117
+ paneActiveId.value = annotations[0]?.id || ''
118
+ } else {
119
+ interaction.onTap(event, annotations)
120
+ }
121
+ }
122
+ function onPaneSelect(ann: Annotation) {
123
+ const el = document.querySelector(`[data-ann-ids*="${ann.id}"]`) as HTMLElement | null
124
+ if (el) {
125
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
126
+ el.classList.add('ann-flash')
127
+ setTimeout(() => el.classList.remove('ann-flash'), 1500)
128
+ }
129
+ }
130
+ const annotationHeadwords = computed(() => {
131
+ const result: Record<string, string> = {}
132
+ for (const ann of mergedAnnotations.value) {
133
+ result[ann.id] = getHeadword(ann)
134
+ }
135
+ return result
136
+ })
137
+
138
+ watch([annotationPane, isVertical], ([pane, vert]) => {
139
+ if (pane && vert) {
140
+ interaction.dismiss()
141
+ } else {
142
+ paneVisible.value = false
143
+ }
144
+ })
99
145
 
100
146
  function initLayers() {
101
147
  if (hasLayers.value && activeLayerIds.value.length === 0) {
@@ -300,7 +346,8 @@ function tcy(n: number): string {
300
346
  </script>
301
347
 
302
348
  <template>
303
- <div v-if="piece">
349
+ <template v-if="piece">
350
+ <div>
304
351
  <!-- ═══════ 直排模式 ═══════ -->
305
352
  <div v-if="isVertical" class="v-root">
306
353
  <SideNav
@@ -337,6 +384,16 @@ function tcy(n: number): string {
337
384
  </div>
338
385
  </section>
339
386
 
387
+ <div class="v-inline-nav">
388
+ <button v-if="adjacent.next !== null" class="v-inav" @click="navigate(1)" :title="t('piece.next')">
389
+
390
+ </button>
391
+ <span v-else class="v-inav-spacer" />
392
+ <button v-if="adjacent.prev !== null" class="v-inav" @click="navigate(-1)" :title="t('piece.previous')">
393
+
394
+ </button>
395
+ </div>
396
+
340
397
  <section v-if="isMultiPart" class="v-poem-col v-multipart">
341
398
  <PartGroup
342
399
  v-for="group in partGroups"
@@ -344,9 +401,9 @@ function tcy(n: number): string {
344
401
  :label="group.label"
345
402
  :parts="group.parts"
346
403
  :vertical="true"
347
- @annotation-hover="interaction.onHover"
348
- @annotation-leave="interaction.onLeave"
349
- @annotation-tap="interaction.onTap"
404
+ @annotation-hover="onAnnotationHover"
405
+ @annotation-leave="onAnnotationLeave"
406
+ @annotation-tap="onAnnotationTap"
350
407
  />
351
408
  </section>
352
409
 
@@ -357,9 +414,9 @@ function tcy(n: number): string {
357
414
  :verses="piece.verses"
358
415
  :author-initial="piece.author?.charAt(0) || '詩'"
359
416
  :annotations="mergedAnnotations"
360
- @annotation-hover="interaction.onHover"
361
- @annotation-leave="interaction.onLeave"
362
- @annotation-tap="interaction.onTap"
417
+ @annotation-hover="onAnnotationHover"
418
+ @annotation-leave="onAnnotationLeave"
419
+ @annotation-tap="onAnnotationTap"
363
420
  @open-author="openAuthorPane"
364
421
  />
365
422
  </section>
@@ -431,17 +488,20 @@ function tcy(n: number): string {
431
488
  </nav>
432
489
  </div>
433
490
 
434
- <AnnotationTooltip
435
- :visible="interaction.visible"
436
- :annotations="interaction.items"
437
- :headword="interaction.headword"
491
+ <AnnotationPane
492
+ v-if="annotationPane"
493
+ :visible="paneVisible"
494
+ :annotations="mergedAnnotations"
495
+ :headwords="annotationHeadwords"
438
496
  :layer-labels="layerLabels"
439
- :style="interaction.style"
440
- :vertical="true"
441
- @close="interaction.dismiss"
442
- @tooltip-enter="interaction.onTooltipEnter"
443
- @tooltip-leave="interaction.onTooltipLeave"
497
+ :active-id="paneActiveId"
498
+ @close="paneVisible = false"
499
+ @select="onPaneSelect"
444
500
  />
501
+
502
+ <Teleport to="body">
503
+ <Transition name="overlay">
504
+ <div v-if="authorPaneOpen" class="v-overlay" @click="closeAuthorPane">
445
505
  <div class="v-author-pane" @click.stop>
446
506
  <button class="v-pane-close" @click="closeAuthorPane">✕</button>
447
507
  <div class="v-pane-header">
@@ -596,18 +656,6 @@ function tcy(n: number): string {
596
656
  </div>
597
657
  </div>
598
658
 
599
- <AnnotationTooltip
600
- :visible="interaction.visible"
601
- :annotations="interaction.items"
602
- :headword="interaction.headword"
603
- :layer-labels="layerLabels"
604
- :style="interaction.style"
605
- :vertical="false"
606
- @close="interaction.dismiss"
607
- @tooltip-enter="interaction.onTooltipEnter"
608
- @tooltip-leave="interaction.onTooltipLeave"
609
- />
610
-
611
659
  <BackToTop />
612
660
 
613
661
  <Teleport to="body">
@@ -656,6 +704,19 @@ function tcy(n: number): string {
656
704
  </div>
657
705
  </div>
658
706
 
707
+ <AnnotationTooltip
708
+ v-if="piece && !(annotationPane && isVertical)"
709
+ :visible="interaction.visible"
710
+ :annotations="interaction.items"
711
+ :headword="interaction.headword"
712
+ :layer-labels="layerLabels"
713
+ :style="interaction.style"
714
+ @close="interaction.dismiss"
715
+ @tooltip-enter="interaction.onTooltipEnter"
716
+ @tooltip-leave="interaction.onTooltipLeave"
717
+ />
718
+ </template>
719
+
659
720
  <div v-else class="page-loading">
660
721
  <img v-if="CHAM_LOGO_URL" :src="CHAM_LOGO_URL" alt="" class="page-loading-logo" />
661
722
  <div v-else class="page-loading-seal">文</div>
@@ -1228,6 +1289,55 @@ function tcy(n: number): string {
1228
1289
  .v-poem-author:active { color: var(--vermillion); }
1229
1290
  .h-author-link:active { color: var(--vermillion); }
1230
1291
 
1292
+ /* ─── 直排行內導航 ─── */
1293
+ .v-inline-nav {
1294
+ writing-mode: horizontal-tb;
1295
+ display: flex;
1296
+ flex-direction: column;
1297
+ gap: 8px;
1298
+ flex-shrink: 0;
1299
+ height: 100vh;
1300
+ align-items: center;
1301
+ justify-content: center;
1302
+ padding: 0 6px;
1303
+ }
1304
+ .v-inav {
1305
+ width: 30px;
1306
+ height: 44px;
1307
+ border: 1px solid var(--border-light);
1308
+ border-radius: 6px;
1309
+ background: var(--surface);
1310
+ color: var(--ink-faint);
1311
+ font-size: 14px;
1312
+ cursor: pointer;
1313
+ transition: all 0.15s;
1314
+ display: flex;
1315
+ align-items: center;
1316
+ justify-content: center;
1317
+ }
1318
+ .v-inav:hover {
1319
+ border-color: var(--vermillion);
1320
+ color: var(--vermillion);
1321
+ background: var(--surface-warm);
1322
+ box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
1323
+ }
1324
+ .v-inav:active {
1325
+ transform: scale(0.94);
1326
+ }
1327
+ .v-inav-spacer {
1328
+ width: 30px;
1329
+ height: 44px;
1330
+ }
1331
+
1332
+ /* ─── 注釋閃爍 ─── */
1333
+ :deep(.ann-flash) {
1334
+ animation: ann-flash-anim 1.2s ease-out;
1335
+ }
1336
+ @keyframes ann-flash-anim {
1337
+ 0% { background: rgba(194, 58, 43, 0.2); }
1338
+ 100% { background: transparent; }
1339
+ }
1340
+
1231
1341
  /* ═══════ 行動裝置適配 ═══════ */
1232
1342
 
1233
1343
  @media (max-width: 768px) {