@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.
@@ -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<{ close: [] }>()
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 onBackdropTouchMove() {
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 v-if="visible && annotations.length" class="ann-backdrop" @click="emit('close')" @touchmove="onBackdropTouchMove">
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
- class="ann-tooltip"
42
- :class="{ 'ann-vertical': layout === 'vertical', 'ann-mobile-bottom': isMobile }"
43
- :style="style"
44
- @click.stop
45
- @touchmove.stop
75
+ v-for="ann in annotations"
76
+ :key="ann.id"
77
+ class="ann-entry"
78
+ :class="ann.kind"
46
79
  >
47
- <button v-if="isMobile" class="ann-handle" @click="emit('close')">
48
- <span class="ann-handle-bar" />
49
- </button>
50
- <div
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">版面</div>
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
- >橫排</button>
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
- >直排</button>
34
+ >{{ t('settings.vertical') }}</button>
33
35
  </div>
34
36
  </div>
35
37
  <div class="rt-group">
36
- <div class="rt-label">主題</div>
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">版面</div>
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)">橫排</button>
62
- <button class="ss-opt" :class="{ active: layout === 'vertical' }" @click="setLayout('vertical' as LayoutMode)">直排</button>
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">主題</div>
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">正文字號</div>
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">內文字號</div>
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
+ }