Enhance MapView component with new features and icons

- Added fullscreen and measurement mode buttons for improved map interaction.
- Introduced a bookmarks section to save and navigate to locations easily.
- Implemented a search feature for quick access to coordinates and markers.
- Added keyboard shortcuts overlay for enhanced usability.
- Refactored MapControls and MapControlsContent to support new functionalities.
- Introduced new icon components for better visual representation in the UI.
This commit is contained in:
2026-03-01 16:00:25 +03:00
parent 945b803dba
commit 7f990c0c11
17 changed files with 825 additions and 4 deletions

View File

@@ -0,0 +1,191 @@
<template>
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-search class="size-3.5 opacity-80" aria-hidden="true" />
Search
</h3>
<div class="relative">
<input
ref="inputRef"
v-model="query"
type="text"
class="input input-sm w-full pr-8 focus:ring-2 focus:ring-primary"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
placeholder="Coords (x, y) or marker name"
autocomplete="off"
@focus="showDropdown = true"
@blur="scheduleCloseDropdown"
@keydown.enter="onEnter"
@keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)"
/>
<button
v-if="query"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
aria-label="Clear"
@mousedown.prevent="query = ''; showDropdown = false"
>
×
</button>
<div
v-if="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
class="absolute left-0 right-0 top-full z-[600] mt-1 max-h-56 overflow-y-auto rounded-lg border border-base-300 bg-base-100 shadow-xl py-1"
>
<template v-if="suggestions.length > 0">
<button
v-for="(s, i) in suggestions"
:key="s.key"
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
:class="{ 'bg-primary/10': i === highlightIndex }"
@mousedown.prevent="goToSuggestion(s)"
>
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
<span class="truncate">{{ s.label }}</span>
</button>
</template>
<template v-else-if="recent.recent.length > 0">
<p class="px-3 py-1 text-xs text-base-content/60 uppercase tracking-wider">Recent</p>
<button
v-for="(r, i) in recent.recent"
:key="r.at"
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
:class="{ 'bg-primary/10': i === highlightIndex }"
@mousedown.prevent="goToRecent(r)"
>
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
<span class="truncate">{{ r.label || `${r.mapId} · ${r.x}, ${r.y}` }}</span>
</button>
</template>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
import { useMapNavigate } from '~/composables/useMapNavigate'
import { useRecentLocations } from '~/composables/useRecentLocations'
const props = withDefaults(
defineProps<{
maps: MapInfo[]
questGivers: Array<{ id: number; name: string }>
currentMapId: number
currentCoords: { x: number; y: number; z: number } | null
touchFriendly?: boolean
}>(),
{ touchFriendly: false }
)
const { goToCoords } = useMapNavigate()
const recent = useRecentLocations()
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const showDropdown = ref(false)
const highlightIndex = ref(0)
let closeDropdownTimer: ReturnType<typeof setTimeout> | null = null
interface Suggestion {
key: string
label: string
mapId: number
x: number
y: number
zoom?: number
markerId?: number
}
const suggestions = computed<Suggestion[]>(() => {
const q = query.value.trim().toLowerCase()
if (!q) return []
const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/)
if (coordMatch) {
const x = parseInt(coordMatch[1], 10)
const y = parseInt(coordMatch[2], 10)
return [
{
key: `coord-${x}-${y}`,
label: `Go to ${x}, ${y}`,
mapId: props.currentMapId,
x,
y,
zoom: props.currentCoords?.z,
},
]
}
const list: Suggestion[] = []
for (const qg of props.questGivers) {
if (qg.name.toLowerCase().includes(q)) {
list.push({
key: `qg-${qg.id}`,
label: qg.name,
mapId: props.currentMapId,
x: 0,
y: 0,
zoom: undefined,
markerId: qg.id,
})
}
}
if (list.length > 0) return list.slice(0, 8)
return []
})
function scheduleCloseDropdown() {
closeDropdownTimer = setTimeout(() => {
showDropdown.value = false
closeDropdownTimer = null
}, 150)
}
function moveHighlight(delta: number) {
const total = suggestions.value.length || recent.recent.length
if (total === 0) return
highlightIndex.value = (highlightIndex.value + delta + total) % total
}
function goToSuggestion(s: Suggestion) {
showDropdown.value = false
if (s.key.startsWith('coord-')) {
goToCoords(s.mapId, s.x, s.y, s.zoom)
recent.push({ mapId: s.mapId, x: s.x, y: s.y, zoom: s.zoom, label: `${s.x}, ${s.y}` })
} else if (s.markerId != null) {
emit('jump-to-marker', s.markerId)
}
}
function goToRecent(r: (typeof recent.recent.value)[0]) {
showDropdown.value = false
goToCoords(r.mapId, r.x, r.y, r.zoom)
}
function onEnter() {
if (suggestions.value.length > 0) {
const s = suggestions.value[highlightIndex.value] ?? suggestions.value[0]
goToSuggestion(s)
} else if (recent.recent.length > 0) {
const r = recent.recent[highlightIndex.value] ?? recent.recent[0]
goToRecent(r)
}
}
const emit = defineEmits<{
'jump-to-marker': [id: number]
}>()
onMounted(() => {
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
})
onBeforeUnmount(() => {
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
useMapNavigate().setFocusSearch(null)
})
</script>