@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 +1 -1
- package/template/src/components/AnnotationPane.vue +308 -0
- package/template/src/components/AnnotationTooltip.vue +2 -0
- package/template/src/components/ReadingToolbar.vue +16 -1
- package/template/src/composables/useI18n.ts +6 -0
- package/template/src/composables/useReadingMode.ts +11 -1
- package/template/src/views/PieceView.vue +139 -29
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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="
|
|
348
|
-
@annotation-leave="
|
|
349
|
-
@annotation-tap="
|
|
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="
|
|
361
|
-
@annotation-leave="
|
|
362
|
-
@annotation-tap="
|
|
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
|
-
<
|
|
435
|
-
|
|
436
|
-
:
|
|
437
|
-
:
|
|
491
|
+
<AnnotationPane
|
|
492
|
+
v-if="annotationPane"
|
|
493
|
+
:visible="paneVisible"
|
|
494
|
+
:annotations="mergedAnnotations"
|
|
495
|
+
:headwords="annotationHeadwords"
|
|
438
496
|
:layer-labels="layerLabels"
|
|
439
|
-
:
|
|
440
|
-
|
|
441
|
-
@
|
|
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) {
|