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:
96
frontend-nuxt/components/map/MapBookmarks.vue
Normal file
96
frontend-nuxt/components/map/MapBookmarks.vue
Normal 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>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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]
|
||||
|
||||
191
frontend-nuxt/components/map/MapSearch.vue
Normal file
191
frontend-nuxt/components/map/MapSearch.vue
Normal 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>
|
||||
84
frontend-nuxt/components/map/MapShortcutsOverlay.vue
Normal file
84
frontend-nuxt/components/map/MapShortcutsOverlay.vue
Normal 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>
|
||||
Reference in New Issue
Block a user