@hanology/cham-browser 0.3.3 → 0.3.5
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/index.html +14 -5
- package/template/src/App.vue +21 -4
- package/template/src/components/PartBlock.vue +186 -0
- package/template/src/components/PartGroup.vue +73 -0
- package/template/src/components/PoemCard.vue +6 -6
- package/template/src/components/ReadingProgress.vue +83 -0
- package/template/src/components/ReadingToolbar.vue +81 -5
- package/template/src/components/SectionBlock.vue +32 -7
- package/template/src/components/SideNav.vue +36 -0
- package/template/src/main.ts +5 -1
- package/template/src/styles/main.css +45 -8
- package/template/src/types.ts +14 -2
- package/template/src/views/AboutView.vue +6 -0
- package/template/src/views/BookHome.vue +1 -0
- package/template/src/views/LibraryHome.vue +37 -2
- package/template/src/views/PieceView.vue +141 -14
- package/template/src/components/AnnotationLayerSelector.vue +0 -66
- package/template/src/composables/usePageLayout.ts +0 -25
package/package.json
CHANGED
package/template/index.html
CHANGED
|
@@ -15,14 +15,23 @@
|
|
|
15
15
|
})();
|
|
16
16
|
</script>
|
|
17
17
|
<style>
|
|
18
|
-
#app-loading{position:fixed;inset:0;background:var(--paper,#faf6ee);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;font-family:'Noto Serif TC',serif}
|
|
19
|
-
#app-loading
|
|
20
|
-
#app-loading .
|
|
21
|
-
|
|
18
|
+
#app-loading{position:fixed;inset:0;background:var(--paper,#faf6ee);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;font-family:'Noto Serif TC',serif;transition:opacity .4s ease}
|
|
19
|
+
#app-loading.fade-out{opacity:0;pointer-events:none}
|
|
20
|
+
#app-loading .seal{width:72px;height:72px;border:2px solid var(--vermillion,#c23a2b);border-radius:4px;display:flex;align-items:center;justify-content:center;animation:sealReveal .8s cubic-bezier(.16,1,.3,1) forwards;opacity:0}
|
|
21
|
+
#app-loading .char{font-size:36px;font-weight:900;color:var(--vermillion,#c23a2b);line-height:1}
|
|
22
|
+
#app-loading .line{width:1px;height:40px;background:linear-gradient(180deg,var(--vermillion,#c23a2b),transparent);margin-top:24px;animation:lineGrow .6s .3s cubic-bezier(.16,1,.3,1) forwards;transform:scaleY(0);transform-origin:top}
|
|
23
|
+
#app-loading .text{font-size:11px;color:var(--ink-faint,#a89b8a);letter-spacing:6px;margin-top:16px;animation:fadeIn .5s .5s ease forwards;opacity:0;font-family:'Noto Sans TC',sans-serif}
|
|
24
|
+
@keyframes sealReveal{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}
|
|
25
|
+
@keyframes lineGrow{to{transform:scaleY(1)}}
|
|
26
|
+
@keyframes fadeIn{to{opacity:1}}
|
|
22
27
|
</style>
|
|
23
28
|
</head>
|
|
24
29
|
<body>
|
|
25
|
-
<div id="app-loading"
|
|
30
|
+
<div id="app-loading">
|
|
31
|
+
<div class="seal"><div class="char">詩</div></div>
|
|
32
|
+
<div class="line"></div>
|
|
33
|
+
<div class="text">載 入 中</div>
|
|
34
|
+
</div>
|
|
26
35
|
<div id="app"></div>
|
|
27
36
|
<script type="module" src="/src/main.ts"></script>
|
|
28
37
|
</body>
|
package/template/src/App.vue
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useRouter } from 'vue-router'
|
|
3
3
|
import { useReadingMode } from './composables/useReadingMode'
|
|
4
4
|
import ReadingToolbar from './components/ReadingToolbar.vue'
|
|
5
|
-
import { computed } from 'vue'
|
|
5
|
+
import { computed, ref } from 'vue'
|
|
6
6
|
|
|
7
7
|
const router = useRouter()
|
|
8
8
|
const { toggleLayout, cycleTheme, layout } = useReadingMode()
|
|
@@ -19,11 +19,28 @@ function onKey(event: KeyboardEvent) {
|
|
|
19
19
|
<template>
|
|
20
20
|
<div @keydown="onKey">
|
|
21
21
|
<router-view v-slot="{ Component }">
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
22
|
+
<Transition name="page" mode="out-in">
|
|
23
|
+
<Suspense>
|
|
24
|
+
<component :is="Component" :key="$route.fullPath" />
|
|
25
|
+
</Suspense>
|
|
26
|
+
</Transition>
|
|
25
27
|
</router-view>
|
|
26
28
|
<!-- 橫排模式才顯示浮動設定鈕 -->
|
|
27
29
|
<ReadingToolbar v-if="!isVertical" />
|
|
28
30
|
</div>
|
|
29
31
|
</template>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
.page-enter-active,
|
|
35
|
+
.page-leave-active {
|
|
36
|
+
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
37
|
+
}
|
|
38
|
+
.page-enter-from {
|
|
39
|
+
opacity: 0;
|
|
40
|
+
transform: translateY(8px);
|
|
41
|
+
}
|
|
42
|
+
.page-leave-to {
|
|
43
|
+
opacity: 0;
|
|
44
|
+
transform: translateY(-4px);
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Annotation, VerseLine, PieceSource } from '../types'
|
|
3
|
+
import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations, countVerseSpans } from '../composables/useAnnotationRenderer'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
num: number
|
|
7
|
+
verses: VerseLine[]
|
|
8
|
+
annotations: Annotation[]
|
|
9
|
+
vertical?: boolean
|
|
10
|
+
source?: PieceSource
|
|
11
|
+
annotationText?: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
annotationHover: [event: MouseEvent, annotations: Annotation[]]
|
|
16
|
+
annotationLeave: []
|
|
17
|
+
annotationTap: [event: MouseEvent, annotations: Annotation[]]
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
function verseHtml(index: number): string {
|
|
21
|
+
const useRuby = props.vertical
|
|
22
|
+
let offset = 0
|
|
23
|
+
for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
|
|
24
|
+
const spans = buildVerseAnnotations(props.annotations, index)
|
|
25
|
+
return renderAnnotatedText(props.verses[index].text, spans, useRuby, offset)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function onHover(event: MouseEvent) {
|
|
29
|
+
const matched = resolveHoveredAnnotations(event, props.annotations)
|
|
30
|
+
if (matched) emit('annotationHover', event, matched)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function onLeave() { emit('annotationLeave') }
|
|
34
|
+
|
|
35
|
+
function onTap(event: MouseEvent) {
|
|
36
|
+
const matched = resolveHoveredAnnotations(event, props.annotations)
|
|
37
|
+
if (matched) emit('annotationTap', event, matched)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sourceLabel = (() => {
|
|
41
|
+
const r = props.source?.range as Record<string, string> | undefined
|
|
42
|
+
return r?.chapter || ''
|
|
43
|
+
})()
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<div class="part-block" :class="{ 'part-block--vertical': vertical }">
|
|
48
|
+
<div v-if="sourceLabel" class="part-source">
|
|
49
|
+
{{ sourceLabel }}
|
|
50
|
+
</div>
|
|
51
|
+
<div class="part-text" @mouseover="onHover" @mouseleave="onLeave" @click="onTap">
|
|
52
|
+
<span
|
|
53
|
+
v-for="(_, i) in verses"
|
|
54
|
+
:key="i"
|
|
55
|
+
:class="vertical ? 'part-line-v' : 'part-line-h'"
|
|
56
|
+
v-html="verseHtml(i)"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div v-if="annotationText" class="part-annotations">
|
|
60
|
+
<div v-for="line in annotationText.split('\n')" :key="line" class="part-ann-line">{{ line }}</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.part-block {
|
|
67
|
+
padding: 20px 0;
|
|
68
|
+
border-bottom: 1px solid var(--border-light);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.part-block:last-child {
|
|
72
|
+
border-bottom: none;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.part-block--vertical {
|
|
76
|
+
writing-mode: vertical-rl;
|
|
77
|
+
text-orientation: mixed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.part-source {
|
|
81
|
+
font-family: var(--sans);
|
|
82
|
+
font-size: 12px;
|
|
83
|
+
letter-spacing: 1px;
|
|
84
|
+
color: var(--ink-faint);
|
|
85
|
+
background: var(--surface);
|
|
86
|
+
display: inline-block;
|
|
87
|
+
padding: 3px 10px;
|
|
88
|
+
border-radius: 3px;
|
|
89
|
+
margin-bottom: 12px;
|
|
90
|
+
border: 1px solid var(--border-light);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.part-text {
|
|
94
|
+
line-height: 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.part-annotations {
|
|
98
|
+
margin-top: 16px;
|
|
99
|
+
padding-top: 12px;
|
|
100
|
+
border-top: 1px dashed var(--border-light);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.part-ann-line {
|
|
104
|
+
font-family: var(--sans);
|
|
105
|
+
font-size: 14px;
|
|
106
|
+
line-height: 2;
|
|
107
|
+
color: var(--ink-mid);
|
|
108
|
+
letter-spacing: 0.5px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.part-line-h {
|
|
112
|
+
font-size: var(--main-font-size, 22px);
|
|
113
|
+
line-height: 2.4;
|
|
114
|
+
letter-spacing: 3px;
|
|
115
|
+
color: var(--ink);
|
|
116
|
+
display: block;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.part-line-v {
|
|
120
|
+
font-size: var(--main-font-size, 22px);
|
|
121
|
+
line-height: 2.4;
|
|
122
|
+
letter-spacing: 6px;
|
|
123
|
+
color: var(--ink);
|
|
124
|
+
display: block;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
:deep(.ann-target) {
|
|
128
|
+
border-bottom: 2px solid var(--vermillion);
|
|
129
|
+
cursor: help;
|
|
130
|
+
transition: background 0.15s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
:deep(.ann-target:hover) {
|
|
134
|
+
background: rgba(194, 58, 43, 0.08);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
:deep(.ann-num) {
|
|
138
|
+
font-size: 10px;
|
|
139
|
+
color: var(--vermillion);
|
|
140
|
+
font-family: var(--sans);
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
vertical-align: super;
|
|
143
|
+
margin-right: 1px;
|
|
144
|
+
letter-spacing: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
:deep(.ann-target.pronunciation) {
|
|
148
|
+
border-bottom-color: var(--jade);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
:deep(.ann-target.pronunciation:hover) {
|
|
152
|
+
background: rgba(58, 107, 94, 0.08);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.part-block--vertical :deep(.ann-target) {
|
|
156
|
+
border-bottom: none;
|
|
157
|
+
border-left: 2px solid var(--vermillion);
|
|
158
|
+
padding-left: 2px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.part-block--vertical :deep(.ann-target.pronunciation) {
|
|
162
|
+
border-left-color: var(--jade);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.part-block--vertical :deep(.ann-num) {
|
|
166
|
+
font-size: 0.45em;
|
|
167
|
+
text-combine-upright: all;
|
|
168
|
+
text-align: end;
|
|
169
|
+
letter-spacing: 0;
|
|
170
|
+
vertical-align: baseline;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.part-block--vertical .part-source {
|
|
174
|
+
margin-bottom: 0;
|
|
175
|
+
margin-left: 8px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.part-block--vertical .part-annotations {
|
|
179
|
+
margin-top: 0;
|
|
180
|
+
margin-left: 12px;
|
|
181
|
+
padding-top: 0;
|
|
182
|
+
padding-left: 12px;
|
|
183
|
+
border-top: none;
|
|
184
|
+
border-left: 1px dashed var(--border-light);
|
|
185
|
+
}
|
|
186
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Annotation, Part } from '../types'
|
|
3
|
+
import PartBlock from './PartBlock.vue'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
label: string
|
|
7
|
+
parts: Part[]
|
|
8
|
+
vertical?: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
annotationHover: [event: MouseEvent, annotations: Annotation[]]
|
|
13
|
+
annotationLeave: []
|
|
14
|
+
annotationTap: [event: MouseEvent, annotations: Annotation[]]
|
|
15
|
+
}>()
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="part-group" :class="{ 'part-group--vertical': vertical }">
|
|
20
|
+
<div class="part-group-label">{{ label }}</div>
|
|
21
|
+
<PartBlock
|
|
22
|
+
v-for="part in parts"
|
|
23
|
+
:key="part.num"
|
|
24
|
+
:num="part.num"
|
|
25
|
+
:verses="part.verses"
|
|
26
|
+
:annotations="part.annotations"
|
|
27
|
+
:annotation-text="part.annotationText"
|
|
28
|
+
:vertical="vertical"
|
|
29
|
+
:source="part.source"
|
|
30
|
+
@annotation-hover="(e, a) => emit('annotationHover', e, a)"
|
|
31
|
+
@annotation-leave="emit('annotationLeave')"
|
|
32
|
+
@annotation-tap="(e, a) => emit('annotationTap', e, a)"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<style scoped>
|
|
38
|
+
.part-group {
|
|
39
|
+
margin-bottom: 40px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.part-group:last-child {
|
|
43
|
+
margin-bottom: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.part-group-label {
|
|
47
|
+
font-size: 28px;
|
|
48
|
+
font-weight: 900;
|
|
49
|
+
letter-spacing: 6px;
|
|
50
|
+
color: var(--ink);
|
|
51
|
+
padding-bottom: 12px;
|
|
52
|
+
margin-bottom: 8px;
|
|
53
|
+
border-bottom: 3px solid var(--vermillion);
|
|
54
|
+
display: inline-block;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.part-group--vertical {
|
|
58
|
+
writing-mode: vertical-rl;
|
|
59
|
+
text-orientation: mixed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.part-group--vertical .part-group-label {
|
|
63
|
+
font-size: 28px;
|
|
64
|
+
letter-spacing: 10px;
|
|
65
|
+
border-bottom: none;
|
|
66
|
+
border-left: 3px solid var(--vermillion);
|
|
67
|
+
padding-bottom: 0;
|
|
68
|
+
padding-left: 16px;
|
|
69
|
+
margin-bottom: 0;
|
|
70
|
+
margin-left: 12px;
|
|
71
|
+
display: block;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -9,7 +9,7 @@ const props = defineProps<{
|
|
|
9
9
|
defineEmits<{ click: [] }>()
|
|
10
10
|
|
|
11
11
|
const preview = computed(() => {
|
|
12
|
-
const max =
|
|
12
|
+
const max = 2
|
|
13
13
|
return props.poem.verses.slice(0, max).map(v => v.text).join('\n')
|
|
14
14
|
})
|
|
15
15
|
</script>
|
|
@@ -33,7 +33,7 @@ const preview = computed(() => {
|
|
|
33
33
|
border: 1px solid var(--border-light);
|
|
34
34
|
border-radius: 8px;
|
|
35
35
|
cursor: pointer;
|
|
36
|
-
transition: all 0.
|
|
36
|
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
37
37
|
overflow: hidden;
|
|
38
38
|
}
|
|
39
39
|
.pc-accent {
|
|
@@ -41,12 +41,12 @@ const preview = computed(() => {
|
|
|
41
41
|
top: 0; left: 0;
|
|
42
42
|
width: 3px; height: 0;
|
|
43
43
|
background: var(--vermillion);
|
|
44
|
-
transition: height 0.
|
|
44
|
+
transition: height 0.3s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
45
45
|
}
|
|
46
46
|
.pc-root:hover {
|
|
47
|
-
transform: translateY(-
|
|
48
|
-
box-shadow: 0
|
|
49
|
-
border-color: var(--gold
|
|
47
|
+
transform: translateY(-2px);
|
|
48
|
+
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.08);
|
|
49
|
+
border-color: var(--gold);
|
|
50
50
|
}
|
|
51
51
|
.pc-root:hover .pc-accent { height: 100%; }
|
|
52
52
|
.pc-body { padding: 24px; }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
vertical?: boolean
|
|
6
|
+
scrollContainer?: HTMLElement | null
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const progress = ref(0)
|
|
10
|
+
let raf = 0
|
|
11
|
+
|
|
12
|
+
function updateProgress() {
|
|
13
|
+
if (props.vertical && props.scrollContainer) {
|
|
14
|
+
const el = props.scrollContainer
|
|
15
|
+
const max = el.scrollWidth - el.clientWidth
|
|
16
|
+
progress.value = max > 0 ? Math.min((el.scrollLeft / max) * 100, 100) : 0
|
|
17
|
+
} else if (!props.vertical) {
|
|
18
|
+
const max = document.documentElement.scrollHeight - window.innerHeight
|
|
19
|
+
progress.value = max > 0 ? Math.min((window.scrollY / max) * 100, 100) : 0
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onScroll() {
|
|
24
|
+
cancelAnimationFrame(raf)
|
|
25
|
+
raf = requestAnimationFrame(updateProgress)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function attach() {
|
|
29
|
+
if (props.vertical && props.scrollContainer) {
|
|
30
|
+
props.scrollContainer.addEventListener('scroll', onScroll, { passive: true })
|
|
31
|
+
} else if (!props.vertical) {
|
|
32
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
33
|
+
}
|
|
34
|
+
updateProgress()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function detach() {
|
|
38
|
+
if (props.vertical && props.scrollContainer) {
|
|
39
|
+
props.scrollContainer.removeEventListener('scroll', onScroll)
|
|
40
|
+
} else {
|
|
41
|
+
window.removeEventListener('scroll', onScroll)
|
|
42
|
+
}
|
|
43
|
+
cancelAnimationFrame(raf)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
watch(() => props.scrollContainer, (el, old) => {
|
|
47
|
+
detach()
|
|
48
|
+
if (old) old.removeEventListener('scroll', onScroll)
|
|
49
|
+
attach()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
onMounted(attach)
|
|
53
|
+
onUnmounted(detach)
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div
|
|
58
|
+
class="rp"
|
|
59
|
+
:class="{ 'rp-v': vertical }"
|
|
60
|
+
:style="vertical
|
|
61
|
+
? { height: progress + '%' }
|
|
62
|
+
: { width: progress + '%' }"
|
|
63
|
+
/>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style scoped>
|
|
67
|
+
.rp {
|
|
68
|
+
position: fixed;
|
|
69
|
+
z-index: 1001;
|
|
70
|
+
background: linear-gradient(90deg, var(--vermillion), var(--vermillion-light));
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
will-change: width, height;
|
|
73
|
+
}
|
|
74
|
+
.rp:not(.rp-v) {
|
|
75
|
+
top: 0; left: 0;
|
|
76
|
+
height: 2px;
|
|
77
|
+
}
|
|
78
|
+
.rp-v {
|
|
79
|
+
top: 0; left: 0;
|
|
80
|
+
width: 2px;
|
|
81
|
+
background: linear-gradient(180deg, var(--vermillion), var(--vermillion-light));
|
|
82
|
+
}
|
|
83
|
+
</style>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import { useReadingMode, THEMES, THEME_LABELS } from '../composables/useReadingMode'
|
|
4
|
-
import type { LayoutMode } from '../composables/useReadingMode'
|
|
3
|
+
import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables/useReadingMode'
|
|
4
|
+
import type { LayoutMode, FontSize } from '../composables/useReadingMode'
|
|
5
5
|
import { useI18n, LOCALE_LABELS, type Locale } from '../composables/useI18n'
|
|
6
6
|
|
|
7
|
-
const { theme, layout, setTheme, setLayout } = useReadingMode()
|
|
7
|
+
const { theme, layout, mainFontSize, bodyFontSize, setTheme, setLayout, setMainFontSize, setBodyFontSize } = useReadingMode()
|
|
8
8
|
const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
|
|
9
9
|
const open = ref(false)
|
|
10
10
|
|
|
@@ -46,6 +46,22 @@ function close() { open.value = false }
|
|
|
46
46
|
>{{ THEME_LABELS[t] }}</button>
|
|
47
47
|
</div>
|
|
48
48
|
</div>
|
|
49
|
+
<div class="rt-group">
|
|
50
|
+
<div class="rt-label">{{ t('settings.mainFontSize') }}</div>
|
|
51
|
+
<div class="rt-size-row">
|
|
52
|
+
<button class="rt-size-btn" @click="setMainFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(mainFontSize) - 1)] as FontSize)">−</button>
|
|
53
|
+
<span class="rt-size-val">{{ mainFontSize }}</span>
|
|
54
|
+
<button class="rt-size-btn" @click="setMainFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(mainFontSize) + 1)] as FontSize)">+</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="rt-group">
|
|
58
|
+
<div class="rt-label">{{ t('settings.bodyFontSize') }}</div>
|
|
59
|
+
<div class="rt-size-row">
|
|
60
|
+
<button class="rt-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(bodyFontSize) - 1)] as FontSize)">−</button>
|
|
61
|
+
<span class="rt-size-val">{{ bodyFontSize }}</span>
|
|
62
|
+
<button class="rt-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(bodyFontSize) + 1)] as FontSize)">+</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
49
65
|
<div class="rt-group">
|
|
50
66
|
<div class="rt-label">{{ t('settings.language') }}</div>
|
|
51
67
|
<div class="rt-options">
|
|
@@ -58,6 +74,11 @@ function close() { open.value = false }
|
|
|
58
74
|
>{{ localeLabels[loc] }}</button>
|
|
59
75
|
</div>
|
|
60
76
|
</div>
|
|
77
|
+
<div class="rt-shortcuts">
|
|
78
|
+
<div class="rt-sc"><kbd>V</kbd> 直/橫</div>
|
|
79
|
+
<div class="rt-sc"><kbd>T</kbd> 主題</div>
|
|
80
|
+
<div class="rt-sc"><kbd>Esc</kbd> 首頁</div>
|
|
81
|
+
</div>
|
|
61
82
|
</div>
|
|
62
83
|
<div v-if="open" class="rt-backdrop" @click="close" />
|
|
63
84
|
</div>
|
|
@@ -79,13 +100,14 @@ function close() { open.value = false }
|
|
|
79
100
|
font-size: 16px;
|
|
80
101
|
cursor: pointer;
|
|
81
102
|
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.12);
|
|
82
|
-
transition: all 0.
|
|
103
|
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
83
104
|
display: flex; align-items: center; justify-content: center;
|
|
84
105
|
}
|
|
85
106
|
.rt-fab:hover {
|
|
86
107
|
background: var(--ink);
|
|
87
108
|
color: var(--paper);
|
|
88
109
|
border-color: var(--ink);
|
|
110
|
+
transform: scale(1.05);
|
|
89
111
|
}
|
|
90
112
|
.rt-icon {
|
|
91
113
|
font-family: var(--sans);
|
|
@@ -102,7 +124,7 @@ function close() { open.value = false }
|
|
|
102
124
|
border-radius: 8px;
|
|
103
125
|
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.16);
|
|
104
126
|
padding: 16px;
|
|
105
|
-
animation: slideUp 0.
|
|
127
|
+
animation: slideUp 0.25s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
106
128
|
}
|
|
107
129
|
@keyframes slideUp {
|
|
108
130
|
from { opacity: 0; transform: translateY(8px); }
|
|
@@ -138,8 +160,62 @@ function close() { open.value = false }
|
|
|
138
160
|
color: var(--paper);
|
|
139
161
|
border-color: var(--ink);
|
|
140
162
|
}
|
|
163
|
+
.rt-size-row {
|
|
164
|
+
display: flex; align-items: center; gap: 6px; justify-content: center;
|
|
165
|
+
}
|
|
166
|
+
.rt-size-btn {
|
|
167
|
+
width: 28px; height: 28px;
|
|
168
|
+
border: 1px solid var(--border);
|
|
169
|
+
border-radius: 4px;
|
|
170
|
+
background: none;
|
|
171
|
+
font-family: var(--sans);
|
|
172
|
+
font-size: 14px;
|
|
173
|
+
color: var(--ink-mid);
|
|
174
|
+
cursor: pointer;
|
|
175
|
+
display: flex; align-items: center; justify-content: center;
|
|
176
|
+
transition: all 0.15s;
|
|
177
|
+
}
|
|
178
|
+
.rt-size-btn:hover { border-color: var(--ink); color: var(--ink); }
|
|
179
|
+
.rt-size-val {
|
|
180
|
+
font-family: var(--sans);
|
|
181
|
+
font-size: 13px;
|
|
182
|
+
color: var(--ink);
|
|
183
|
+
min-width: 32px;
|
|
184
|
+
text-align: center;
|
|
185
|
+
}
|
|
141
186
|
.rt-backdrop {
|
|
142
187
|
position: fixed; inset: 0;
|
|
143
188
|
z-index: -1;
|
|
144
189
|
}
|
|
190
|
+
.rt-shortcuts {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-wrap: wrap;
|
|
193
|
+
gap: 6px;
|
|
194
|
+
padding-top: 10px;
|
|
195
|
+
border-top: 1px solid var(--border-light);
|
|
196
|
+
margin-top: 2px;
|
|
197
|
+
}
|
|
198
|
+
.rt-sc {
|
|
199
|
+
font-family: var(--sans);
|
|
200
|
+
font-size: 10px;
|
|
201
|
+
color: var(--ink-faint);
|
|
202
|
+
letter-spacing: 1px;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: 3px;
|
|
206
|
+
}
|
|
207
|
+
.rt-sc kbd {
|
|
208
|
+
display: inline-flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: center;
|
|
211
|
+
min-width: 18px;
|
|
212
|
+
height: 16px;
|
|
213
|
+
padding: 0 3px;
|
|
214
|
+
border: 1px solid var(--border);
|
|
215
|
+
border-radius: 2px;
|
|
216
|
+
font-family: var(--sans);
|
|
217
|
+
font-size: 9px;
|
|
218
|
+
color: var(--ink-light);
|
|
219
|
+
background: var(--surface);
|
|
220
|
+
}
|
|
145
221
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
2
|
+
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
3
3
|
import { parseAnnotationBlock } from '../utils/annotationParser'
|
|
4
4
|
import PronunciationGroup from './PronunciationGroup.vue'
|
|
5
5
|
|
|
@@ -12,6 +12,27 @@ const props = defineProps<{
|
|
|
12
12
|
vertical?: boolean
|
|
13
13
|
}>()
|
|
14
14
|
|
|
15
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
16
|
+
const visible = ref(false)
|
|
17
|
+
|
|
18
|
+
onMounted(() => {
|
|
19
|
+
if (props.vertical || !rootRef.value) {
|
|
20
|
+
visible.value = true
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const observer = new IntersectionObserver(
|
|
24
|
+
([entry]) => {
|
|
25
|
+
if (entry.isIntersecting) {
|
|
26
|
+
visible.value = true
|
|
27
|
+
observer.disconnect()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{ rootMargin: '0px 0px -40px 0px', threshold: 0 }
|
|
31
|
+
)
|
|
32
|
+
observer.observe(rootRef.value)
|
|
33
|
+
onUnmounted(() => observer.disconnect())
|
|
34
|
+
})
|
|
35
|
+
|
|
15
36
|
function esc(str: string): string {
|
|
16
37
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
17
38
|
}
|
|
@@ -40,7 +61,7 @@ const paragraphsHtml = computed(() => {
|
|
|
40
61
|
</script>
|
|
41
62
|
|
|
42
63
|
<template>
|
|
43
|
-
<div v-if="text" class="sb-root" :class="{ 'sb-vertical': vertical }">
|
|
64
|
+
<div v-if="text" ref="rootRef" class="sb-root" :class="{ 'sb-vertical': vertical, 'sb-visible': visible }">
|
|
44
65
|
<div class="sb-header">
|
|
45
66
|
<span v-if="displayNum" class="sb-num" :class="{ special }">{{ displayNum }}</span>
|
|
46
67
|
<h3>{{ special ? '【' + label + '】' : label }}</h3>
|
|
@@ -64,11 +85,13 @@ const paragraphsHtml = computed(() => {
|
|
|
64
85
|
<style scoped>
|
|
65
86
|
.sb-root {
|
|
66
87
|
margin-bottom: 40px;
|
|
67
|
-
|
|
88
|
+
opacity: 0;
|
|
89
|
+
transform: translateY(12px);
|
|
90
|
+
transition: opacity 0.5s ease, transform 0.5s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
68
91
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
.sb-root.sb-visible {
|
|
93
|
+
opacity: 1;
|
|
94
|
+
transform: translateY(0);
|
|
72
95
|
}
|
|
73
96
|
.sb-header {
|
|
74
97
|
display: flex; align-items: center; gap: 12px;
|
|
@@ -107,7 +130,9 @@ const paragraphsHtml = computed(() => {
|
|
|
107
130
|
border-right: 1px solid var(--border);
|
|
108
131
|
overflow-x: auto;
|
|
109
132
|
overflow-y: hidden;
|
|
110
|
-
|
|
133
|
+
opacity: 1;
|
|
134
|
+
transform: none;
|
|
135
|
+
transition: none;
|
|
111
136
|
}
|
|
112
137
|
.sb-vertical .sb-header {
|
|
113
138
|
flex-direction: column;
|
|
@@ -113,6 +113,11 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
113
113
|
>{{ localeLabels[loc] }}</button>
|
|
114
114
|
</div>
|
|
115
115
|
</div>
|
|
116
|
+
<div class="ss-shortcuts">
|
|
117
|
+
<span class="ss-sc"><kbd>V</kbd> 直/橫</span>
|
|
118
|
+
<span class="ss-sc"><kbd>T</kbd> 主題</span>
|
|
119
|
+
<span class="ss-sc"><kbd>Esc</kbd> 首頁</span>
|
|
120
|
+
</div>
|
|
116
121
|
</div>
|
|
117
122
|
</Transition>
|
|
118
123
|
|
|
@@ -308,6 +313,37 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
308
313
|
position: fixed; inset: 0;
|
|
309
314
|
z-index: -1;
|
|
310
315
|
}
|
|
316
|
+
.ss-shortcuts {
|
|
317
|
+
display: flex;
|
|
318
|
+
flex-wrap: wrap;
|
|
319
|
+
gap: 4px 8px;
|
|
320
|
+
padding-top: 10px;
|
|
321
|
+
border-top: 1px solid var(--border-light);
|
|
322
|
+
margin-top: 2px;
|
|
323
|
+
}
|
|
324
|
+
.ss-sc {
|
|
325
|
+
font-family: var(--sans);
|
|
326
|
+
font-size: 10px;
|
|
327
|
+
color: var(--ink-faint);
|
|
328
|
+
letter-spacing: 1px;
|
|
329
|
+
display: inline-flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
gap: 3px;
|
|
332
|
+
}
|
|
333
|
+
.ss-sc kbd {
|
|
334
|
+
display: inline-flex;
|
|
335
|
+
align-items: center;
|
|
336
|
+
justify-content: center;
|
|
337
|
+
min-width: 18px;
|
|
338
|
+
height: 16px;
|
|
339
|
+
padding: 0 3px;
|
|
340
|
+
border: 1px solid var(--border);
|
|
341
|
+
border-radius: 2px;
|
|
342
|
+
font-family: var(--sans);
|
|
343
|
+
font-size: 9px;
|
|
344
|
+
color: var(--ink-light);
|
|
345
|
+
background: var(--surface);
|
|
346
|
+
}
|
|
311
347
|
|
|
312
348
|
@media (max-width: 768px) {
|
|
313
349
|
.sidenav { width: 44px; padding: 8px 0; gap: 6px; }
|
package/template/src/main.ts
CHANGED
|
@@ -17,6 +17,10 @@ export async function createApp() {
|
|
|
17
17
|
if (!import.meta.env.SSR) {
|
|
18
18
|
createApp().then(({ app, router }) => {
|
|
19
19
|
app.mount('#app')
|
|
20
|
-
document.getElementById('app-loading')
|
|
20
|
+
const loader = document.getElementById('app-loading')
|
|
21
|
+
if (loader) {
|
|
22
|
+
loader.classList.add('fade-out')
|
|
23
|
+
loader.addEventListener('transitionend', () => loader.remove())
|
|
24
|
+
}
|
|
21
25
|
})
|
|
22
26
|
}
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
--serif: 'Noto Serif TC', '宋體-繁', serif;
|
|
90
90
|
--sans: 'Noto Sans TC', 'PingFang TC', sans-serif;
|
|
91
91
|
--nav-width: 56px;
|
|
92
|
+
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
92
93
|
}
|
|
93
94
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
94
95
|
html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
|
|
@@ -103,6 +104,7 @@ body {
|
|
|
103
104
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
104
105
|
::-webkit-scrollbar-track { background: transparent; }
|
|
105
106
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
107
|
+
::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
|
|
106
108
|
|
|
107
109
|
a { color: inherit; text-decoration: none; }
|
|
108
110
|
|
|
@@ -113,21 +115,56 @@ a { color: inherit; text-decoration: none; }
|
|
|
113
115
|
display: flex; flex-direction: column;
|
|
114
116
|
align-items: center; justify-content: center;
|
|
115
117
|
z-index: 9999;
|
|
118
|
+
transition: opacity 0.4s ease;
|
|
119
|
+
}
|
|
120
|
+
#app-loading.fade-out {
|
|
121
|
+
opacity: 0;
|
|
122
|
+
pointer-events: none;
|
|
123
|
+
}
|
|
124
|
+
#app-loading .seal {
|
|
125
|
+
width: 72px; height: 72px;
|
|
126
|
+
border: 2px solid var(--vermillion);
|
|
127
|
+
border-radius: 4px;
|
|
128
|
+
display: flex; align-items: center; justify-content: center;
|
|
129
|
+
animation: sealReveal 0.8s var(--ease-out-expo) forwards;
|
|
130
|
+
opacity: 0;
|
|
116
131
|
}
|
|
117
132
|
#app-loading .char {
|
|
118
133
|
font-family: var(--serif);
|
|
119
|
-
font-size:
|
|
120
|
-
color: var(--
|
|
121
|
-
|
|
134
|
+
font-size: 36px; font-weight: 900;
|
|
135
|
+
color: var(--vermillion);
|
|
136
|
+
line-height: 1;
|
|
137
|
+
}
|
|
138
|
+
#app-loading .line {
|
|
139
|
+
width: 1px; height: 40px;
|
|
140
|
+
background: linear-gradient(180deg, var(--vermillion), transparent);
|
|
141
|
+
margin-top: 24px;
|
|
142
|
+
animation: lineGrow 0.6s 0.3s var(--ease-out-expo) forwards;
|
|
143
|
+
transform: scaleY(0);
|
|
144
|
+
transform-origin: top;
|
|
122
145
|
}
|
|
123
146
|
#app-loading .text {
|
|
124
147
|
font-family: var(--sans);
|
|
125
|
-
font-size:
|
|
126
|
-
letter-spacing:
|
|
148
|
+
font-size: 11px; color: var(--ink-faint);
|
|
149
|
+
letter-spacing: 6px; margin-top: 16px;
|
|
150
|
+
animation: fadeIn 0.5s 0.5s ease forwards;
|
|
151
|
+
opacity: 0;
|
|
127
152
|
}
|
|
128
|
-
@keyframes
|
|
129
|
-
|
|
130
|
-
|
|
153
|
+
@keyframes sealReveal {
|
|
154
|
+
from { opacity: 0; transform: scale(0.8); }
|
|
155
|
+
to { opacity: 1; transform: scale(1); }
|
|
156
|
+
}
|
|
157
|
+
@keyframes lineGrow {
|
|
158
|
+
to { transform: scaleY(1); }
|
|
159
|
+
}
|
|
160
|
+
@keyframes fadeIn {
|
|
161
|
+
to { opacity: 1; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ===== CONTENT ENTRANCE ANIMATION ===== */
|
|
165
|
+
@keyframes enterUp {
|
|
166
|
+
from { opacity: 0; transform: translateY(16px); }
|
|
167
|
+
to { opacity: 1; transform: translateY(0); }
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
/* ===== RESPONSIVE ===== */
|
package/template/src/types.ts
CHANGED
|
@@ -108,11 +108,12 @@ export interface PieceContributor {
|
|
|
108
108
|
title?: string
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export interface PieceSource {
|
|
111
|
+
export interface PieceSource {
|
|
112
|
+
text?: string
|
|
112
113
|
textRef?: string
|
|
113
114
|
pieceRef?: number
|
|
114
115
|
relation: 'section' | 'excerpt' | 'standalone'
|
|
115
|
-
range?: { start
|
|
116
|
+
range?: { start?: string; end?: string; chapter?: string; [key: string]: string | undefined }
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export interface ProseSection {
|
|
@@ -123,6 +124,16 @@ export interface ProseSection {
|
|
|
123
124
|
order: number
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
export interface Part {
|
|
128
|
+
num: number
|
|
129
|
+
group?: string
|
|
130
|
+
title?: string
|
|
131
|
+
source?: PieceSource
|
|
132
|
+
verses: VerseLine[]
|
|
133
|
+
annotations: Annotation[]
|
|
134
|
+
annotationText?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
export interface Piece {
|
|
127
138
|
bookId: string
|
|
128
139
|
num: number
|
|
@@ -139,6 +150,7 @@ export interface Piece {
|
|
|
139
150
|
annotationLayers?: AnnotationLayer[]
|
|
140
151
|
source?: PieceSource
|
|
141
152
|
contributors?: PieceContributor[]
|
|
153
|
+
parts?: Part[]
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
// Backward compatibility alias
|
|
@@ -80,6 +80,7 @@ function goHome() { router.push('/') }
|
|
|
80
80
|
background: var(--paper);
|
|
81
81
|
scrollbar-width: thin;
|
|
82
82
|
scrollbar-color: var(--gold) transparent;
|
|
83
|
+
scroll-snap-type: x proximity;
|
|
83
84
|
}
|
|
84
85
|
.v-page::-webkit-scrollbar { height: 4px; }
|
|
85
86
|
.v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
@@ -161,6 +162,11 @@ function goHome() { router.push('/') }
|
|
|
161
162
|
background: var(--surface);
|
|
162
163
|
border: 1px solid var(--border-light);
|
|
163
164
|
border-radius: 8px;
|
|
165
|
+
animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
|
|
166
|
+
}
|
|
167
|
+
@keyframes cardEnter {
|
|
168
|
+
from { opacity: 0; transform: translateY(12px); }
|
|
169
|
+
to { opacity: 1; transform: translateY(0); }
|
|
164
170
|
}
|
|
165
171
|
.h-about-block:last-child { margin-bottom: 0; }
|
|
166
172
|
.h-about-block h2 {
|
|
@@ -154,6 +154,7 @@ function scrollToCatalog() {
|
|
|
154
154
|
background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
|
|
155
155
|
scrollbar-width: thin;
|
|
156
156
|
scrollbar-color: var(--gold) transparent;
|
|
157
|
+
scroll-snap-type: x proximity;
|
|
157
158
|
}
|
|
158
159
|
.v-page::-webkit-scrollbar { height: 4px; }
|
|
159
160
|
.v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
@@ -80,6 +80,9 @@ function openBook(bookId: string) {
|
|
|
80
80
|
<div v-if="isVertical" class="v-root">
|
|
81
81
|
<SideNav @home="router.push('/')" @back="router.push('/')" />
|
|
82
82
|
<div ref="vPageRef" class="v-page">
|
|
83
|
+
<div class="v-about-col">
|
|
84
|
+
<router-link to="/about" class="v-about-link">關 於</router-link>
|
|
85
|
+
</div>
|
|
83
86
|
<section class="v-hero">
|
|
84
87
|
<h1 class="v-title">古 典 詩 文 圖 書 館</h1>
|
|
85
88
|
<p class="v-subtitle">Classical Chinese Text Library</p>
|
|
@@ -122,9 +125,10 @@ function openBook(bookId: string) {
|
|
|
122
125
|
<h2 class="lib-group-title">{{ group.category }}</h2>
|
|
123
126
|
<div class="lib-grid">
|
|
124
127
|
<div
|
|
125
|
-
v-for="book in group.books"
|
|
128
|
+
v-for="(book, bi) in group.books"
|
|
126
129
|
:key="book.id"
|
|
127
130
|
class="lib-card"
|
|
131
|
+
:style="{ animationDelay: bi * 0.06 + 's' }"
|
|
128
132
|
@click="openBook(book.id)"
|
|
129
133
|
>
|
|
130
134
|
<div class="lib-card-accent"></div>
|
|
@@ -160,6 +164,7 @@ function openBook(bookId: string) {
|
|
|
160
164
|
background: linear-gradient(90deg, var(--paper) 0%, var(--paper-warm) 100%);
|
|
161
165
|
scrollbar-width: thin;
|
|
162
166
|
scrollbar-color: var(--gold) transparent;
|
|
167
|
+
scroll-snap-type: x proximity;
|
|
163
168
|
}
|
|
164
169
|
.v-page::-webkit-scrollbar { height: 4px; }
|
|
165
170
|
.v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
@@ -175,6 +180,31 @@ function openBook(bookId: string) {
|
|
|
175
180
|
justify-content: center;
|
|
176
181
|
padding: 40px 20px;
|
|
177
182
|
}
|
|
183
|
+
.v-about-col {
|
|
184
|
+
writing-mode: vertical-rl;
|
|
185
|
+
text-orientation: mixed;
|
|
186
|
+
flex-shrink: 0;
|
|
187
|
+
height: 100vh;
|
|
188
|
+
display: flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
justify-content: center;
|
|
191
|
+
padding: 0 12px;
|
|
192
|
+
border-right: 1px solid var(--border-light);
|
|
193
|
+
}
|
|
194
|
+
.v-about-link {
|
|
195
|
+
font-size: 14px;
|
|
196
|
+
color: var(--ink-faint);
|
|
197
|
+
letter-spacing: 6px;
|
|
198
|
+
font-family: var(--sans);
|
|
199
|
+
padding: 12px 8px;
|
|
200
|
+
border: 1px solid var(--border-light);
|
|
201
|
+
border-radius: 2px;
|
|
202
|
+
transition: all 0.2s;
|
|
203
|
+
}
|
|
204
|
+
.v-about-link:hover {
|
|
205
|
+
color: var(--ink);
|
|
206
|
+
border-color: var(--ink);
|
|
207
|
+
}
|
|
178
208
|
.v-title {
|
|
179
209
|
font-size: 48px; font-weight: 900;
|
|
180
210
|
letter-spacing: 16px; color: var(--ink);
|
|
@@ -363,9 +393,14 @@ function openBook(bookId: string) {
|
|
|
363
393
|
border: 1px solid var(--border-light);
|
|
364
394
|
border-radius: 8px;
|
|
365
395
|
cursor: pointer;
|
|
366
|
-
transition: all 0.3s ease;
|
|
396
|
+
transition: all 0.3s var(--ease-out-expo, ease);
|
|
367
397
|
position: relative;
|
|
368
398
|
background: var(--surface);
|
|
399
|
+
animation: cardEnter 0.5s var(--ease-out-expo, ease) both;
|
|
400
|
+
}
|
|
401
|
+
@keyframes cardEnter {
|
|
402
|
+
from { opacity: 0; transform: translateY(12px); }
|
|
403
|
+
to { opacity: 1; transform: translateY(0); }
|
|
369
404
|
}
|
|
370
405
|
.lib-card:hover { border-color: var(--gold); box-shadow: 0 4px 20px rgba(var(--shadow-rgb), 0.1); }
|
|
371
406
|
.lib-card-accent {
|
|
@@ -13,7 +13,9 @@ import SectionBlock from '../components/SectionBlock.vue'
|
|
|
13
13
|
import AnnotationTooltip from '../components/AnnotationTooltip.vue'
|
|
14
14
|
import AnnotationControlBar from '../components/AnnotationControlBar.vue'
|
|
15
15
|
import SideNav from '../components/SideNav.vue'
|
|
16
|
-
import
|
|
16
|
+
import PartGroup from '../components/PartGroup.vue'
|
|
17
|
+
import ReadingProgress from '../components/ReadingProgress.vue'
|
|
18
|
+
import type { Piece, Annotation, AnnotationLayer, Part } from '../types'
|
|
17
19
|
|
|
18
20
|
const props = defineProps<{ bookId: string; num: string | number }>()
|
|
19
21
|
const router = useRouter()
|
|
@@ -58,6 +60,17 @@ useTitle(pageTitle.value)
|
|
|
58
60
|
|
|
59
61
|
const isVertical = computed(() => layout.value === 'vertical')
|
|
60
62
|
|
|
63
|
+
const totalAnnotationCount = computed(() => {
|
|
64
|
+
if (!piece.value) return 0
|
|
65
|
+
let count = piece.value.annotations.length
|
|
66
|
+
if (piece.value.annotationLayers) {
|
|
67
|
+
for (const layer of piece.value.annotationLayers) {
|
|
68
|
+
count += layer.annotations.length
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return count
|
|
72
|
+
})
|
|
73
|
+
|
|
61
74
|
const annotationLayers = computed<AnnotationLayer[]>(() => piece.value?.annotationLayers || [])
|
|
62
75
|
const hasLayers = computed(() => annotationLayers.value.length > 1)
|
|
63
76
|
const activeLayerIds = ref<string[]>([])
|
|
@@ -128,6 +141,29 @@ function getHeadword(ann: Annotation): string {
|
|
|
128
141
|
// Initialize layers when piece loads
|
|
129
142
|
watch(() => piece.value, () => initLayers(), { immediate: true })
|
|
130
143
|
|
|
144
|
+
// ─── Multi-part ───────────────────────────────────────────────
|
|
145
|
+
const isMultiPart = computed(() => (piece.value?.parts?.length ?? 0) > 0)
|
|
146
|
+
|
|
147
|
+
const partGroups = computed<{ label: string; parts: Part[] }[]>(() => {
|
|
148
|
+
if (!piece.value?.parts?.length) return []
|
|
149
|
+
const groupMap = new Map<string, Part[]>()
|
|
150
|
+
for (const part of piece.value.parts) {
|
|
151
|
+
const key = part.group || ''
|
|
152
|
+
if (!groupMap.has(key)) groupMap.set(key, [])
|
|
153
|
+
groupMap.get(key)!.push(part)
|
|
154
|
+
}
|
|
155
|
+
return [...groupMap.entries()].map(([label, parts]) => ({ label, parts }))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const allPartAnnotations = computed<Annotation[]>(() => {
|
|
159
|
+
if (!piece.value?.parts) return []
|
|
160
|
+
return piece.value.parts.flatMap(p => p.annotations)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const totalPartAnnotationCount = computed(() => {
|
|
164
|
+
return piece.value?.parts?.reduce((sum, p) => sum + p.annotations.length, 0) ?? 0
|
|
165
|
+
})
|
|
166
|
+
|
|
131
167
|
const SECTION_META: Record<string, { label: string; special: boolean }> = {
|
|
132
168
|
background: { label: '背景資料', special: false },
|
|
133
169
|
analysis: { label: '賞析重點', special: false },
|
|
@@ -218,6 +254,7 @@ function tcy(n: number): string {
|
|
|
218
254
|
@back="goBack"
|
|
219
255
|
@home="goHome"
|
|
220
256
|
/>
|
|
257
|
+
<ReadingProgress vertical :scroll-container="vPageRef" />
|
|
221
258
|
<div ref="vPageRef" class="v-page">
|
|
222
259
|
<section ref="vTitleRef" class="v-title-col">
|
|
223
260
|
<h1 class="v-poem-title">{{ piece.title }}</h1>
|
|
@@ -232,12 +269,31 @@ function tcy(n: number): string {
|
|
|
232
269
|
← {{ meta?.title }}
|
|
233
270
|
</div>
|
|
234
271
|
<div class="v-poem-meta">
|
|
235
|
-
<
|
|
236
|
-
|
|
272
|
+
<template v-if="isMultiPart">
|
|
273
|
+
<span class="v-meta-item" v-html="tcy(piece.parts!.length) + ' 段'" />
|
|
274
|
+
<span class="v-meta-item" v-html="totalPartAnnotationCount > 0 ? tcy(totalPartAnnotationCount) + ' 注' : '無注'" />
|
|
275
|
+
</template>
|
|
276
|
+
<template v-else>
|
|
277
|
+
<span class="v-meta-item" v-html="tcy(piece.verses.length) + ' 段'" />
|
|
278
|
+
<span class="v-meta-item" v-html="totalAnnotationCount > 0 ? tcy(totalAnnotationCount) + ' 注' : '無注'" />
|
|
279
|
+
</template>
|
|
237
280
|
</div>
|
|
238
281
|
</section>
|
|
239
282
|
|
|
240
|
-
<section class="v-poem-col">
|
|
283
|
+
<section v-if="isMultiPart" class="v-poem-col v-multipart">
|
|
284
|
+
<PartGroup
|
|
285
|
+
v-for="group in partGroups"
|
|
286
|
+
:key="group.label"
|
|
287
|
+
:label="group.label"
|
|
288
|
+
:parts="group.parts"
|
|
289
|
+
:vertical="true"
|
|
290
|
+
@annotation-hover="interaction.onHover"
|
|
291
|
+
@annotation-leave="interaction.onLeave"
|
|
292
|
+
@annotation-tap="interaction.onTap"
|
|
293
|
+
/>
|
|
294
|
+
</section>
|
|
295
|
+
|
|
296
|
+
<section v-else class="v-poem-col">
|
|
241
297
|
<VerticalScroll
|
|
242
298
|
:title="''"
|
|
243
299
|
:author="''"
|
|
@@ -252,7 +308,7 @@ function tcy(n: number): string {
|
|
|
252
308
|
</section>
|
|
253
309
|
|
|
254
310
|
<SectionBlock
|
|
255
|
-
v-if="annotationsVisible && piece.sections.annotations"
|
|
311
|
+
v-if="!isMultiPart && annotationsVisible && piece.sections.annotations"
|
|
256
312
|
num=""
|
|
257
313
|
label="注釋"
|
|
258
314
|
:special="false"
|
|
@@ -349,6 +405,7 @@ function tcy(n: number): string {
|
|
|
349
405
|
|
|
350
406
|
<!-- ═══════ 橫排模式 ═══════ -->
|
|
351
407
|
<div v-else class="h-root">
|
|
408
|
+
<ReadingProgress />
|
|
352
409
|
<div class="h-page">
|
|
353
410
|
<nav class="h-nav">
|
|
354
411
|
<div class="h-nav-inner">
|
|
@@ -370,14 +427,32 @@ function tcy(n: number): string {
|
|
|
370
427
|
<span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
|
|
371
428
|
</div>
|
|
372
429
|
<div class="h-controls">
|
|
373
|
-
<
|
|
374
|
-
|
|
430
|
+
<template v-if="isMultiPart">
|
|
431
|
+
<span class="h-tag">{{ piece.parts!.length }} 段</span>
|
|
432
|
+
<span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' 注' : '無注' }}</span>
|
|
433
|
+
</template>
|
|
434
|
+
<template v-else>
|
|
435
|
+
<span class="h-tag">{{ piece.verses.length }} 段</span>
|
|
436
|
+
<span class="h-tag">{{ totalAnnotationCount > 0 ? totalAnnotationCount + ' 注' : '無注' }}</span>
|
|
437
|
+
</template>
|
|
375
438
|
</div>
|
|
376
439
|
</div>
|
|
377
440
|
</nav>
|
|
378
441
|
|
|
379
442
|
<div class="h-content">
|
|
380
|
-
<div class="h-
|
|
443
|
+
<div v-if="isMultiPart" class="h-multipart">
|
|
444
|
+
<PartGroup
|
|
445
|
+
v-for="group in partGroups"
|
|
446
|
+
:key="group.label"
|
|
447
|
+
:label="group.label"
|
|
448
|
+
:parts="group.parts"
|
|
449
|
+
@annotation-hover="interaction.onHover"
|
|
450
|
+
@annotation-leave="interaction.onLeave"
|
|
451
|
+
@annotation-tap="interaction.onTap"
|
|
452
|
+
/>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
<div v-else class="h-poem-block">
|
|
381
456
|
<HorizontalDisplay
|
|
382
457
|
:title="piece.title"
|
|
383
458
|
:author="piece.author"
|
|
@@ -476,8 +551,8 @@ function tcy(n: number): string {
|
|
|
476
551
|
</div>
|
|
477
552
|
</div>
|
|
478
553
|
|
|
479
|
-
<div v-else
|
|
480
|
-
<
|
|
554
|
+
<div v-else class="loading">
|
|
555
|
+
<div class="loading-seal">詩</div>
|
|
481
556
|
</div>
|
|
482
557
|
</template>
|
|
483
558
|
|
|
@@ -495,6 +570,7 @@ function tcy(n: number): string {
|
|
|
495
570
|
background: var(--paper);
|
|
496
571
|
scrollbar-width: thin;
|
|
497
572
|
scrollbar-color: var(--gold) transparent;
|
|
573
|
+
scroll-snap-type: x proximity;
|
|
498
574
|
}
|
|
499
575
|
.v-page::-webkit-scrollbar { height: 4px; }
|
|
500
576
|
.v-page::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
@@ -510,6 +586,7 @@ function tcy(n: number): string {
|
|
|
510
586
|
gap: 16px;
|
|
511
587
|
padding: 40px 24px;
|
|
512
588
|
border-right: 1px solid var(--border);
|
|
589
|
+
scroll-snap-align: start;
|
|
513
590
|
}
|
|
514
591
|
.v-poem-title {
|
|
515
592
|
font-size: 40px; font-weight: 900;
|
|
@@ -551,6 +628,22 @@ function tcy(n: number): string {
|
|
|
551
628
|
padding: 24px;
|
|
552
629
|
}
|
|
553
630
|
|
|
631
|
+
.v-multipart {
|
|
632
|
+
display: flex;
|
|
633
|
+
flex-direction: row-reverse;
|
|
634
|
+
align-items: flex-start;
|
|
635
|
+
gap: 0;
|
|
636
|
+
max-height: calc(100vh - 120px);
|
|
637
|
+
overflow-x: auto;
|
|
638
|
+
overflow-y: hidden;
|
|
639
|
+
padding: 24px 16px;
|
|
640
|
+
scrollbar-width: thin;
|
|
641
|
+
scrollbar-color: var(--gold) var(--paper);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
.v-multipart::-webkit-scrollbar { height: 4px; }
|
|
645
|
+
.v-multipart::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
646
|
+
|
|
554
647
|
.v-section {
|
|
555
648
|
flex-shrink: 0;
|
|
556
649
|
}
|
|
@@ -564,7 +657,7 @@ function tcy(n: number): string {
|
|
|
564
657
|
|
|
565
658
|
.v-source-link {
|
|
566
659
|
font-size: 12px;
|
|
567
|
-
color: var(--
|
|
660
|
+
color: var(--vermillion);
|
|
568
661
|
cursor: pointer;
|
|
569
662
|
margin-top: 4px;
|
|
570
663
|
opacity: 0.8;
|
|
@@ -582,6 +675,7 @@ function tcy(n: number): string {
|
|
|
582
675
|
justify-content: center;
|
|
583
676
|
padding: 24px 12px;
|
|
584
677
|
gap: 32px;
|
|
678
|
+
scroll-snap-align: start;
|
|
585
679
|
}
|
|
586
680
|
.v-nav-spacer { flex: 1; }
|
|
587
681
|
.v-nav-btn {
|
|
@@ -663,6 +757,16 @@ function tcy(n: number): string {
|
|
|
663
757
|
.h-poem-block {
|
|
664
758
|
margin-bottom: 60px; display: flex; justify-content: center;
|
|
665
759
|
}
|
|
760
|
+
|
|
761
|
+
.h-multipart {
|
|
762
|
+
max-width: min(680px, calc(100vw - 80px));
|
|
763
|
+
margin: 0 auto 60px;
|
|
764
|
+
background: var(--surface);
|
|
765
|
+
border: 1px solid var(--border);
|
|
766
|
+
border-radius: 8px;
|
|
767
|
+
padding: 32px 40px;
|
|
768
|
+
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
|
|
769
|
+
}
|
|
666
770
|
.h-sections {
|
|
667
771
|
max-width: min(680px, calc(100vw - 80px));
|
|
668
772
|
margin: 0 auto; padding-bottom: 80px;
|
|
@@ -678,7 +782,7 @@ function tcy(n: number): string {
|
|
|
678
782
|
}
|
|
679
783
|
|
|
680
784
|
.h-source-link {
|
|
681
|
-
color: var(--
|
|
785
|
+
color: var(--vermillion);
|
|
682
786
|
cursor: pointer;
|
|
683
787
|
font-size: 13px;
|
|
684
788
|
}
|
|
@@ -703,7 +807,9 @@ function tcy(n: number): string {
|
|
|
703
807
|
|
|
704
808
|
.h-overlay {
|
|
705
809
|
position: fixed; inset: 0;
|
|
706
|
-
background: rgba(var(--shadow-rgb), 0.
|
|
810
|
+
background: rgba(var(--shadow-rgb), 0.2);
|
|
811
|
+
backdrop-filter: blur(8px);
|
|
812
|
+
-webkit-backdrop-filter: blur(8px);
|
|
707
813
|
z-index: 200;
|
|
708
814
|
display: flex; justify-content: flex-end;
|
|
709
815
|
animation: fadeIn 0.2s ease;
|
|
@@ -750,7 +856,9 @@ function tcy(n: number): string {
|
|
|
750
856
|
|
|
751
857
|
.v-overlay {
|
|
752
858
|
position: fixed; inset: 0;
|
|
753
|
-
background: rgba(var(--shadow-rgb), 0.
|
|
859
|
+
background: rgba(var(--shadow-rgb), 0.2);
|
|
860
|
+
backdrop-filter: blur(8px);
|
|
861
|
+
-webkit-backdrop-filter: blur(8px);
|
|
754
862
|
z-index: 200;
|
|
755
863
|
display: flex; justify-content: flex-start;
|
|
756
864
|
animation: fadeIn 0.2s ease;
|
|
@@ -807,6 +915,25 @@ function tcy(n: number): string {
|
|
|
807
915
|
margin-left: 12px;
|
|
808
916
|
}
|
|
809
917
|
|
|
918
|
+
.loading {
|
|
919
|
+
display: flex; flex-direction: column;
|
|
920
|
+
align-items: center; justify-content: center;
|
|
921
|
+
height: 100vh;
|
|
922
|
+
}
|
|
923
|
+
.loading-seal {
|
|
924
|
+
width: 56px; height: 56px;
|
|
925
|
+
border: 2px solid var(--vermillion);
|
|
926
|
+
border-radius: 4px;
|
|
927
|
+
display: flex; align-items: center; justify-content: center;
|
|
928
|
+
font-size: 28px; font-weight: 900;
|
|
929
|
+
color: var(--vermillion);
|
|
930
|
+
animation: pulse 1.2s ease-in-out infinite;
|
|
931
|
+
}
|
|
932
|
+
@keyframes pulse {
|
|
933
|
+
0%, 100% { opacity: 0.3; }
|
|
934
|
+
50% { opacity: 1; }
|
|
935
|
+
}
|
|
936
|
+
|
|
810
937
|
@media (max-width: 768px) {
|
|
811
938
|
.h-content { padding: 30px 20px; }
|
|
812
939
|
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { AnnotationLayer } from '../types'
|
|
3
|
-
|
|
4
|
-
const props = defineProps<{
|
|
5
|
-
layers: AnnotationLayer[]
|
|
6
|
-
activeIds: string[]
|
|
7
|
-
}>()
|
|
8
|
-
|
|
9
|
-
const emit = defineEmits<{
|
|
10
|
-
'update:activeIds': [ids: string[]]
|
|
11
|
-
}>()
|
|
12
|
-
|
|
13
|
-
function toggle(id: string) {
|
|
14
|
-
const current = props.activeIds
|
|
15
|
-
if (current.includes(id)) {
|
|
16
|
-
emit('update:activeIds', current.filter(x => x !== id))
|
|
17
|
-
} else {
|
|
18
|
-
emit('update:activeIds', [...current, id])
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
</script>
|
|
22
|
-
|
|
23
|
-
<template>
|
|
24
|
-
<div v-if="layers.length > 1" class="layer-selector">
|
|
25
|
-
<button
|
|
26
|
-
v-for="layer in layers"
|
|
27
|
-
:key="layer.id"
|
|
28
|
-
:class="['layer-btn', { active: activeIds.includes(layer.id) }]"
|
|
29
|
-
:title="layer.label"
|
|
30
|
-
@click="toggle(layer.id)"
|
|
31
|
-
>
|
|
32
|
-
{{ layer.shortLabel }}
|
|
33
|
-
</button>
|
|
34
|
-
</div>
|
|
35
|
-
</template>
|
|
36
|
-
|
|
37
|
-
<style scoped>
|
|
38
|
-
.layer-selector {
|
|
39
|
-
display: flex;
|
|
40
|
-
gap: 6px;
|
|
41
|
-
flex-wrap: wrap;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.layer-btn {
|
|
45
|
-
border: 1px solid var(--border);
|
|
46
|
-
border-radius: 4px;
|
|
47
|
-
padding: 4px 12px;
|
|
48
|
-
font-size: 13px;
|
|
49
|
-
background: var(--surface);
|
|
50
|
-
color: var(--ink-mid);
|
|
51
|
-
cursor: pointer;
|
|
52
|
-
transition: all 0.15s;
|
|
53
|
-
font-family: var(--sans);
|
|
54
|
-
letter-spacing: 1px;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
.layer-btn:hover {
|
|
58
|
-
border-color: var(--gold);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
.layer-btn.active {
|
|
62
|
-
background: var(--ink);
|
|
63
|
-
color: var(--paper);
|
|
64
|
-
border-color: var(--ink);
|
|
65
|
-
}
|
|
66
|
-
</style>
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { computed, ref, type Ref } from 'vue'
|
|
2
|
-
import { useReadingMode } from './useReadingMode'
|
|
3
|
-
import { useHorizontalScroll } from './useHorizontalScroll'
|
|
4
|
-
|
|
5
|
-
export interface PageLayoutConfig {
|
|
6
|
-
mode: 'vertical' | 'horizontal'
|
|
7
|
-
navSide: 'top' | 'right'
|
|
8
|
-
contentDirection: 'ltr' | 'rtl'
|
|
9
|
-
isVertical: boolean
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function usePageLayout() {
|
|
13
|
-
const { layout } = useReadingMode()
|
|
14
|
-
const scrollRef = ref<HTMLElement | null>(null)
|
|
15
|
-
const scroll = useHorizontalScroll(scrollRef)
|
|
16
|
-
|
|
17
|
-
const config = computed<PageLayoutConfig>(() => ({
|
|
18
|
-
mode: layout.value,
|
|
19
|
-
navSide: layout.value === 'vertical' ? 'right' : 'top',
|
|
20
|
-
contentDirection: layout.value === 'vertical' ? 'rtl' : 'ltr',
|
|
21
|
-
isVertical: layout.value === 'vertical',
|
|
22
|
-
}))
|
|
23
|
-
|
|
24
|
-
return { config, scrollRef, ...scroll }
|
|
25
|
-
}
|