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,96 @@
<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-bookmark class="size-3.5 opacity-80" aria-hidden="true" />
Saved locations
</h3>
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
<template v-if="bookmarks.length === 0">
<p class="text-xs text-base-content/60 py-1">No saved locations.</p>
</template>
<template v-else>
<div
v-for="b in bookmarks"
:key="b.id"
class="flex items-center gap-2 group rounded-lg hover:bg-base-200/50 px-2 py-1.5 -mx-2"
>
<button
type="button"
class="btn btn-ghost btn-xs flex-1 min-w-0 justify-start text-left truncate font-normal"
:title="`Go to ${b.name}`"
@click="onGoTo(b)"
>
<span class="truncate">{{ b.name }}</span>
</button>
<button
type="button"
class="btn btn-ghost btn-xs btn-square shrink-0 opacity-70 hover:opacity-100 hover:text-error"
aria-label="Remove bookmark"
@click="onRemove(b.id)"
>
×
</button>
</div>
</template>
</div>
<button
type="button"
class="btn btn-outline btn-sm w-full"
:class="touchFriendly ? 'min-h-11' : ''"
:disabled="!canAddCurrent"
title="Save current map position"
@click="onAddCurrent"
>
<icons-icon-plus class="size-4" />
Add current location
</button>
</section>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
import { useMapBookmarks } from '~/composables/useMapBookmarks'
import { useMapNavigate } from '~/composables/useMapNavigate'
const props = withDefaults(
defineProps<{
maps: MapInfo[]
currentMapId: number | null
currentCoords: { x: number; y: number; z: number } | null
touchFriendly?: boolean
}>(),
{ touchFriendly: false }
)
const { bookmarks, add, remove } = useMapBookmarks()
const { goToCoords } = useMapNavigate()
const canAddCurrent = computed(
() =>
props.currentMapId != null &&
props.currentCoords != null &&
props.currentMapId >= 0
)
function onGoTo(b: { mapId: number; x: number; y: number; zoom?: number }) {
goToCoords(b.mapId, b.x, b.y, b.zoom)
}
function onRemove(id: string) {
remove(id)
}
function onAddCurrent() {
if (!canAddCurrent.value) return
const mapId = props.currentMapId!
const { x, y, z } = props.currentCoords!
const mapName = props.maps.find((m) => m.ID === mapId)?.Name ?? `Map ${mapId}`
add({
name: `${mapName} ${x}, ${y}`,
mapId,
x,
y,
zoom: z,
})
}
</script>

View File

@@ -50,9 +50,12 @@
:maps="maps"
:quest-givers="questGivers"
:players="players"
:current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords"
@zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')"
@jump-to-marker="$emit('jumpToMarker', $event)"
/>
</div>
</Transition>
@@ -138,10 +141,13 @@
:maps="maps"
:quest-givers="questGivers"
:players="players"
:current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords"
:touch-friendly="true"
@zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')"
@jump-to-marker="$emit('jumpToMarker', $event)"
/>
</div>
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
@@ -178,14 +184,17 @@ const props = withDefaults(
maps: MapInfo[]
questGivers: QuestGiver[]
players: Player[]
currentMapId?: number | null
currentCoords?: { x: number; y: number; z: number } | null
}>(),
{ maps: () => [], questGivers: () => [], players: () => [] }
{ maps: () => [], questGivers: () => [], players: () => [], currentMapId: null, currentCoords: null }
)
defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
jumpToMarker: [id: number]
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })

View File

@@ -1,5 +1,15 @@
<template>
<div class="flex flex-col gap-4">
<!-- Search -->
<MapSearch
v-if="currentMapId != null && currentCoords != null"
:maps="maps"
:quest-givers="questGivers"
:current-map-id="currentMapId"
:current-coords="currentCoords"
:touch-friendly="touchFriendly"
@jump-to-marker="$emit('jumpToMarker', $event)"
/>
<!-- Zoom -->
<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">
@@ -105,11 +115,19 @@
</select>
</fieldset>
</section>
<!-- Saved locations -->
<MapBookmarks
:maps="maps"
:current-map-id="currentMapId ?? null"
:current-coords="currentCoords ?? null"
:touch-friendly="touchFriendly"
/>
</div>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
import MapBookmarks from '~/components/map/MapBookmarks.vue'
interface QuestGiver {
id: number
@@ -131,14 +149,17 @@ const props = withDefaults(
overlayMapId: number
selectedMarkerIdSelect: string
selectedPlayerIdSelect: string
currentMapId?: number
currentCoords?: { x: number; y: number; z: number } | null
}>(),
{ touchFriendly: false }
{ touchFriendly: false, currentMapId: 0, currentCoords: null }
)
const emit = defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
jumpToMarker: [id: number]
'update:showGridCoordinates': [v: boolean]
'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string]

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>

View File

@@ -0,0 +1,84 @@
<template>
<Teleport to="body">
<Transition name="shortcuts-modal">
<div
v-if="open"
class="fixed inset-0 z-[1200] flex items-center justify-center p-4 bg-black/50"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
@click.self="$emit('close')"
>
<div
class="bg-base-100 rounded-xl shadow-2xl border border-base-300 max-w-md w-full p-6"
@click.stop
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold flex items-center gap-2">
<icons-icon-keyboard class="size-5" />
Keyboard shortcuts
</h2>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
aria-label="Close"
@click="$emit('close')"
>
×
</button>
</div>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">?</kbd></dt>
<dd>Show this help</dd>
</div>
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">+</kbd> / <kbd class="kbd kbd-sm"></kbd></dt>
<dd>Zoom in / out</dd>
</div>
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">H</kbd></dt>
<dd>Toggle markers visibility</dd>
</div>
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">G</kbd></dt>
<dd>Toggle grid coordinates</dd>
</div>
<div class="flex justify-between gap-4 py-1 border-b border-base-300/50">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">F</kbd></dt>
<dd>Focus search</dd>
</div>
<div class="flex justify-between gap-4 py-1">
<dt class="text-base-content/80"><kbd class="kbd kbd-sm">Esc</kbd></dt>
<dd>Close menus / overlay</dd>
</div>
</dl>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{ open: boolean }>()
defineEmits<{ close: [] }>()
</script>
<style scoped>
.shortcuts-modal-enter-active,
.shortcuts-modal-leave-active {
transition: opacity 0.2s ease;
}
.shortcuts-modal-enter-from,
.shortcuts-modal-leave-to {
opacity: 0;
}
.shortcuts-modal-enter-active .bg-base-100,
.shortcuts-modal-leave-active .bg-base-100 {
transition: transform 0.2s ease;
}
.shortcuts-modal-enter-from .bg-base-100,
.shortcuts-modal-leave-to .bg-base-100 {
transform: scale(0.95);
}
</style>