@hanology/cham-browser 0.4.16 → 0.4.18

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.16",
3
+ "version": "0.4.18",
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,346 @@
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
+ vertical?: boolean
15
+ }>()
16
+
17
+ const emit = defineEmits<{
18
+ close: []
19
+ select: [ann: Annotation]
20
+ }>()
21
+
22
+ const bodyRef = ref<HTMLElement | null>(null)
23
+
24
+ function getSegment(ann: Annotation) {
25
+ return annotationToPronSegment(ann)
26
+ }
27
+
28
+ function headword(ann: Annotation): string {
29
+ return props.headwords[ann.id] || ''
30
+ }
31
+
32
+ function kindLabel(ann: Annotation): string {
33
+ const map: Record<string, string> = {
34
+ pronunciation: '讀音',
35
+ semantic: '釋義',
36
+ etymology: '詞源',
37
+ note: '備注',
38
+ definition: '釋義',
39
+ commentary: '評註',
40
+ translation: '譯文',
41
+ person: '人名',
42
+ place: '地名',
43
+ event: '事件',
44
+ date: '紀年',
45
+ allusion: '典故',
46
+ }
47
+ return map[ann.kind] || ann.kind
48
+ }
49
+
50
+ function layerLabel(ann: Annotation): string {
51
+ if (!props.layerLabels || !ann.id) return ''
52
+ for (const [prefix, label] of Object.entries(props.layerLabels)) {
53
+ if (ann.id.startsWith(prefix)) return label
54
+ }
55
+ return ''
56
+ }
57
+
58
+ function onKeydown(e: KeyboardEvent) {
59
+ if (e.key === 'Escape' && props.visible) {
60
+ e.preventDefault()
61
+ emit('close')
62
+ }
63
+ }
64
+
65
+ watch(() => props.activeId, async (id) => {
66
+ if (!id || !props.visible) return
67
+ await nextTick()
68
+ const el = bodyRef.value?.querySelector(`[data-ann-id="${id}"]`)
69
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
70
+ })
71
+
72
+ onMounted(() => document.addEventListener('keydown', onKeydown))
73
+ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
74
+ </script>
75
+
76
+ <template>
77
+ <Teleport to="body">
78
+ <Transition name="ann-pane">
79
+ <div v-if="visible && annotations.length" class="ann-pane" :class="{ vertical }">
80
+ <div class="ann-pane-header">
81
+ <span class="ann-pane-title">注釋</span>
82
+ <span class="ann-pane-count">{{ annotations.length }}</span>
83
+ <button class="ann-pane-close" @click="emit('close')" aria-label="關閉">
84
+ <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>
85
+ </button>
86
+ </div>
87
+ <div ref="bodyRef" class="ann-pane-body">
88
+ <div
89
+ v-for="(ann, idx) in annotations"
90
+ :key="ann.id"
91
+ :data-ann-id="ann.id"
92
+ class="ann-pane-entry"
93
+ :class="{ active: activeId === ann.id, [ann.kind]: true }"
94
+ @click="emit('select', ann)"
95
+ >
96
+ <div class="ann-pane-entry-head">
97
+ <span class="ann-pane-idx">{{ toChineseNumber(idx + 1) }}</span>
98
+ <span v-if="headword(ann)" class="ann-pane-word">{{ headword(ann) }}</span>
99
+ <span class="ann-pane-kind" :class="ann.kind">{{ kindLabel(ann) }}</span>
100
+ <span v-if="layerLabel(ann)" class="ann-pane-layer">{{ layerLabel(ann) }}</span>
101
+ </div>
102
+ <div class="ann-pane-entry-body">
103
+ <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
104
+ <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-pane-text">{{ ann.text }}</div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </Transition>
110
+ </Teleport>
111
+ </template>
112
+
113
+ <style scoped>
114
+ .ann-pane {
115
+ position: fixed;
116
+ left: 0;
117
+ top: 0;
118
+ width: 320px;
119
+ height: 100vh;
120
+ background: var(--surface-warm);
121
+ border-right: 1px solid var(--border);
122
+ z-index: 300;
123
+ display: flex;
124
+ flex-direction: column;
125
+ writing-mode: horizontal-tb;
126
+ box-shadow: 4px 0 24px rgba(var(--shadow-rgb), 0.06);
127
+ }
128
+
129
+ .ann-pane-header {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 8px;
133
+ padding: 16px 20px;
134
+ border-bottom: 1px solid var(--border-light);
135
+ background: var(--surface);
136
+ flex-shrink: 0;
137
+ }
138
+
139
+ .ann-pane-title {
140
+ font-family: var(--serif);
141
+ font-size: 18px;
142
+ font-weight: 900;
143
+ letter-spacing: 4px;
144
+ color: var(--ink);
145
+ }
146
+
147
+ .ann-pane-count {
148
+ font-family: var(--sans);
149
+ font-size: 11px;
150
+ font-weight: 600;
151
+ color: var(--ink-faint);
152
+ background: var(--surface-warm);
153
+ border: 1px solid var(--border-light);
154
+ border-radius: 10px;
155
+ padding: 2px 8px;
156
+ }
157
+
158
+ .ann-pane-close {
159
+ margin-left: auto;
160
+ width: 24px;
161
+ height: 24px;
162
+ border: none;
163
+ border-radius: 4px;
164
+ background: transparent;
165
+ color: var(--ink-faint);
166
+ cursor: pointer;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ transition: all 0.15s;
171
+ }
172
+
173
+ .ann-pane-close:hover {
174
+ background: var(--ink);
175
+ color: var(--paper);
176
+ }
177
+
178
+ .ann-pane-body {
179
+ flex: 1;
180
+ overflow-y: auto;
181
+ overscroll-behavior: contain;
182
+ padding: 4px 0;
183
+ }
184
+
185
+ .ann-pane-entry {
186
+ padding: 12px 20px;
187
+ border-bottom: 1px solid var(--border-light);
188
+ border-left: 3px solid transparent;
189
+ cursor: pointer;
190
+ transition: all 0.15s;
191
+ }
192
+
193
+ .ann-pane-entry:hover {
194
+ background: var(--surface);
195
+ }
196
+
197
+ .ann-pane-entry.active {
198
+ border-left-color: var(--vermillion);
199
+ background: var(--surface);
200
+ }
201
+
202
+ .ann-pane-entry.active.pronunciation {
203
+ border-left-color: var(--jade);
204
+ }
205
+
206
+ .ann-pane-entry-head {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 6px;
210
+ margin-bottom: 4px;
211
+ flex-wrap: wrap;
212
+ }
213
+
214
+ .ann-pane-idx {
215
+ font-family: var(--serif);
216
+ font-size: 12px;
217
+ font-weight: 700;
218
+ color: var(--vermillion);
219
+ flex-shrink: 0;
220
+ }
221
+
222
+ .ann-pane-entry.active.pronunciation .ann-pane-idx {
223
+ color: var(--jade);
224
+ }
225
+
226
+ .ann-pane-word {
227
+ font-family: var(--serif);
228
+ font-size: 18px;
229
+ font-weight: 900;
230
+ letter-spacing: 2px;
231
+ color: var(--ink);
232
+ }
233
+
234
+ .ann-pane-kind {
235
+ display: inline-block;
236
+ padding: 1px 6px;
237
+ border-radius: 3px;
238
+ font-size: 10px;
239
+ font-family: var(--sans);
240
+ font-weight: 700;
241
+ letter-spacing: 1px;
242
+ line-height: 1.5;
243
+ }
244
+
245
+ .ann-pane-kind.pronunciation { background: var(--jade); color: #fff; }
246
+ .ann-pane-kind.semantic { background: var(--vermillion); color: #fff; }
247
+ .ann-pane-kind.etymology { background: #6b5b95; color: #fff; }
248
+ .ann-pane-kind.note,
249
+ .ann-pane-kind.definition { background: var(--ink); color: var(--paper); }
250
+ .ann-pane-kind.commentary { background: #c0392b; color: #fff; }
251
+ .ann-pane-kind.translation { background: #2c6e49; color: #fff; }
252
+ .ann-pane-kind.person { background: var(--ann-person); color: #fff; }
253
+ .ann-pane-kind.place { background: var(--ann-place); color: #fff; }
254
+ .ann-pane-kind.event { background: var(--ann-event); color: #fff; }
255
+ .ann-pane-kind.date { background: var(--ann-date); color: #fff; }
256
+ .ann-pane-kind.allusion { background: var(--ann-allusion); color: #fff; }
257
+
258
+ .ann-pane-layer {
259
+ font-size: 10px;
260
+ font-family: var(--sans);
261
+ color: var(--ink-faint);
262
+ padding: 1px 5px;
263
+ border: 1px solid var(--border-light);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .ann-pane-entry-body {
268
+ padding-left: 2px;
269
+ }
270
+
271
+ .ann-pane-text {
272
+ font-size: 14px;
273
+ color: var(--ink-mid);
274
+ line-height: 1.8;
275
+ letter-spacing: 0.5px;
276
+ white-space: pre-line;
277
+ }
278
+
279
+ /* Transition */
280
+ .ann-pane-enter-active {
281
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
282
+ }
283
+ .ann-pane-leave-active {
284
+ transition: transform 0.2s ease;
285
+ }
286
+ .ann-pane-enter-from,
287
+ .ann-pane-leave-to {
288
+ transform: translateX(-100%);
289
+ }
290
+
291
+ @media (max-width: 768px) {
292
+ .ann-pane {
293
+ width: 100%;
294
+ height: auto;
295
+ max-height: 55vh;
296
+ top: auto;
297
+ bottom: 0;
298
+ left: 0;
299
+ border-right: none;
300
+ border-top: 1px solid var(--border);
301
+ border-radius: 14px 14px 0 0;
302
+ box-shadow: 0 -4px 24px rgba(var(--shadow-rgb), 0.08);
303
+ }
304
+ .ann-pane-enter-from,
305
+ .ann-pane-leave-to {
306
+ transform: translateY(100%);
307
+ }
308
+ }
309
+
310
+ /* ─── Vertical mode ─── */
311
+ .ann-pane.vertical {
312
+ writing-mode: vertical-rl;
313
+ text-orientation: mixed;
314
+ width: 240px;
315
+ }
316
+
317
+ .ann-pane.vertical .ann-pane-body {
318
+ overflow-x: auto;
319
+ overflow-y: hidden;
320
+ }
321
+
322
+ .ann-pane.vertical .ann-pane-entry {
323
+ padding: 12px 8px;
324
+ border-bottom: none;
325
+ border-left: 1px solid var(--border-light);
326
+ border-right: 3px solid transparent;
327
+ }
328
+
329
+ .ann-pane.vertical .ann-pane-entry.active {
330
+ border-right-color: var(--vermillion);
331
+ }
332
+
333
+ .ann-pane.vertical .ann-pane-entry.active.pronunciation {
334
+ border-right-color: var(--jade);
335
+ }
336
+
337
+ .ann-pane.vertical .ann-pane-entry-head {
338
+ flex-direction: column;
339
+ gap: 4px;
340
+ }
341
+
342
+ .ann-pane.vertical .ann-pane-text {
343
+ line-height: 2;
344
+ letter-spacing: 1px;
345
+ }
346
+ </style>
@@ -118,6 +118,7 @@ onBeforeUnmount(() => {
118
118
  <div
119
119
  v-if="!isMobile && stickyVisible && annotations.length"
120
120
  class="ann-card"
121
+ :class="{ vertical }"
121
122
  :style="style"
122
123
  @mouseenter="emit('tooltipEnter')"
123
124
  @mouseleave="emit('tooltipLeave')"
@@ -136,7 +137,7 @@ onBeforeUnmount(() => {
136
137
  <span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
137
138
  </div>
138
139
  <div class="ann-entry-body">
139
- <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
140
+ <div v-if="getSegment(ann)" class="ann-pron-h"><PronunciationGroup :segment="getSegment(ann)!" /></div>
140
141
  <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
141
142
  </div>
142
143
  </div>
@@ -149,6 +150,7 @@ onBeforeUnmount(() => {
149
150
  <div
150
151
  v-if="isMobile && stickyVisible && annotations.length"
151
152
  class="ann-sheet"
153
+ :class="{ vertical }"
152
154
  >
153
155
  <button class="ann-sheet-handle" @click="dismiss">
154
156
  <span class="ann-handle-bar" />
@@ -164,7 +166,7 @@ onBeforeUnmount(() => {
164
166
  <span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
165
167
  </div>
166
168
  <div class="ann-entry-body">
167
- <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
169
+ <div v-if="getSegment(ann)" class="ann-pron-h"><PronunciationGroup :segment="getSegment(ann)!" /></div>
168
170
  <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
169
171
  </div>
170
172
  </div>
@@ -280,6 +282,7 @@ onBeforeUnmount(() => {
280
282
  overflow: hidden;
281
283
  backdrop-filter: blur(8px);
282
284
  -webkit-backdrop-filter: blur(8px);
285
+ writing-mode: horizontal-tb;
283
286
  }
284
287
 
285
288
  .ann-card-close {
@@ -347,6 +350,7 @@ onBeforeUnmount(() => {
347
350
  z-index: 1000;
348
351
  display: flex;
349
352
  flex-direction: column;
353
+ writing-mode: horizontal-tb;
350
354
  }
351
355
 
352
356
  .ann-sheet-handle {
@@ -405,4 +409,80 @@ onBeforeUnmount(() => {
405
409
  @media (min-width: 768px) {
406
410
  .ann-sheet { display: none; }
407
411
  }
412
+
413
+ /* ─── Vertical mode ─── */
414
+ .ann-card.vertical {
415
+ writing-mode: vertical-rl;
416
+ text-orientation: mixed;
417
+ }
418
+
419
+ .ann-card.vertical .ann-card-scroll {
420
+ display: flex;
421
+ flex-direction: row;
422
+ padding: 10px 8px;
423
+ }
424
+
425
+ .ann-card.vertical .ann-entry {
426
+ border-bottom: none;
427
+ padding: 8px 4px;
428
+ }
429
+
430
+ .ann-card.vertical .ann-entry + .ann-entry {
431
+ border-top: 1px solid var(--border-light);
432
+ margin-top: 4px;
433
+ }
434
+
435
+ .ann-card.vertical .ann-card-close {
436
+ writing-mode: horizontal-tb;
437
+ }
438
+
439
+ .ann-card.vertical .ann-pron-h {
440
+ writing-mode: horizontal-tb;
441
+ }
442
+
443
+ .ann-card.vertical .ann-entry-body {
444
+ padding-left: 0;
445
+ padding-top: 4px;
446
+ }
447
+
448
+ .ann-card.vertical .ann-text {
449
+ white-space: pre-line;
450
+ line-height: 2;
451
+ }
452
+
453
+ .ann-sheet.vertical {
454
+ writing-mode: vertical-rl;
455
+ text-orientation: mixed;
456
+ }
457
+
458
+ .ann-sheet.vertical .ann-sheet-scroll {
459
+ display: flex;
460
+ flex-direction: column;
461
+ overflow-x: auto;
462
+ overflow-y: hidden;
463
+ padding: 4px 16px 24px;
464
+ }
465
+
466
+ .ann-sheet.vertical .ann-entry {
467
+ border-bottom: none;
468
+ border-left: 1px solid var(--border-light);
469
+ padding: 0 12px;
470
+ }
471
+
472
+ .ann-sheet.vertical .ann-entry:first-child {
473
+ border-left: none;
474
+ }
475
+
476
+ .ann-sheet.vertical .ann-pron-h {
477
+ writing-mode: horizontal-tb;
478
+ }
479
+
480
+ .ann-sheet.vertical .ann-text {
481
+ white-space: pre-line;
482
+ line-height: 2;
483
+ }
484
+
485
+ .ann-sheet.vertical .ann-sheet-handle {
486
+ writing-mode: horizontal-tb;
487
+ }
408
488
  </style>
@@ -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">
@@ -160,11 +160,12 @@ export function useAnnotationTooltip() {
160
160
  bottom: '0',
161
161
  }
162
162
  } else if (layout.value === 'vertical') {
163
- // Vertical mode: card to the left of the annotation
164
- const cardW = 340
163
+ // Vertical mode: tall narrow card to the left of the annotation
164
+ const cardW = 180
165
+ const cardH = Math.min(vh - 16, 480)
165
166
  const gap = 12
166
167
  let left = Math.max(8, rect.left - cardW - gap)
167
- let top = Math.max(8, Math.min(rect.top, vh - 300))
168
+ let top = Math.max(8, Math.min(rect.top, vh - cardH))
168
169
 
169
170
  // If not enough room on left, go right
170
171
  if (rect.left - cardW - gap < 8) {
@@ -175,7 +176,7 @@ export function useAnnotationTooltip() {
175
176
  left: left + 'px',
176
177
  top: top + 'px',
177
178
  width: cardW + 'px',
178
- maxHeight: Math.min(vh - 16, 500) + 'px',
179
+ maxHeight: cardH + 'px',
179
180
  }
180
181
  } else {
181
182
  // Horizontal mode: card below/above the annotation
@@ -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,16 +488,16 @@ 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
+ :vertical="isVertical"
499
+ @close="paneVisible = false"
500
+ @select="onPaneSelect"
444
501
  />
445
502
 
446
503
  <Teleport to="body">
@@ -600,18 +657,6 @@ function tcy(n: number): string {
600
657
  </div>
601
658
  </div>
602
659
 
603
- <AnnotationTooltip
604
- :visible="interaction.visible"
605
- :annotations="interaction.items"
606
- :headword="interaction.headword"
607
- :layer-labels="layerLabels"
608
- :style="interaction.style"
609
- :vertical="false"
610
- @close="interaction.dismiss"
611
- @tooltip-enter="interaction.onTooltipEnter"
612
- @tooltip-leave="interaction.onTooltipLeave"
613
- />
614
-
615
660
  <BackToTop />
616
661
 
617
662
  <Teleport to="body">
@@ -660,6 +705,20 @@ function tcy(n: number): string {
660
705
  </div>
661
706
  </div>
662
707
 
708
+ <AnnotationTooltip
709
+ v-if="piece && !(annotationPane && isVertical)"
710
+ :visible="interaction.visible"
711
+ :annotations="interaction.items"
712
+ :headword="interaction.headword"
713
+ :layer-labels="layerLabels"
714
+ :style="interaction.style"
715
+ :vertical="isVertical"
716
+ @close="interaction.dismiss"
717
+ @tooltip-enter="interaction.onTooltipEnter"
718
+ @tooltip-leave="interaction.onTooltipLeave"
719
+ />
720
+ </template>
721
+
663
722
  <div v-else class="page-loading">
664
723
  <img v-if="CHAM_LOGO_URL" :src="CHAM_LOGO_URL" alt="" class="page-loading-logo" />
665
724
  <div v-else class="page-loading-seal">文</div>
@@ -1232,6 +1291,55 @@ function tcy(n: number): string {
1232
1291
  .v-poem-author:active { color: var(--vermillion); }
1233
1292
  .h-author-link:active { color: var(--vermillion); }
1234
1293
 
1294
+ /* ─── 直排行內導航 ─── */
1295
+ .v-inline-nav {
1296
+ writing-mode: horizontal-tb;
1297
+ display: flex;
1298
+ flex-direction: column;
1299
+ gap: 8px;
1300
+ flex-shrink: 0;
1301
+ height: 100vh;
1302
+ align-items: center;
1303
+ justify-content: center;
1304
+ padding: 0 6px;
1305
+ }
1306
+ .v-inav {
1307
+ width: 30px;
1308
+ height: 44px;
1309
+ border: 1px solid var(--border-light);
1310
+ border-radius: 6px;
1311
+ background: var(--surface);
1312
+ color: var(--ink-faint);
1313
+ font-size: 14px;
1314
+ cursor: pointer;
1315
+ transition: all 0.15s;
1316
+ display: flex;
1317
+ align-items: center;
1318
+ justify-content: center;
1319
+ }
1320
+ .v-inav:hover {
1321
+ border-color: var(--vermillion);
1322
+ color: var(--vermillion);
1323
+ background: var(--surface-warm);
1324
+ box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
1325
+ }
1326
+ .v-inav:active {
1327
+ transform: scale(0.94);
1328
+ }
1329
+ .v-inav-spacer {
1330
+ width: 30px;
1331
+ height: 44px;
1332
+ }
1333
+
1334
+ /* ─── 注釋閃爍 ─── */
1335
+ :deep(.ann-flash) {
1336
+ animation: ann-flash-anim 1.2s ease-out;
1337
+ }
1338
+ @keyframes ann-flash-anim {
1339
+ 0% { background: rgba(194, 58, 43, 0.2); }
1340
+ 100% { background: transparent; }
1341
+ }
1342
+
1235
1343
  /* ═══════ 行動裝置適配 ═══════ */
1236
1344
 
1237
1345
  @media (max-width: 768px) {