- 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.
192 lines
5.8 KiB
Vue
192 lines
5.8 KiB
Vue
<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>
|