@hanology/cham-browser 0.4.13 → 0.4.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "CHAM — browser-compatible parser, serializer, and site generator for Classical Han Annotated Markdown",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -4,11 +4,10 @@ import { annotationToPronSegment } from '../utils/annotationParser'
4
4
  import PronunciationGroup from './PronunciationGroup.vue'
5
5
  import type { Annotation } from '../types'
6
6
 
7
- type DisplayMode = 'pane' | 'popup' | 'sheet'
8
-
9
7
  const props = defineProps<{
10
8
  visible: boolean
11
9
  annotations: Annotation[]
10
+ headword?: string
12
11
  layerLabels?: Record<string, string>
13
12
  style?: Record<string, string>
14
13
  vertical?: boolean
@@ -21,25 +20,14 @@ const emit = defineEmits<{
21
20
  }>()
22
21
 
23
22
  const ww = ref(typeof window !== 'undefined' ? window.innerWidth : 1024)
23
+ const isMobile = computed(() => ww.value < 768)
24
24
 
25
25
  function onResize() { ww.value = window.innerWidth }
26
26
 
27
- const mode = computed<DisplayMode>(() => {
28
- const w = ww.value
29
- if (w < 768) return 'sheet'
30
- if (w >= 1024) return 'pane'
31
- return 'popup'
32
- })
33
-
34
27
  const stickyVisible = ref(false)
35
28
 
36
29
  watch(() => props.visible, (v) => {
37
30
  if (v) stickyVisible.value = true
38
- else if (mode.value === 'popup') stickyVisible.value = false
39
- })
40
-
41
- watch(mode, () => {
42
- if (mode.value === 'popup' && !props.visible) stickyVisible.value = false
43
31
  })
44
32
 
45
33
  function dismiss() {
@@ -77,77 +65,81 @@ function kindLabel(ann: Annotation): string {
77
65
  return map[ann.kind] || ann.kind
78
66
  }
79
67
 
68
+ function dominantKind(): string {
69
+ if (!props.annotations.length) return ''
70
+ const counts: Record<string, number> = {}
71
+ for (const a of props.annotations) {
72
+ counts[a.kind] = (counts[a.kind] || 0) + 1
73
+ }
74
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]
75
+ }
76
+
80
77
  function onDocClick(e: MouseEvent) {
81
78
  if (!stickyVisible.value) return
82
79
  const el = (e.target as HTMLElement)
83
- if (el.closest('.ann-left-pane, .ann-right-pane, .ann-bottom-sheet, .ann-popup')) return
80
+ if (el.closest('.ann-card, .ann-sheet')) return
84
81
  if (el.closest('.ann-target')) return
85
82
  dismiss()
86
83
  }
87
84
 
88
85
  function onDocTouchMove(e: TouchEvent) {
89
- if (!stickyVisible.value || ww.value >= 768) return
86
+ if (!stickyVisible.value || !isMobile.value) return
90
87
  const el = (e.target as HTMLElement)
91
- if (el.closest('.ann-bottom-sheet')) return
88
+ if (el.closest('.ann-sheet')) return
92
89
  dismiss()
93
90
  }
94
91
 
92
+ function onKeydown(e: KeyboardEvent) {
93
+ if (e.key === 'Escape' && stickyVisible.value) {
94
+ e.preventDefault()
95
+ dismiss()
96
+ }
97
+ }
98
+
95
99
  onMounted(() => {
96
100
  window.addEventListener('resize', onResize, { passive: true })
97
101
  document.addEventListener('click', onDocClick, true)
98
102
  document.addEventListener('touchmove', onDocTouchMove, { passive: true })
103
+ document.addEventListener('keydown', onKeydown)
99
104
  })
100
105
 
101
106
  onBeforeUnmount(() => {
102
107
  window.removeEventListener('resize', onResize)
103
108
  document.removeEventListener('click', onDocClick, true)
104
109
  document.removeEventListener('touchmove', onDocTouchMove)
110
+ document.removeEventListener('keydown', onKeydown)
105
111
  })
106
112
  </script>
107
113
 
108
114
  <template>
109
115
  <Teleport to="body">
110
- <!-- Desktop pane: LEFT for horizontal, RIGHT for vertical -->
111
- <Transition :name="vertical ? 'ann-slide-left' : 'ann-slide-right'">
116
+ <!-- Desktop/Tablet floating card -->
117
+ <Transition name="ann-pop">
112
118
  <div
113
- v-if="mode === 'pane' && stickyVisible && annotations.length"
114
- :class="vertical ? 'ann-right-pane' : 'ann-left-pane'"
119
+ v-if="!isMobile && stickyVisible && annotations.length"
120
+ class="ann-card"
121
+ :style="style"
115
122
  @mouseenter="emit('tooltipEnter')"
116
123
  @mouseleave="emit('tooltipLeave')"
117
124
  >
118
- <button class="ann-pane-close" @click="dismiss" aria-label="關閉">
119
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
125
+ <div v-if="headword" class="ann-card-head" :class="dominantKind()">
126
+ <span class="ann-headword">{{ headword }}</span>
127
+ <span class="ann-badge-count" v-if="annotations.length > 1">{{ annotations.length }}</span>
128
+ </div>
129
+ <button class="ann-card-close" @click="dismiss" aria-label="關閉">
130
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 6L6 18M6 6l12 12"/></svg>
120
131
  </button>
121
- <div class="ann-pane-scroll">
122
- <div v-for="ann in annotations" :key="ann.id" class="ann-entry" :class="ann.kind">
123
- <div class="ann-head">
132
+ <div class="ann-card-scroll">
133
+ <div v-for="ann in annotations" :key="ann.id" class="ann-entry">
134
+ <div class="ann-entry-header">
124
135
  <span class="ann-kind" :class="ann.kind">{{ kindLabel(ann) }}</span>
125
136
  <span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
126
137
  </div>
127
- <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
128
- <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
129
- </div>
130
- </div>
131
- </div>
132
- </Transition>
133
-
134
- <!-- Tablet popup -->
135
- <Transition name="ann-fade">
136
- <div
137
- v-if="mode === 'popup' && visible && annotations.length"
138
- class="ann-popup"
139
- :class="{ 'ann-popup--vertical': vertical }"
140
- :style="style"
141
- @mouseenter="emit('tooltipEnter')"
142
- @mouseleave="emit('tooltipLeave')"
143
- >
144
- <div v-for="ann in annotations" :key="ann.id" class="ann-entry" :class="ann.kind">
145
- <div class="ann-head">
146
- <span class="ann-kind" :class="ann.kind">{{ kindLabel(ann) }}</span>
147
- <span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
138
+ <div class="ann-entry-body">
139
+ <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
140
+ <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
141
+ </div>
148
142
  </div>
149
- <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
150
- <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
151
143
  </div>
152
144
  </div>
153
145
  </Transition>
@@ -155,20 +147,26 @@ onBeforeUnmount(() => {
155
147
  <!-- Mobile bottom sheet -->
156
148
  <Transition name="ann-sheet">
157
149
  <div
158
- v-if="mode === 'sheet' && stickyVisible && annotations.length"
159
- class="ann-bottom-sheet"
150
+ v-if="isMobile && stickyVisible && annotations.length"
151
+ class="ann-sheet"
160
152
  >
161
153
  <button class="ann-sheet-handle" @click="dismiss">
162
154
  <span class="ann-handle-bar" />
163
155
  </button>
156
+ <div v-if="headword" class="ann-sheet-head" :class="dominantKind()">
157
+ <span class="ann-headword">{{ headword }}</span>
158
+ <span class="ann-badge-count" v-if="annotations.length > 1">{{ annotations.length }}</span>
159
+ </div>
164
160
  <div class="ann-sheet-scroll">
165
- <div v-for="ann in annotations" :key="ann.id" class="ann-entry" :class="ann.kind">
166
- <div class="ann-head">
161
+ <div v-for="ann in annotations" :key="ann.id" class="ann-entry">
162
+ <div class="ann-entry-header">
167
163
  <span class="ann-kind" :class="ann.kind">{{ kindLabel(ann) }}</span>
168
164
  <span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
169
165
  </div>
170
- <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
171
- <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
166
+ <div class="ann-entry-body">
167
+ <PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
168
+ <div v-if="ann.text && ann.kind !== 'pronunciation'" class="ann-text">{{ ann.text }}</div>
169
+ </div>
172
170
  </div>
173
171
  </div>
174
172
  </div>
@@ -177,7 +175,38 @@ onBeforeUnmount(() => {
177
175
  </template>
178
176
 
179
177
  <style scoped>
180
- /* ─── Compact annotation entry ─── */
178
+ /* ─── Headword header ─── */
179
+ .ann-card-head,
180
+ .ann-sheet-head {
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: space-between;
184
+ padding: 10px 14px;
185
+ border-bottom: 1px solid var(--border-light);
186
+ background: var(--surface);
187
+ }
188
+
189
+ .ann-headword {
190
+ font-family: var(--serif);
191
+ font-size: 22px;
192
+ font-weight: 900;
193
+ letter-spacing: 4px;
194
+ color: var(--ink);
195
+ }
196
+
197
+ .ann-badge-count {
198
+ font-family: var(--sans);
199
+ font-size: 11px;
200
+ font-weight: 700;
201
+ color: var(--ink-faint);
202
+ background: var(--surface-warm);
203
+ border: 1px solid var(--border-light);
204
+ border-radius: 10px;
205
+ padding: 2px 8px;
206
+ letter-spacing: 0;
207
+ }
208
+
209
+ /* ─── Annotation entry ─── */
181
210
  .ann-entry {
182
211
  padding: 10px 0;
183
212
  border-bottom: 1px solid var(--border-light);
@@ -189,12 +218,15 @@ onBeforeUnmount(() => {
189
218
  .ann-entry:last-child { border-bottom: none; padding-bottom: 0; }
190
219
  .ann-entry:first-child { padding-top: 0; }
191
220
 
192
- .ann-head {
221
+ .ann-entry-header {
193
222
  display: inline-flex;
194
223
  align-items: center;
195
224
  gap: 6px;
196
- margin-bottom: 2px;
197
- vertical-align: baseline;
225
+ margin-bottom: 3px;
226
+ }
227
+
228
+ .ann-entry-body {
229
+ padding-left: 2px;
198
230
  }
199
231
 
200
232
  .ann-kind {
@@ -206,7 +238,6 @@ onBeforeUnmount(() => {
206
238
  font-weight: 700;
207
239
  letter-spacing: 1px;
208
240
  line-height: 1.5;
209
- vertical-align: middle;
210
241
  }
211
242
  .ann-kind.pronunciation { background: var(--jade); color: #fff; }
212
243
  .ann-kind.semantic { background: var(--vermillion); color: #fff; }
@@ -229,7 +260,6 @@ onBeforeUnmount(() => {
229
260
  border: 1px solid var(--border-light);
230
261
  border-radius: 2px;
231
262
  letter-spacing: 0.5px;
232
- line-height: 1.4;
233
263
  }
234
264
 
235
265
  .ann-text {
@@ -237,128 +267,79 @@ onBeforeUnmount(() => {
237
267
  line-height: 1.8;
238
268
  }
239
269
 
240
- /* ─── Desktop Left Pane (horizontal mode) ─── */
241
- .ann-left-pane {
270
+ /* ─── Floating card ─── */
271
+ .ann-card {
242
272
  position: fixed;
243
- top: 72px;
244
- left: 20px;
245
- width: 280px;
246
- max-height: calc(100vh - 100px);
247
273
  background: var(--surface-warm);
248
274
  border: 1px solid var(--border);
249
275
  border-radius: 10px;
250
- box-shadow: 0 8px 40px rgba(var(--shadow-rgb), 0.12);
276
+ box-shadow: 0 12px 48px rgba(var(--shadow-rgb), 0.2), 0 2px 8px rgba(var(--shadow-rgb), 0.06);
251
277
  z-index: 1000;
252
278
  display: flex;
253
279
  flex-direction: column;
280
+ overflow: hidden;
281
+ backdrop-filter: blur(8px);
282
+ -webkit-backdrop-filter: blur(8px);
254
283
  }
255
284
 
256
- .ann-pane-close {
285
+ .ann-card-close {
257
286
  position: absolute;
258
287
  top: 8px;
259
288
  right: 8px;
260
- width: 24px;
261
- height: 24px;
289
+ width: 22px;
290
+ height: 22px;
262
291
  border: none;
263
292
  border-radius: 4px;
264
- background: var(--surface);
293
+ background: transparent;
265
294
  color: var(--ink-faint);
266
295
  cursor: pointer;
267
296
  display: flex;
268
297
  align-items: center;
269
298
  justify-content: center;
270
299
  transition: all 0.15s;
300
+ opacity: 0;
271
301
  z-index: 1;
272
- opacity: 0.6;
273
302
  }
274
- .ann-pane-close:hover {
275
- opacity: 1;
303
+ .ann-card:hover .ann-card-close,
304
+ .ann-card-close:focus-visible {
305
+ opacity: 0.5;
306
+ }
307
+ .ann-card-close:hover {
308
+ opacity: 1 !important;
276
309
  background: var(--ink);
277
310
  color: var(--paper);
278
311
  }
279
312
 
280
- .ann-pane-scroll {
281
- padding: 12px 14px;
313
+ .ann-card-scroll {
314
+ padding: 10px 14px;
282
315
  overflow-y: auto;
283
316
  overscroll-behavior: contain;
284
317
  flex: 1;
285
318
  }
286
319
 
287
- /* ─── Desktop Right Pane (vertical mode) ─── */
288
- .ann-right-pane {
289
- position: fixed;
290
- top: 72px;
291
- right: 20px;
292
- height: calc(100vh - 100px);
293
- width: auto;
294
- max-width: 280px;
295
- writing-mode: vertical-rl;
296
- text-orientation: mixed;
297
- background: var(--surface-warm);
298
- border: 1px solid var(--border);
299
- border-radius: 10px;
300
- box-shadow: 0 8px 40px rgba(var(--shadow-rgb), 0.12);
301
- z-index: 1000;
302
- display: flex;
303
- flex-direction: column;
304
- }
305
- .ann-right-pane .ann-pane-close {
306
- position: static;
307
- margin: 8px 8px 0;
308
- opacity: 0.5;
309
- flex-shrink: 0;
310
- }
311
- .ann-right-pane .ann-pane-scroll {
312
- writing-mode: vertical-rl;
313
- text-orientation: mixed;
314
- overflow-x: auto;
315
- overflow-y: hidden;
316
- padding: 12px 14px;
317
- flex: 1;
320
+ /* Card transition */
321
+ .ann-pop-enter-active {
322
+ transition: opacity 0.15s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
318
323
  }
319
- .ann-right-pane .ann-entry {
320
- writing-mode: vertical-rl;
321
- text-orientation: mixed;
322
- border-bottom: none;
323
- border-left: 1px solid var(--border-light);
324
- padding: 0 0 0 12px;
325
- margin-left: 8px;
326
- }
327
- .ann-right-pane .ann-entry:first-child { padding-top: 0; }
328
- .ann-right-pane .ann-entry:last-child { border-left: none; }
329
- .ann-right-pane .ann-head {
330
- flex-direction: column;
331
- align-items: flex-start;
332
- margin-bottom: 0;
333
- margin-left: 4px;
324
+ .ann-pop-leave-active {
325
+ transition: opacity 0.1s ease, transform 0.1s ease;
334
326
  }
335
- .ann-right-pane .ann-text {
336
- writing-mode: vertical-rl;
337
- text-orientation: mixed;
338
- line-height: 2;
327
+ .ann-pop-enter-from {
328
+ opacity: 0;
329
+ transform: scale(0.96) translateY(4px);
339
330
  }
340
-
341
- /* ─── Tablet Popup ─── */
342
- .ann-popup {
343
- position: fixed;
344
- padding: 12px 14px;
345
- background: var(--surface-warm);
346
- border: 1px solid var(--border);
347
- border-radius: 8px;
348
- box-shadow: 0 16px 48px rgba(var(--shadow-rgb), 0.16);
349
- max-width: 300px;
350
- max-height: 50vh;
351
- overflow-y: auto;
352
- z-index: 1000;
331
+ .ann-pop-leave-to {
332
+ opacity: 0;
333
+ transform: scale(0.98);
353
334
  }
354
335
 
355
- /* ─── Mobile Bottom Sheet ─── */
356
- .ann-bottom-sheet {
336
+ /* ─── Mobile bottom sheet ─── */
337
+ .ann-sheet {
357
338
  position: fixed;
358
339
  left: 0;
359
340
  right: 0;
360
341
  bottom: 0;
361
- max-height: 50vh;
342
+ max-height: 60vh;
362
343
  background: var(--surface-warm);
363
344
  border-top: 1px solid var(--border);
364
345
  border-radius: 14px 14px 0 0;
@@ -394,53 +375,7 @@ onBeforeUnmount(() => {
394
375
  flex: 1;
395
376
  }
396
377
 
397
- /* ─── Transitions ─── */
398
-
399
- .ann-slide-right-enter-active {
400
- transition: opacity 0.2s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
401
- }
402
- .ann-slide-right-leave-active {
403
- transition: opacity 0.15s ease, transform 0.15s ease;
404
- }
405
- .ann-slide-right-enter-from {
406
- opacity: 0;
407
- transform: translateX(-20px);
408
- }
409
- .ann-slide-right-leave-to {
410
- opacity: 0;
411
- transform: translateX(-12px);
412
- }
413
-
414
- .ann-slide-left-enter-active {
415
- transition: opacity 0.2s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
416
- }
417
- .ann-slide-left-leave-active {
418
- transition: opacity 0.15s ease, transform 0.15s ease;
419
- }
420
- .ann-slide-left-enter-from {
421
- opacity: 0;
422
- transform: translateX(20px);
423
- }
424
- .ann-slide-left-leave-to {
425
- opacity: 0;
426
- transform: translateX(12px);
427
- }
428
-
429
- .ann-fade-enter-active {
430
- transition: opacity 0.15s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
431
- }
432
- .ann-fade-leave-active {
433
- transition: opacity 0.15s ease, transform 0.15s ease;
434
- }
435
- .ann-fade-enter-from {
436
- opacity: 0;
437
- transform: scale(0.94) translateY(4px);
438
- }
439
- .ann-fade-leave-to {
440
- opacity: 0;
441
- transform: scale(0.96);
442
- }
443
-
378
+ /* Sheet transition */
444
379
  .ann-sheet-enter-active {
445
380
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
446
381
  }
@@ -452,11 +387,22 @@ onBeforeUnmount(() => {
452
387
  transform: translateY(100%);
453
388
  }
454
389
 
455
- @media (max-width: 1023px) {
456
- .ann-left-pane, .ann-right-pane { display: none; }
390
+ /* ─── Active annotation on page ─── */
391
+ :global(.ann-target.ann-active) {
392
+ background: rgba(194, 58, 43, 0.12) !important;
393
+ box-shadow: 0 0 0 2px rgba(194, 58, 43, 0.15);
394
+ border-radius: 2px;
395
+ }
396
+ :global(.ann-target.ann-active.pronunciation) {
397
+ background: rgba(58, 107, 94, 0.12) !important;
398
+ box-shadow: 0 0 0 2px rgba(58, 107, 94, 0.15);
399
+ }
400
+ :global(.ann-target.ann-active.person) {
401
+ background: rgba(58, 90, 140, 0.12) !important;
402
+ box-shadow: 0 0 0 2px rgba(58, 90, 140, 0.15);
457
403
  }
458
404
 
459
- @media (min-width: 1024px) {
460
- .ann-bottom-sheet { display: none; }
405
+ @media (min-width: 768px) {
406
+ .ann-sheet { display: none; }
461
407
  }
462
408
  </style>
@@ -8,19 +8,28 @@ function isMobile(): boolean {
8
8
  export function useAnnotationInteraction() {
9
9
  const tooltip = useAnnotationTooltip()
10
10
  let hideTimer: ReturnType<typeof setTimeout> | null = null
11
+ let activeEl: HTMLElement | null = null
11
12
 
12
13
  function cancelHide() {
13
14
  if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
14
15
  }
15
16
 
17
+ function setActive(el: HTMLElement | null) {
18
+ if (activeEl) activeEl.classList.remove('ann-active')
19
+ activeEl = el
20
+ if (activeEl) activeEl.classList.add('ann-active')
21
+ }
22
+
16
23
  function scheduleHide(delay = 300) {
17
24
  cancelHide()
18
- hideTimer = setTimeout(() => { tooltip.hide(); hideTimer = null }, delay)
25
+ hideTimer = setTimeout(() => { tooltip.hide(); setActive(null); hideTimer = null }, delay)
19
26
  }
20
27
 
21
28
  function onHover(event: MouseEvent, annotations: Annotation[]) {
22
29
  if (isMobile()) return
23
30
  cancelHide()
31
+ const el = (event.target as HTMLElement).closest('.ann-target') as HTMLElement | null
32
+ setActive(el)
24
33
  tooltip.show(event, annotations)
25
34
  }
26
35
 
@@ -30,6 +39,8 @@ export function useAnnotationInteraction() {
30
39
 
31
40
  function onTap(event: MouseEvent, annotations: Annotation[]) {
32
41
  cancelHide()
42
+ const el = (event.target as HTMLElement).closest('.ann-target') as HTMLElement | null
43
+ setActive(el)
33
44
  tooltip.toggle(event, annotations)
34
45
  }
35
46
 
@@ -43,12 +54,14 @@ export function useAnnotationInteraction() {
43
54
 
44
55
  function dismiss() {
45
56
  cancelHide()
57
+ setActive(null)
46
58
  tooltip.hide()
47
59
  }
48
60
 
49
61
  return {
50
62
  visible: tooltip.visible,
51
63
  items: tooltip.items,
64
+ headword: tooltip.headword,
52
65
  style: tooltip.style,
53
66
  onHover,
54
67
  onLeave,
@@ -132,6 +132,7 @@ export function useAnnotationTooltip() {
132
132
  const visible = ref(false)
133
133
  const items = ref<Annotation[]>([])
134
134
  const style = ref<Record<string, string>>({})
135
+ const headword = ref('')
135
136
  const { layout } = useReadingMode()
136
137
 
137
138
  function show(event: MouseEvent, annotations: Annotation[]) {
@@ -139,51 +140,66 @@ export function useAnnotationTooltip() {
139
140
  const el = (event.target as HTMLElement).closest('.ann-target') as HTMLElement | null
140
141
  const rect = (el ?? event.target as HTMLElement).getBoundingClientRect()
141
142
 
142
- if (layout.value === 'vertical') {
143
- const isMobile = window.innerWidth < 768
144
- if (isMobile) {
145
- style.value = {
146
- left: '4vw',
147
- right: '4vw',
148
- bottom: '0',
149
- maxWidth: 'none',
150
- }
151
- } else if (window.innerWidth >= 1024) {
152
- style.value = {
153
- right: '20px',
154
- top: '72px',
155
- maxHeight: 'calc(100vh - 100px)',
156
- }
157
- } else {
158
- const right = window.innerWidth - rect.left + 8
159
- style.value = {
160
- right: Math.min(right, window.innerWidth - 40) + 'px',
161
- top: '50%',
162
- transform: 'translateY(-50%)',
163
- }
143
+ // Extract headword text (strip the annotation number)
144
+ if (el) {
145
+ const clone = el.cloneNode(true) as HTMLElement
146
+ const nums = clone.querySelectorAll('.ann-num, sup')
147
+ nums.forEach(n => n.remove())
148
+ headword.value = clone.textContent?.trim() || ''
149
+ } else {
150
+ headword.value = ''
151
+ }
152
+ const vw = window.innerWidth
153
+ const vh = window.innerHeight
154
+
155
+ if (vw < 768) {
156
+ // Mobile: bottom sheet
157
+ style.value = {
158
+ left: '0',
159
+ right: '0',
160
+ bottom: '0',
161
+ }
162
+ } else if (layout.value === 'vertical') {
163
+ // Vertical mode: card to the left of the annotation
164
+ const cardW = 340
165
+ const gap = 12
166
+ let left = Math.max(8, rect.left - cardW - gap)
167
+ let top = Math.max(8, Math.min(rect.top, vh - 300))
168
+
169
+ // If not enough room on left, go right
170
+ if (rect.left - cardW - gap < 8) {
171
+ left = Math.min(rect.right + gap, vw - cardW - 8)
172
+ }
173
+
174
+ style.value = {
175
+ left: left + 'px',
176
+ top: top + 'px',
177
+ width: cardW + 'px',
178
+ maxHeight: Math.min(vh - 16, 500) + 'px',
164
179
  }
165
180
  } else {
166
- const isMobile = window.innerWidth < 768
167
- if (isMobile) {
168
- style.value = {
169
- left: '4vw',
170
- right: '4vw',
171
- bottom: '0',
172
- maxWidth: 'none',
173
- }
174
- } else {
175
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 288))
176
- const top = Math.max(8, rect.bottom + 8)
177
- style.value = {
178
- left: left + 'px',
179
- top: Math.min(top, window.innerHeight - 200) + 'px',
180
- }
181
+ // Horizontal mode: card below/above the annotation
182
+ const cardW = 340
183
+ const gap = 8
184
+ let left = Math.max(8, Math.min(rect.left, vw - cardW - 8))
185
+ let top = rect.bottom + gap
186
+
187
+ // If overflows bottom, show above
188
+ if (top + 200 > vh) {
189
+ top = Math.max(8, rect.top - gap - 200)
190
+ }
191
+
192
+ style.value = {
193
+ left: left + 'px',
194
+ top: top + 'px',
195
+ width: cardW + 'px',
196
+ maxHeight: Math.min(vh - top - 16, 500) + 'px',
181
197
  }
182
198
  }
183
199
  visible.value = true
184
200
  }
185
201
 
186
- function hide() { visible.value = false }
202
+ function hide() { visible.value = false; headword.value = '' }
187
203
  function toggle(event: MouseEvent, annotations: Annotation[]) {
188
204
  if (visible.value) {
189
205
  const currentIds = items.value.map(a => a.id).sort().join(',')
@@ -198,5 +214,5 @@ export function useAnnotationTooltip() {
198
214
  }
199
215
  }
200
216
 
201
- return { visible, items, style, show, hide, toggle }
217
+ return { visible, items, style, headword, show, hide, toggle }
202
218
  }
@@ -434,6 +434,7 @@ function tcy(n: number): string {
434
434
  <AnnotationTooltip
435
435
  :visible="interaction.visible"
436
436
  :annotations="interaction.items"
437
+ :headword="interaction.headword"
437
438
  :layer-labels="layerLabels"
438
439
  :style="interaction.style"
439
440
  :vertical="true"
@@ -441,10 +442,6 @@ function tcy(n: number): string {
441
442
  @tooltip-enter="interaction.onTooltipEnter"
442
443
  @tooltip-leave="interaction.onTooltipLeave"
443
444
  />
444
-
445
- <Teleport to="body">
446
- <Transition name="overlay">
447
- <div v-if="authorPaneOpen" class="v-overlay" @click="closeAuthorPane">
448
445
  <div class="v-author-pane" @click.stop>
449
446
  <button class="v-pane-close" @click="closeAuthorPane">✕</button>
450
447
  <div class="v-pane-header">
@@ -602,6 +599,7 @@ function tcy(n: number): string {
602
599
  <AnnotationTooltip
603
600
  :visible="interaction.visible"
604
601
  :annotations="interaction.items"
602
+ :headword="interaction.headword"
605
603
  :layer-labels="layerLabels"
606
604
  :style="interaction.style"
607
605
  :vertical="false"