@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 +1 -1
- package/template/src/components/AnnotationPane.vue +346 -0
- package/template/src/components/AnnotationTooltip.vue +82 -2
- package/template/src/components/ReadingToolbar.vue +16 -1
- package/template/src/composables/useAnnotationRenderer.ts +5 -4
- package/template/src/composables/useI18n.ts +6 -0
- package/template/src/composables/useReadingMode.ts +11 -1
- package/template/src/views/PieceView.vue +137 -29
package/package.json
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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 =
|
|
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 -
|
|
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:
|
|
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
|
-
|
|
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,16 +488,16 @@ 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
|
-
:vertical="
|
|
441
|
-
@close="
|
|
442
|
-
@
|
|
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) {
|