@hanology/cham-browser 0.2.2 → 0.3.0
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/pipeline.d.ts +2 -14
- package/dist/pipeline.js +1 -376
- package/dist/pipeline.js.map +1 -1
- package/package.json +4 -2
- package/template/__tests__/annotationRenderer.test.ts +140 -0
- package/template/__tests__/annotationTooltip.test.ts +176 -0
- package/template/src/components/AnnotationControlBar.vue +124 -0
- package/template/src/components/AnnotationTooltip.vue +47 -30
- package/template/src/components/ReadingToolbar.vue +18 -4
- package/template/src/components/SideNav.vue +20 -6
- package/template/src/composables/useAnnotationInteraction.ts +60 -0
- package/template/src/composables/useI18n.ts +174 -0
- package/template/src/views/AboutView.vue +173 -0
- package/template/src/views/PieceView.vue +68 -58
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { resolveHoveredAnnotations, useAnnotationTooltip } from '../src/composables/useAnnotationRenderer'
|
|
3
|
+
import type { Annotation } from '../src/types'
|
|
4
|
+
|
|
5
|
+
function makeAnn(overrides: Partial<Annotation> & { id: string; kind: Annotation['kind'] }): Annotation {
|
|
6
|
+
return {
|
|
7
|
+
text: 'test',
|
|
8
|
+
range: { scope: 'verse', verseIndex: 0, start: 0, end: 1 },
|
|
9
|
+
...overrides,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createMockEvent(target: HTMLElement): MouseEvent {
|
|
14
|
+
return { target } as MouseEvent
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createAnnTarget(ids: string[], kinds: string): HTMLElement {
|
|
18
|
+
const el = document.createElement('span')
|
|
19
|
+
el.className = `ann-target ${kinds}`
|
|
20
|
+
el.setAttribute('data-ann-ids', ids.join(','))
|
|
21
|
+
el.textContent = 'test text'
|
|
22
|
+
return el
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('resolveHoveredAnnotations', () => {
|
|
26
|
+
const annotations: Annotation[] = [
|
|
27
|
+
makeAnn({ id: 'a1', kind: 'semantic' }),
|
|
28
|
+
makeAnn({ id: 'p1', kind: 'pronunciation' }),
|
|
29
|
+
makeAnn({ id: 'a2', kind: 'semantic' }),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
it('returns null when target is not an ann-target element', () => {
|
|
33
|
+
const plain = document.createElement('span')
|
|
34
|
+
const result = resolveHoveredAnnotations(createMockEvent(plain), annotations)
|
|
35
|
+
expect(result).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns matched annotations by id', () => {
|
|
39
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
40
|
+
// Mock closest to return itself
|
|
41
|
+
target.closest = vi.fn().mockReturnValue(target)
|
|
42
|
+
const result = resolveHoveredAnnotations(createMockEvent(target), annotations)
|
|
43
|
+
expect(result).toHaveLength(1)
|
|
44
|
+
expect(result![0].id).toBe('a1')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns multiple annotations for overlapping targets', () => {
|
|
48
|
+
const target = createAnnTarget(['a1', 'p1'], 'semantic pronunciation')
|
|
49
|
+
target.closest = vi.fn().mockReturnValue(target)
|
|
50
|
+
const result = resolveHoveredAnnotations(createMockEvent(target), annotations)
|
|
51
|
+
expect(result).toHaveLength(2)
|
|
52
|
+
const ids = result!.map(a => a.id).sort()
|
|
53
|
+
expect(ids).toEqual(['a1', 'p1'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns null when no matching annotation found', () => {
|
|
57
|
+
const target = createAnnTarget(['unknown'], 'semantic')
|
|
58
|
+
target.closest = vi.fn().mockReturnValue(target)
|
|
59
|
+
const result = resolveHoveredAnnotations(createMockEvent(target), annotations)
|
|
60
|
+
expect(result).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('useAnnotationTooltip', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
// Mock window.innerWidth for desktop
|
|
67
|
+
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true })
|
|
68
|
+
Object.defineProperty(window, 'innerHeight', { value: 768, writable: true, configurable: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('starts hidden', () => {
|
|
72
|
+
const { visible } = useAnnotationTooltip()
|
|
73
|
+
expect(visible.value).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('shows tooltip on show()', () => {
|
|
77
|
+
const { visible, items, show } = useAnnotationTooltip()
|
|
78
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
79
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
80
|
+
left: 100, top: 200, bottom: 230, right: 150, width: 50, height: 30,
|
|
81
|
+
})
|
|
82
|
+
const anns = [makeAnn({ id: 'a1', kind: 'semantic' })]
|
|
83
|
+
show(createMockEvent(target), anns)
|
|
84
|
+
expect(visible.value).toBe(true)
|
|
85
|
+
expect(items.value).toEqual(anns)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('hides tooltip on hide()', () => {
|
|
89
|
+
const { visible, show, hide } = useAnnotationTooltip()
|
|
90
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
91
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
92
|
+
left: 100, top: 200, bottom: 230, right: 150, width: 50, height: 30,
|
|
93
|
+
})
|
|
94
|
+
show(createMockEvent(target), [makeAnn({ id: 'a1', kind: 'semantic' })])
|
|
95
|
+
expect(visible.value).toBe(true)
|
|
96
|
+
hide()
|
|
97
|
+
expect(visible.value).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('toggle shows when not visible', () => {
|
|
101
|
+
const { visible, toggle } = useAnnotationTooltip()
|
|
102
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
103
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
104
|
+
left: 100, top: 200, bottom: 230, right: 150, width: 50, height: 30,
|
|
105
|
+
})
|
|
106
|
+
expect(visible.value).toBe(false)
|
|
107
|
+
toggle(createMockEvent(target), [makeAnn({ id: 'a1', kind: 'semantic' })])
|
|
108
|
+
expect(visible.value).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('mobile toggle behavior', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
Object.defineProperty(window, 'innerWidth', { value: 375, writable: true, configurable: true })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('dismisses on toggle when same annotation on mobile', () => {
|
|
117
|
+
const { visible, show, toggle } = useAnnotationTooltip()
|
|
118
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
119
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
120
|
+
left: 100, top: 200, bottom: 230, right: 150, width: 50, height: 30,
|
|
121
|
+
})
|
|
122
|
+
const anns = [makeAnn({ id: 'a1', kind: 'semantic' })]
|
|
123
|
+
show(createMockEvent(target), anns)
|
|
124
|
+
expect(visible.value).toBe(true)
|
|
125
|
+
toggle(createMockEvent(target), anns)
|
|
126
|
+
expect(visible.value).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('shows new annotation on toggle when different on mobile', () => {
|
|
130
|
+
const { visible, show, toggle, items } = useAnnotationTooltip()
|
|
131
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
132
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
133
|
+
left: 100, top: 200, bottom: 230, right: 150, width: 50, height: 30,
|
|
134
|
+
})
|
|
135
|
+
const anns1 = [makeAnn({ id: 'a1', kind: 'semantic' })]
|
|
136
|
+
const anns2 = [makeAnn({ id: 'a2', kind: 'semantic' })]
|
|
137
|
+
show(createMockEvent(target), anns1)
|
|
138
|
+
toggle(createMockEvent(target), anns2)
|
|
139
|
+
expect(visible.value).toBe(true)
|
|
140
|
+
expect(items.value[0].id).toBe('a2')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('desktop toggle behavior', () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('does NOT dismiss on toggle when same annotation on desktop', () => {
|
|
150
|
+
const { visible, show, toggle } = useAnnotationTooltip()
|
|
151
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
152
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
153
|
+
left: 100, top: 200, bottom: 230, right: 150, width: 50, height: 30,
|
|
154
|
+
})
|
|
155
|
+
const anns = [makeAnn({ id: 'a1', kind: 'semantic' })]
|
|
156
|
+
show(createMockEvent(target), anns)
|
|
157
|
+
expect(visible.value).toBe(true)
|
|
158
|
+
toggle(createMockEvent(target), anns)
|
|
159
|
+
// Desktop: same annotation stays open
|
|
160
|
+
expect(visible.value).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('positions tooltip for vertical layout (default)', () => {
|
|
165
|
+
const { style, show } = useAnnotationTooltip()
|
|
166
|
+
const target = createAnnTarget(['a1'], 'semantic')
|
|
167
|
+
target.getBoundingClientRect = vi.fn().mockReturnValue({
|
|
168
|
+
left: 200, top: 300, bottom: 330, right: 250, width: 50, height: 30,
|
|
169
|
+
})
|
|
170
|
+
show(createMockEvent(target), [makeAnn({ id: 'a1', kind: 'semantic' })])
|
|
171
|
+
// Default layout is vertical — positioned via right + top
|
|
172
|
+
expect(style.value.right).toBeTruthy()
|
|
173
|
+
expect(style.value.top).toBe('50%')
|
|
174
|
+
expect(style.value.transform).toBe('translateY(-50%)')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AnnotationLayer } from '../types'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
layers: AnnotationLayer[]
|
|
6
|
+
hasAnnotations: boolean
|
|
7
|
+
activeIds: string[]
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
'update:activeIds': [ids: string[]]
|
|
12
|
+
'update:annotationsVisible': [visible: boolean]
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const allIds = () => props.layers.map(l => l.id)
|
|
16
|
+
const noneIds = () => [] as string[]
|
|
17
|
+
|
|
18
|
+
function toggleAnnotations() {
|
|
19
|
+
if (props.activeIds.length > 0) {
|
|
20
|
+
emit('update:activeIds', noneIds())
|
|
21
|
+
emit('update:annotationsVisible', false)
|
|
22
|
+
} else {
|
|
23
|
+
emit('update:activeIds', allIds())
|
|
24
|
+
emit('update:annotationsVisible', true)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toggleLayer(id: string) {
|
|
29
|
+
const current = props.activeIds
|
|
30
|
+
if (current.includes(id)) {
|
|
31
|
+
const next = current.filter(x => x !== id)
|
|
32
|
+
emit('update:activeIds', next)
|
|
33
|
+
if (next.length === 0) emit('update:annotationsVisible', false)
|
|
34
|
+
} else {
|
|
35
|
+
const next = [...current, id]
|
|
36
|
+
emit('update:activeIds', next)
|
|
37
|
+
emit('update:annotationsVisible', true)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div v-if="hasAnnotations" class="ann-control-bar">
|
|
44
|
+
<button
|
|
45
|
+
class="ann-toggle"
|
|
46
|
+
:class="{ active: activeIds.length > 0 }"
|
|
47
|
+
@click="toggleAnnotations"
|
|
48
|
+
>注</button>
|
|
49
|
+
<template v-if="layers.length > 1">
|
|
50
|
+
<span class="ann-bar-sep" />
|
|
51
|
+
<button
|
|
52
|
+
v-for="layer in layers"
|
|
53
|
+
:key="layer.id"
|
|
54
|
+
:class="['ann-layer-btn', { active: activeIds.includes(layer.id) }]"
|
|
55
|
+
:title="layer.label"
|
|
56
|
+
@click="toggleLayer(layer.id)"
|
|
57
|
+
>
|
|
58
|
+
{{ layer.shortLabel }}
|
|
59
|
+
</button>
|
|
60
|
+
</template>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<style scoped>
|
|
65
|
+
.ann-control-bar {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 4px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ann-toggle {
|
|
72
|
+
width: 28px;
|
|
73
|
+
height: 28px;
|
|
74
|
+
border: 1px solid var(--vermillion);
|
|
75
|
+
border-radius: 4px;
|
|
76
|
+
background: none;
|
|
77
|
+
color: var(--vermillion);
|
|
78
|
+
font-family: var(--serif);
|
|
79
|
+
font-size: 14px;
|
|
80
|
+
font-weight: 700;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
transition: all 0.15s;
|
|
83
|
+
letter-spacing: 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ann-toggle.active {
|
|
87
|
+
background: var(--vermillion);
|
|
88
|
+
color: #fff;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ann-toggle:hover {
|
|
92
|
+
border-color: var(--vermillion-light);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.ann-bar-sep {
|
|
96
|
+
width: 1px;
|
|
97
|
+
height: 16px;
|
|
98
|
+
background: var(--border);
|
|
99
|
+
margin: 0 2px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ann-layer-btn {
|
|
103
|
+
border: 1px solid var(--border);
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
padding: 3px 10px;
|
|
106
|
+
font-size: 12px;
|
|
107
|
+
background: var(--surface);
|
|
108
|
+
color: var(--ink-mid);
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
transition: all 0.15s;
|
|
111
|
+
font-family: var(--sans);
|
|
112
|
+
letter-spacing: 1px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ann-layer-btn:hover {
|
|
116
|
+
border-color: var(--gold);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.ann-layer-btn.active {
|
|
120
|
+
background: var(--ink);
|
|
121
|
+
color: var(--paper);
|
|
122
|
+
border-color: var(--ink);
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
2
|
+
import { computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
3
3
|
import { useReadingMode } from '../composables/useReadingMode'
|
|
4
4
|
import { annotationToPronSegment } from '../utils/annotationParser'
|
|
5
5
|
import PronunciationGroup from './PronunciationGroup.vue'
|
|
@@ -12,9 +12,13 @@ const props = defineProps<{
|
|
|
12
12
|
style?: Record<string, string>
|
|
13
13
|
}>()
|
|
14
14
|
|
|
15
|
-
const emit = defineEmits<{
|
|
15
|
+
const emit = defineEmits<{
|
|
16
|
+
close: []
|
|
17
|
+
tooltipEnter: []
|
|
18
|
+
tooltipLeave: []
|
|
19
|
+
}>()
|
|
16
20
|
const { layout } = useReadingMode()
|
|
17
|
-
const isMobile = computed(() => window.innerWidth < 768)
|
|
21
|
+
const isMobile = computed(() => typeof window !== 'undefined' && window.innerWidth < 768)
|
|
18
22
|
|
|
19
23
|
function getSegment(ann: Annotation) {
|
|
20
24
|
return annotationToPronSegment(ann)
|
|
@@ -28,36 +32,55 @@ function layerLabel(ann: Annotation): string {
|
|
|
28
32
|
return ''
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
function
|
|
35
|
+
function onDocClick(e: MouseEvent) {
|
|
36
|
+
if (!props.visible) return
|
|
37
|
+
const tooltip = (e.target as HTMLElement).closest('.ann-tooltip')
|
|
38
|
+
if (tooltip) return
|
|
32
39
|
emit('close')
|
|
33
40
|
}
|
|
41
|
+
|
|
42
|
+
function onDocTouchMove(e: TouchEvent) {
|
|
43
|
+
if (!props.visible || !isMobile.value) return
|
|
44
|
+
const tooltip = (e.target as HTMLElement).closest('.ann-tooltip')
|
|
45
|
+
if (tooltip) return
|
|
46
|
+
emit('close')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onMounted(() => {
|
|
50
|
+
document.addEventListener('click', onDocClick, true)
|
|
51
|
+
document.addEventListener('touchmove', onDocTouchMove, { passive: true })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
onBeforeUnmount(() => {
|
|
55
|
+
document.removeEventListener('click', onDocClick, true)
|
|
56
|
+
document.removeEventListener('touchmove', onDocTouchMove)
|
|
57
|
+
})
|
|
34
58
|
</script>
|
|
35
59
|
|
|
36
60
|
<template>
|
|
37
61
|
<Teleport to="body">
|
|
38
62
|
<Transition name="ann-fade">
|
|
39
|
-
<div
|
|
63
|
+
<div
|
|
64
|
+
v-if="visible && annotations.length"
|
|
65
|
+
class="ann-tooltip"
|
|
66
|
+
:class="{ 'ann-vertical': layout === 'vertical', 'ann-mobile-bottom': isMobile }"
|
|
67
|
+
:style="style"
|
|
68
|
+
@mouseenter="emit('tooltipEnter')"
|
|
69
|
+
@mouseleave="emit('tooltipLeave')"
|
|
70
|
+
>
|
|
71
|
+
<button v-if="isMobile" class="ann-handle" @click="emit('close')">
|
|
72
|
+
<span class="ann-handle-bar" />
|
|
73
|
+
</button>
|
|
40
74
|
<div
|
|
41
|
-
|
|
42
|
-
:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@touchmove.stop
|
|
75
|
+
v-for="ann in annotations"
|
|
76
|
+
:key="ann.id"
|
|
77
|
+
class="ann-entry"
|
|
78
|
+
:class="ann.kind"
|
|
46
79
|
>
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
v-for="ann in annotations"
|
|
52
|
-
:key="ann.id"
|
|
53
|
-
class="ann-entry"
|
|
54
|
-
:class="ann.kind"
|
|
55
|
-
>
|
|
56
|
-
<span class="ann-kind">{{ ann.kind === 'pronunciation' ? '音' : '義' }}</span>
|
|
57
|
-
<span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
|
|
58
|
-
<PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
|
|
59
|
-
<span v-else class="ann-body">{{ ann.text }}</span>
|
|
60
|
-
</div>
|
|
80
|
+
<span class="ann-kind">{{ ann.kind === 'pronunciation' ? '音' : '義' }}</span>
|
|
81
|
+
<span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
|
|
82
|
+
<PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
|
|
83
|
+
<span v-else class="ann-body">{{ ann.text }}</span>
|
|
61
84
|
</div>
|
|
62
85
|
</div>
|
|
63
86
|
</Transition>
|
|
@@ -65,12 +88,6 @@ function onBackdropTouchMove() {
|
|
|
65
88
|
</template>
|
|
66
89
|
|
|
67
90
|
<style scoped>
|
|
68
|
-
.ann-backdrop {
|
|
69
|
-
position: fixed;
|
|
70
|
-
inset: 0;
|
|
71
|
-
z-index: 999;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
91
|
.ann-tooltip {
|
|
75
92
|
position: fixed;
|
|
76
93
|
padding: 12px 16px;
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import { useReadingMode, THEMES, THEME_LABELS } from '../composables/useReadingMode'
|
|
4
4
|
import type { LayoutMode } from '../composables/useReadingMode'
|
|
5
|
+
import { useI18n, LOCALE_LABELS, type Locale } from '../composables/useI18n'
|
|
5
6
|
|
|
6
7
|
const { theme, layout, setTheme, setLayout } = useReadingMode()
|
|
8
|
+
const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
|
|
7
9
|
const open = ref(false)
|
|
8
10
|
|
|
9
11
|
function toggle() { open.value = !open.value }
|
|
@@ -18,22 +20,22 @@ function close() { open.value = false }
|
|
|
18
20
|
</button>
|
|
19
21
|
<div v-if="open" class="rt-panel" @click.stop>
|
|
20
22
|
<div class="rt-group">
|
|
21
|
-
<div class="rt-label"
|
|
23
|
+
<div class="rt-label">{{ t('settings.layout') }}</div>
|
|
22
24
|
<div class="rt-options">
|
|
23
25
|
<button
|
|
24
26
|
class="rt-opt"
|
|
25
27
|
:class="{ active: layout === 'horizontal' }"
|
|
26
28
|
@click="setLayout('horizontal' as LayoutMode)"
|
|
27
|
-
|
|
29
|
+
>{{ t('settings.horizontal') }}</button>
|
|
28
30
|
<button
|
|
29
31
|
class="rt-opt"
|
|
30
32
|
:class="{ active: layout === 'vertical' }"
|
|
31
33
|
@click="setLayout('vertical' as LayoutMode)"
|
|
32
|
-
|
|
34
|
+
>{{ t('settings.vertical') }}</button>
|
|
33
35
|
</div>
|
|
34
36
|
</div>
|
|
35
37
|
<div class="rt-group">
|
|
36
|
-
<div class="rt-label"
|
|
38
|
+
<div class="rt-label">{{ t('settings.theme') }}</div>
|
|
37
39
|
<div class="rt-options">
|
|
38
40
|
<button
|
|
39
41
|
v-for="t in THEMES"
|
|
@@ -44,6 +46,18 @@ function close() { open.value = false }
|
|
|
44
46
|
>{{ THEME_LABELS[t] }}</button>
|
|
45
47
|
</div>
|
|
46
48
|
</div>
|
|
49
|
+
<div class="rt-group">
|
|
50
|
+
<div class="rt-label">{{ t('settings.language') }}</div>
|
|
51
|
+
<div class="rt-options">
|
|
52
|
+
<button
|
|
53
|
+
v-for="loc in availableLocales"
|
|
54
|
+
:key="loc"
|
|
55
|
+
class="rt-opt"
|
|
56
|
+
:class="{ active: locale === loc }"
|
|
57
|
+
@click="setLocale(loc as Locale)"
|
|
58
|
+
>{{ localeLabels[loc] }}</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
47
61
|
</div>
|
|
48
62
|
<div v-if="open" class="rt-backdrop" @click="close" />
|
|
49
63
|
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import { useReadingMode, THEMES, THEME_LABELS, FONT_SIZES } from '../composables/useReadingMode'
|
|
4
4
|
import type { LayoutMode, FontSize } from '../composables/useReadingMode'
|
|
5
|
+
import { useI18n, LOCALE_LABELS, type Locale } from '../composables/useI18n'
|
|
5
6
|
|
|
6
7
|
defineProps<{
|
|
7
8
|
context?: string
|
|
@@ -16,6 +17,7 @@ const emit = defineEmits<{
|
|
|
16
17
|
}>()
|
|
17
18
|
|
|
18
19
|
const { theme, layout, mainFontSize, bodyFontSize, setTheme, setLayout, setMainFontSize, setBodyFontSize } = useReadingMode()
|
|
20
|
+
const { t, setLocale, locale, availableLocales, localeLabels } = useI18n()
|
|
19
21
|
const settingsOpen = ref(false)
|
|
20
22
|
|
|
21
23
|
function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
@@ -56,14 +58,14 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
56
58
|
<Transition name="slide-left">
|
|
57
59
|
<div v-if="settingsOpen" class="sn-settings" @click.stop>
|
|
58
60
|
<div class="ss-group">
|
|
59
|
-
<div class="ss-label"
|
|
61
|
+
<div class="ss-label">{{ t('settings.layout') }}</div>
|
|
60
62
|
<div class="ss-options">
|
|
61
|
-
<button class="ss-opt" :class="{ active: layout === 'horizontal' }" @click="setLayout('horizontal' as LayoutMode)"
|
|
62
|
-
<button class="ss-opt" :class="{ active: layout === 'vertical' }" @click="setLayout('vertical' as LayoutMode)"
|
|
63
|
+
<button class="ss-opt" :class="{ active: layout === 'horizontal' }" @click="setLayout('horizontal' as LayoutMode)">{{ t('settings.horizontal') }}</button>
|
|
64
|
+
<button class="ss-opt" :class="{ active: layout === 'vertical' }" @click="setLayout('vertical' as LayoutMode)">{{ t('settings.vertical') }}</button>
|
|
63
65
|
</div>
|
|
64
66
|
</div>
|
|
65
67
|
<div class="ss-group">
|
|
66
|
-
<div class="ss-label"
|
|
68
|
+
<div class="ss-label">{{ t('settings.theme') }}</div>
|
|
67
69
|
<div class="ss-options">
|
|
68
70
|
<button
|
|
69
71
|
v-for="t in THEMES"
|
|
@@ -75,7 +77,7 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
75
77
|
</div>
|
|
76
78
|
</div>
|
|
77
79
|
<div class="ss-group">
|
|
78
|
-
<div class="ss-label"
|
|
80
|
+
<div class="ss-label">{{ t('settings.mainFontSize') }}</div>
|
|
79
81
|
<div class="ss-size-row">
|
|
80
82
|
<button class="ss-size-btn" @click="setMainFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(mainFontSize) - 1)] as FontSize)">−</button>
|
|
81
83
|
<span class="ss-size-val">{{ mainFontSize }}</span>
|
|
@@ -83,13 +85,25 @@ function toggleSettings() { settingsOpen.value = !settingsOpen.value }
|
|
|
83
85
|
</div>
|
|
84
86
|
</div>
|
|
85
87
|
<div class="ss-group">
|
|
86
|
-
<div class="ss-label"
|
|
88
|
+
<div class="ss-label">{{ t('settings.bodyFontSize') }}</div>
|
|
87
89
|
<div class="ss-size-row">
|
|
88
90
|
<button class="ss-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.max(0, FONT_SIZES.indexOf(bodyFontSize) - 1)] as FontSize)">−</button>
|
|
89
91
|
<span class="ss-size-val">{{ bodyFontSize }}</span>
|
|
90
92
|
<button class="ss-size-btn" @click="setBodyFontSize(FONT_SIZES[Math.min(FONT_SIZES.length - 1, FONT_SIZES.indexOf(bodyFontSize) + 1)] as FontSize)">+</button>
|
|
91
93
|
</div>
|
|
92
94
|
</div>
|
|
95
|
+
<div class="ss-group">
|
|
96
|
+
<div class="ss-label">{{ t('settings.language') }}</div>
|
|
97
|
+
<div class="ss-options">
|
|
98
|
+
<button
|
|
99
|
+
v-for="loc in availableLocales"
|
|
100
|
+
:key="loc"
|
|
101
|
+
class="ss-opt"
|
|
102
|
+
:class="{ active: locale === loc }"
|
|
103
|
+
@click="setLocale(loc as Locale)"
|
|
104
|
+
>{{ localeLabels[loc] }}</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
93
107
|
</div>
|
|
94
108
|
</Transition>
|
|
95
109
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Annotation } from '../types'
|
|
2
|
+
import { useAnnotationTooltip } from './useAnnotationRenderer'
|
|
3
|
+
|
|
4
|
+
function isMobile(): boolean {
|
|
5
|
+
return typeof window !== 'undefined' && window.innerWidth < 768
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useAnnotationInteraction() {
|
|
9
|
+
const tooltip = useAnnotationTooltip()
|
|
10
|
+
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
|
11
|
+
|
|
12
|
+
function cancelHide() {
|
|
13
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function scheduleHide(delay = 300) {
|
|
17
|
+
cancelHide()
|
|
18
|
+
hideTimer = setTimeout(() => { tooltip.hide(); hideTimer = null }, delay)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function onHover(event: MouseEvent, annotations: Annotation[]) {
|
|
22
|
+
if (isMobile()) return
|
|
23
|
+
cancelHide()
|
|
24
|
+
tooltip.show(event, annotations)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onLeave() {
|
|
28
|
+
if (!isMobile()) scheduleHide()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function onTap(event: MouseEvent, annotations: Annotation[]) {
|
|
32
|
+
cancelHide()
|
|
33
|
+
tooltip.toggle(event, annotations)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onTooltipEnter() {
|
|
37
|
+
cancelHide()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onTooltipLeave() {
|
|
41
|
+
if (!isMobile()) scheduleHide()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dismiss() {
|
|
45
|
+
cancelHide()
|
|
46
|
+
tooltip.hide()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
visible: tooltip.visible,
|
|
51
|
+
items: tooltip.items,
|
|
52
|
+
style: tooltip.style,
|
|
53
|
+
onHover,
|
|
54
|
+
onLeave,
|
|
55
|
+
onTap,
|
|
56
|
+
onTooltipEnter,
|
|
57
|
+
onTooltipLeave,
|
|
58
|
+
dismiss,
|
|
59
|
+
}
|
|
60
|
+
}
|