@hanology/cham-browser 0.3.3 → 0.3.4
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/dist/cli.js +0 -0
- package/package.json +1 -1
- package/template/index.html +14 -5
- package/template/src/App.vue +21 -4
- package/template/src/components/PartBlock.vue +160 -0
- package/template/src/components/PartGroup.vue +72 -0
- package/template/src/components/PoemCard.vue +6 -6
- package/template/src/components/ReadingToolbar.vue +45 -5
- package/template/src/main.ts +5 -1
- package/template/src/styles/main.css +45 -8
- package/template/src/types.ts +10 -0
- package/template/src/views/LibraryHome.vue +28 -0
- package/template/src/views/PieceView.vue +97 -10
- package/template/src/components/AnnotationLayerSelector.vue +0 -66
- package/template/src/composables/usePageLayout.ts +0 -25
package/dist/cli.js
CHANGED
|
File without changes
|
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,160 @@
|
|
|
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
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
annotationHover: [event: MouseEvent, annotations: Annotation[]]
|
|
15
|
+
annotationLeave: []
|
|
16
|
+
annotationTap: [event: MouseEvent, annotations: Annotation[]]
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
function verseHtml(index: number): string {
|
|
20
|
+
const useRuby = props.vertical
|
|
21
|
+
let offset = 0
|
|
22
|
+
for (let i = 0; i < index; i++) offset += countVerseSpans(props.annotations, i)
|
|
23
|
+
const spans = buildVerseAnnotations(props.annotations, index)
|
|
24
|
+
return renderAnnotatedText(props.verses[index].text, spans, useRuby, offset)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onHover(event: MouseEvent) {
|
|
28
|
+
const matched = resolveHoveredAnnotations(event, props.annotations)
|
|
29
|
+
if (matched) emit('annotationHover', event, matched)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function onLeave() { emit('annotationLeave') }
|
|
33
|
+
|
|
34
|
+
function onTap(event: MouseEvent) {
|
|
35
|
+
const matched = resolveHoveredAnnotations(event, props.annotations)
|
|
36
|
+
if (matched) emit('annotationTap', event, matched)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sourceLabel = (() => {
|
|
40
|
+
const r = props.source?.range as Record<string, string> | undefined
|
|
41
|
+
return r?.chapter || ''
|
|
42
|
+
})()
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div class="part-block" :class="{ 'part-block--vertical': vertical }">
|
|
47
|
+
<div v-if="sourceLabel" class="part-source">
|
|
48
|
+
{{ sourceLabel }}
|
|
49
|
+
</div>
|
|
50
|
+
<div class="part-text" @mouseover="onHover" @mouseleave="onLeave" @click="onTap">
|
|
51
|
+
<span
|
|
52
|
+
v-for="(_, i) in verses"
|
|
53
|
+
:key="i"
|
|
54
|
+
:class="vertical ? 'part-line-v' : 'part-line-h'"
|
|
55
|
+
v-html="verseHtml(i)"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.part-block {
|
|
63
|
+
padding: 20px 0;
|
|
64
|
+
border-bottom: 1px solid var(--border-light);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.part-block:last-child {
|
|
68
|
+
border-bottom: none;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.part-block--vertical {
|
|
72
|
+
writing-mode: vertical-rl;
|
|
73
|
+
text-orientation: mixed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.part-source {
|
|
77
|
+
font-family: var(--sans);
|
|
78
|
+
font-size: 12px;
|
|
79
|
+
letter-spacing: 1px;
|
|
80
|
+
color: var(--ink-faint);
|
|
81
|
+
background: var(--surface);
|
|
82
|
+
display: inline-block;
|
|
83
|
+
padding: 3px 10px;
|
|
84
|
+
border-radius: 3px;
|
|
85
|
+
margin-bottom: 12px;
|
|
86
|
+
border: 1px solid var(--border-light);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.part-text {
|
|
90
|
+
line-height: 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.part-line-h {
|
|
94
|
+
font-size: var(--main-font-size, 22px);
|
|
95
|
+
line-height: 2.4;
|
|
96
|
+
letter-spacing: 3px;
|
|
97
|
+
color: var(--ink);
|
|
98
|
+
display: block;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.part-line-v {
|
|
102
|
+
font-size: var(--main-font-size, 22px);
|
|
103
|
+
line-height: 2.4;
|
|
104
|
+
letter-spacing: 6px;
|
|
105
|
+
color: var(--ink);
|
|
106
|
+
display: block;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
:deep(.ann-target) {
|
|
110
|
+
border-bottom: 2px solid var(--vermillion);
|
|
111
|
+
cursor: help;
|
|
112
|
+
transition: background 0.15s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
:deep(.ann-target:hover) {
|
|
116
|
+
background: rgba(194, 58, 43, 0.08);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
:deep(.ann-num) {
|
|
120
|
+
font-size: 10px;
|
|
121
|
+
color: var(--vermillion);
|
|
122
|
+
font-family: var(--sans);
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
vertical-align: super;
|
|
125
|
+
margin-right: 1px;
|
|
126
|
+
letter-spacing: 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
:deep(.ann-target.pronunciation) {
|
|
130
|
+
border-bottom-color: var(--jade);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
:deep(.ann-target.pronunciation:hover) {
|
|
134
|
+
background: rgba(58, 107, 94, 0.08);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Vertical mode overrides */
|
|
138
|
+
.part-block--vertical :deep(.ann-target) {
|
|
139
|
+
border-bottom: none;
|
|
140
|
+
border-left: 2px solid var(--vermillion);
|
|
141
|
+
padding-left: 2px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.part-block--vertical :deep(.ann-target.pronunciation) {
|
|
145
|
+
border-left-color: var(--jade);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.part-block--vertical :deep(.ann-num) {
|
|
149
|
+
font-size: 0.45em;
|
|
150
|
+
text-combine-upright: all;
|
|
151
|
+
text-align: end;
|
|
152
|
+
letter-spacing: 0;
|
|
153
|
+
vertical-align: baseline;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.part-block--vertical .part-source {
|
|
157
|
+
margin-bottom: 0;
|
|
158
|
+
margin-left: 8px;
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
:vertical="vertical"
|
|
28
|
+
:source="part.source"
|
|
29
|
+
@annotation-hover="(e, a) => emit('annotationHover', e, a)"
|
|
30
|
+
@annotation-leave="emit('annotationLeave')"
|
|
31
|
+
@annotation-tap="(e, a) => emit('annotationTap', e, a)"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.part-group {
|
|
38
|
+
margin-bottom: 40px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.part-group:last-child {
|
|
42
|
+
margin-bottom: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.part-group-label {
|
|
46
|
+
font-size: 28px;
|
|
47
|
+
font-weight: 900;
|
|
48
|
+
letter-spacing: 6px;
|
|
49
|
+
color: var(--ink);
|
|
50
|
+
padding-bottom: 12px;
|
|
51
|
+
margin-bottom: 8px;
|
|
52
|
+
border-bottom: 3px solid var(--vermillion);
|
|
53
|
+
display: inline-block;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.part-group--vertical {
|
|
57
|
+
writing-mode: vertical-rl;
|
|
58
|
+
text-orientation: mixed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.part-group--vertical .part-group-label {
|
|
62
|
+
font-size: 28px;
|
|
63
|
+
letter-spacing: 10px;
|
|
64
|
+
border-bottom: none;
|
|
65
|
+
border-left: 3px solid var(--vermillion);
|
|
66
|
+
padding-bottom: 0;
|
|
67
|
+
padding-left: 16px;
|
|
68
|
+
margin-bottom: 0;
|
|
69
|
+
margin-left: 12px;
|
|
70
|
+
display: block;
|
|
71
|
+
}
|
|
72
|
+
</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; }
|
|
@@ -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">
|
|
@@ -79,13 +95,14 @@ function close() { open.value = false }
|
|
|
79
95
|
font-size: 16px;
|
|
80
96
|
cursor: pointer;
|
|
81
97
|
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.12);
|
|
82
|
-
transition: all 0.
|
|
98
|
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
83
99
|
display: flex; align-items: center; justify-content: center;
|
|
84
100
|
}
|
|
85
101
|
.rt-fab:hover {
|
|
86
102
|
background: var(--ink);
|
|
87
103
|
color: var(--paper);
|
|
88
104
|
border-color: var(--ink);
|
|
105
|
+
transform: scale(1.05);
|
|
89
106
|
}
|
|
90
107
|
.rt-icon {
|
|
91
108
|
font-family: var(--sans);
|
|
@@ -102,7 +119,7 @@ function close() { open.value = false }
|
|
|
102
119
|
border-radius: 8px;
|
|
103
120
|
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.16);
|
|
104
121
|
padding: 16px;
|
|
105
|
-
animation: slideUp 0.
|
|
122
|
+
animation: slideUp 0.25s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
106
123
|
}
|
|
107
124
|
@keyframes slideUp {
|
|
108
125
|
from { opacity: 0; transform: translateY(8px); }
|
|
@@ -138,6 +155,29 @@ function close() { open.value = false }
|
|
|
138
155
|
color: var(--paper);
|
|
139
156
|
border-color: var(--ink);
|
|
140
157
|
}
|
|
158
|
+
.rt-size-row {
|
|
159
|
+
display: flex; align-items: center; gap: 6px; justify-content: center;
|
|
160
|
+
}
|
|
161
|
+
.rt-size-btn {
|
|
162
|
+
width: 28px; height: 28px;
|
|
163
|
+
border: 1px solid var(--border);
|
|
164
|
+
border-radius: 4px;
|
|
165
|
+
background: none;
|
|
166
|
+
font-family: var(--sans);
|
|
167
|
+
font-size: 14px;
|
|
168
|
+
color: var(--ink-mid);
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
display: flex; align-items: center; justify-content: center;
|
|
171
|
+
transition: all 0.15s;
|
|
172
|
+
}
|
|
173
|
+
.rt-size-btn:hover { border-color: var(--ink); color: var(--ink); }
|
|
174
|
+
.rt-size-val {
|
|
175
|
+
font-family: var(--sans);
|
|
176
|
+
font-size: 13px;
|
|
177
|
+
color: var(--ink);
|
|
178
|
+
min-width: 32px;
|
|
179
|
+
text-align: center;
|
|
180
|
+
}
|
|
141
181
|
.rt-backdrop {
|
|
142
182
|
position: fixed; inset: 0;
|
|
143
183
|
z-index: -1;
|
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
|
@@ -123,6 +123,15 @@ export interface ProseSection {
|
|
|
123
123
|
order: number
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
export interface Part {
|
|
127
|
+
num: number
|
|
128
|
+
group?: string
|
|
129
|
+
title?: string
|
|
130
|
+
source?: PieceSource
|
|
131
|
+
verses: VerseLine[]
|
|
132
|
+
annotations: Annotation[]
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
export interface Piece {
|
|
127
136
|
bookId: string
|
|
128
137
|
num: number
|
|
@@ -139,6 +148,7 @@ export interface Piece {
|
|
|
139
148
|
annotationLayers?: AnnotationLayer[]
|
|
140
149
|
source?: PieceSource
|
|
141
150
|
contributors?: PieceContributor[]
|
|
151
|
+
parts?: Part[]
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
// Backward compatibility alias
|
|
@@ -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>
|
|
@@ -175,6 +178,31 @@ function openBook(bookId: string) {
|
|
|
175
178
|
justify-content: center;
|
|
176
179
|
padding: 40px 20px;
|
|
177
180
|
}
|
|
181
|
+
.v-about-col {
|
|
182
|
+
writing-mode: vertical-rl;
|
|
183
|
+
text-orientation: mixed;
|
|
184
|
+
flex-shrink: 0;
|
|
185
|
+
height: 100vh;
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: center;
|
|
189
|
+
padding: 0 12px;
|
|
190
|
+
border-right: 1px solid var(--border-light);
|
|
191
|
+
}
|
|
192
|
+
.v-about-link {
|
|
193
|
+
font-size: 14px;
|
|
194
|
+
color: var(--ink-faint);
|
|
195
|
+
letter-spacing: 6px;
|
|
196
|
+
font-family: var(--sans);
|
|
197
|
+
padding: 12px 8px;
|
|
198
|
+
border: 1px solid var(--border-light);
|
|
199
|
+
border-radius: 2px;
|
|
200
|
+
transition: all 0.2s;
|
|
201
|
+
}
|
|
202
|
+
.v-about-link:hover {
|
|
203
|
+
color: var(--ink);
|
|
204
|
+
border-color: var(--ink);
|
|
205
|
+
}
|
|
178
206
|
.v-title {
|
|
179
207
|
font-size: 48px; font-weight: 900;
|
|
180
208
|
letter-spacing: 16px; color: var(--ink);
|
|
@@ -13,7 +13,8 @@ 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 type { Piece, Annotation, AnnotationLayer, Part } from '../types'
|
|
17
18
|
|
|
18
19
|
const props = defineProps<{ bookId: string; num: string | number }>()
|
|
19
20
|
const router = useRouter()
|
|
@@ -128,6 +129,29 @@ function getHeadword(ann: Annotation): string {
|
|
|
128
129
|
// Initialize layers when piece loads
|
|
129
130
|
watch(() => piece.value, () => initLayers(), { immediate: true })
|
|
130
131
|
|
|
132
|
+
// ─── Multi-part ───────────────────────────────────────────────
|
|
133
|
+
const isMultiPart = computed(() => (piece.value?.parts?.length ?? 0) > 0)
|
|
134
|
+
|
|
135
|
+
const partGroups = computed<{ label: string; parts: Part[] }[]>(() => {
|
|
136
|
+
if (!piece.value?.parts?.length) return []
|
|
137
|
+
const groupMap = new Map<string, Part[]>()
|
|
138
|
+
for (const part of piece.value.parts) {
|
|
139
|
+
const key = part.group || ''
|
|
140
|
+
if (!groupMap.has(key)) groupMap.set(key, [])
|
|
141
|
+
groupMap.get(key)!.push(part)
|
|
142
|
+
}
|
|
143
|
+
return [...groupMap.entries()].map(([label, parts]) => ({ label, parts }))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const allPartAnnotations = computed<Annotation[]>(() => {
|
|
147
|
+
if (!piece.value?.parts) return []
|
|
148
|
+
return piece.value.parts.flatMap(p => p.annotations)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const totalPartAnnotationCount = computed(() => {
|
|
152
|
+
return piece.value?.parts?.reduce((sum, p) => sum + p.annotations.length, 0) ?? 0
|
|
153
|
+
})
|
|
154
|
+
|
|
131
155
|
const SECTION_META: Record<string, { label: string; special: boolean }> = {
|
|
132
156
|
background: { label: '背景資料', special: false },
|
|
133
157
|
analysis: { label: '賞析重點', special: false },
|
|
@@ -232,12 +256,31 @@ function tcy(n: number): string {
|
|
|
232
256
|
← {{ meta?.title }}
|
|
233
257
|
</div>
|
|
234
258
|
<div class="v-poem-meta">
|
|
235
|
-
<
|
|
236
|
-
|
|
259
|
+
<template v-if="isMultiPart">
|
|
260
|
+
<span class="v-meta-item" v-html="tcy(piece.parts!.length) + ' 段'" />
|
|
261
|
+
<span class="v-meta-item" v-html="totalPartAnnotationCount > 0 ? tcy(totalPartAnnotationCount) + ' 注' : '無注'" />
|
|
262
|
+
</template>
|
|
263
|
+
<template v-else>
|
|
264
|
+
<span class="v-meta-item" v-html="tcy(piece.verses.length) + ' 段'" />
|
|
265
|
+
<span class="v-meta-item" v-html="piece.annotations.length > 0 ? tcy(piece.annotations.length) + ' 注' : '無注'" />
|
|
266
|
+
</template>
|
|
237
267
|
</div>
|
|
238
268
|
</section>
|
|
239
269
|
|
|
240
|
-
<section class="v-poem-col">
|
|
270
|
+
<section v-if="isMultiPart" class="v-poem-col v-multipart">
|
|
271
|
+
<PartGroup
|
|
272
|
+
v-for="group in partGroups"
|
|
273
|
+
:key="group.label"
|
|
274
|
+
:label="group.label"
|
|
275
|
+
:parts="group.parts"
|
|
276
|
+
:vertical="true"
|
|
277
|
+
@annotation-hover="interaction.onHover"
|
|
278
|
+
@annotation-leave="interaction.onLeave"
|
|
279
|
+
@annotation-tap="interaction.onTap"
|
|
280
|
+
/>
|
|
281
|
+
</section>
|
|
282
|
+
|
|
283
|
+
<section v-else class="v-poem-col">
|
|
241
284
|
<VerticalScroll
|
|
242
285
|
:title="''"
|
|
243
286
|
:author="''"
|
|
@@ -252,7 +295,7 @@ function tcy(n: number): string {
|
|
|
252
295
|
</section>
|
|
253
296
|
|
|
254
297
|
<SectionBlock
|
|
255
|
-
v-if="annotationsVisible && piece.sections.annotations"
|
|
298
|
+
v-if="!isMultiPart && annotationsVisible && piece.sections.annotations"
|
|
256
299
|
num=""
|
|
257
300
|
label="注釋"
|
|
258
301
|
:special="false"
|
|
@@ -370,14 +413,32 @@ function tcy(n: number): string {
|
|
|
370
413
|
<span v-else class="h-author-link" @click="openAuthorPane">{{ piece.author }}</span>
|
|
371
414
|
</div>
|
|
372
415
|
<div class="h-controls">
|
|
373
|
-
<
|
|
374
|
-
|
|
416
|
+
<template v-if="isMultiPart">
|
|
417
|
+
<span class="h-tag">{{ piece.parts!.length }} 段</span>
|
|
418
|
+
<span class="h-tag">{{ totalPartAnnotationCount > 0 ? totalPartAnnotationCount + ' 注' : '無注' }}</span>
|
|
419
|
+
</template>
|
|
420
|
+
<template v-else>
|
|
421
|
+
<span class="h-tag">{{ piece.verses.length }} 段</span>
|
|
422
|
+
<span class="h-tag">{{ piece.annotations.length > 0 ? piece.annotations.length + ' 注' : '無注' }}</span>
|
|
423
|
+
</template>
|
|
375
424
|
</div>
|
|
376
425
|
</div>
|
|
377
426
|
</nav>
|
|
378
427
|
|
|
379
428
|
<div class="h-content">
|
|
380
|
-
<div class="h-
|
|
429
|
+
<div v-if="isMultiPart" class="h-multipart">
|
|
430
|
+
<PartGroup
|
|
431
|
+
v-for="group in partGroups"
|
|
432
|
+
:key="group.label"
|
|
433
|
+
:label="group.label"
|
|
434
|
+
:parts="group.parts"
|
|
435
|
+
@annotation-hover="interaction.onHover"
|
|
436
|
+
@annotation-leave="interaction.onLeave"
|
|
437
|
+
@annotation-tap="interaction.onTap"
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div v-else class="h-poem-block">
|
|
381
442
|
<HorizontalDisplay
|
|
382
443
|
:title="piece.title"
|
|
383
444
|
:author="piece.author"
|
|
@@ -551,6 +612,22 @@ function tcy(n: number): string {
|
|
|
551
612
|
padding: 24px;
|
|
552
613
|
}
|
|
553
614
|
|
|
615
|
+
.v-multipart {
|
|
616
|
+
display: flex;
|
|
617
|
+
flex-direction: row-reverse;
|
|
618
|
+
align-items: flex-start;
|
|
619
|
+
gap: 0;
|
|
620
|
+
max-height: calc(100vh - 120px);
|
|
621
|
+
overflow-x: auto;
|
|
622
|
+
overflow-y: hidden;
|
|
623
|
+
padding: 24px 16px;
|
|
624
|
+
scrollbar-width: thin;
|
|
625
|
+
scrollbar-color: var(--gold) var(--paper);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.v-multipart::-webkit-scrollbar { height: 4px; }
|
|
629
|
+
.v-multipart::-webkit-scrollbar-thumb { background: var(--gold); border-radius: 2px; }
|
|
630
|
+
|
|
554
631
|
.v-section {
|
|
555
632
|
flex-shrink: 0;
|
|
556
633
|
}
|
|
@@ -564,7 +641,7 @@ function tcy(n: number): string {
|
|
|
564
641
|
|
|
565
642
|
.v-source-link {
|
|
566
643
|
font-size: 12px;
|
|
567
|
-
color: var(--
|
|
644
|
+
color: var(--vermillion);
|
|
568
645
|
cursor: pointer;
|
|
569
646
|
margin-top: 4px;
|
|
570
647
|
opacity: 0.8;
|
|
@@ -663,6 +740,16 @@ function tcy(n: number): string {
|
|
|
663
740
|
.h-poem-block {
|
|
664
741
|
margin-bottom: 60px; display: flex; justify-content: center;
|
|
665
742
|
}
|
|
743
|
+
|
|
744
|
+
.h-multipart {
|
|
745
|
+
max-width: min(680px, calc(100vw - 80px));
|
|
746
|
+
margin: 0 auto 60px;
|
|
747
|
+
background: var(--surface);
|
|
748
|
+
border: 1px solid var(--border);
|
|
749
|
+
border-radius: 8px;
|
|
750
|
+
padding: 32px 40px;
|
|
751
|
+
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.08);
|
|
752
|
+
}
|
|
666
753
|
.h-sections {
|
|
667
754
|
max-width: min(680px, calc(100vw - 80px));
|
|
668
755
|
margin: 0 auto; padding-bottom: 80px;
|
|
@@ -678,7 +765,7 @@ function tcy(n: number): string {
|
|
|
678
765
|
}
|
|
679
766
|
|
|
680
767
|
.h-source-link {
|
|
681
|
-
color: var(--
|
|
768
|
+
color: var(--vermillion);
|
|
682
769
|
cursor: pointer;
|
|
683
770
|
font-size: 13px;
|
|
684
771
|
}
|
|
@@ -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
|
-
}
|