Enhance map functionality with bookmark features and UI improvements

- Introduced a new MapBookmarkNameModal for adding and editing bookmarks.
- Updated MapView to manage selected markers for bookmarking and handle bookmark name submissions.
- Enhanced MapContextMenu with an option to add markers to bookmarks.
- Improved MapBookmarks component to support editing bookmark names and adding selected markers.
- Refactored MapControls and MapControlsContent to integrate selected marker functionality for bookmarks.
- Updated useMapBookmarks composable to include bookmark updating logic.
- Removed unused grid coordinates toggle from the UI for a cleaner interface.
This commit is contained in:
2026-03-03 20:05:42 +03:00
parent d27eb2651e
commit 52c34ef8f2
15 changed files with 425 additions and 244 deletions

View File

@@ -34,14 +34,20 @@
/>
<div
v-if="sseConnectionState === 'error' && mapReady"
class="absolute bottom-4 left-1/2 z-[501] -translate-x-1/2 rounded-lg px-3 py-2 text-sm bg-warning/90 text-warning-content shadow"
class="absolute bottom-4 left-1/2 z-[501] -translate-x-1/2 rounded-lg px-3 py-2 text-sm bg-warning/90 text-warning-content shadow flex items-center gap-2"
role="status"
title="Live updates will resume when reconnected"
>
<span class="inline-flex items-center gap-2">
<span class="inline-block size-2 animate-pulse rounded-full bg-current" aria-hidden="true" />
Reconnecting
Connection lost. Reconnecting
</span>
<button
type="button"
class="btn btn-ghost btn-xs text-warning-content hover:bg-warning-content/20"
@click="reloadPage"
>
Reload
</button>
</div>
<div class="absolute top-2 right-2 z-[501] flex flex-col gap-1">
<button
@@ -78,8 +84,6 @@
</template>
</div>
<MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
:hide-markers="mapLogic.state.hideMarkers.value"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value"
@@ -95,6 +99,7 @@
:players="players"
:current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark"
@zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)"
@@ -102,9 +107,11 @@
/>
<MapContextMenu
:context-menu="mapLogic.contextMenu"
:is-admin="auths.includes('admin')"
@wipe-tile="onWipeTile"
@rewrite-coords="onRewriteCoords"
@hide-marker="onHideMarker"
@add-to-bookmarks="onAddMarkerToBookmarks"
/>
<MapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom.value"
@@ -113,6 +120,13 @@
@close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet"
/>
<MapBookmarkNameModal
:open="bookmarkNameModalOpen"
:default-name="bookmarkNameModalDefaultName"
:title="bookmarkNameModalTitle"
@close="bookmarkNameModalOpen = false"
@submit="onBookmarkNameModalSubmit"
/>
<MapShortcutsOverlay :open="showShortcutsOverlay" @close="showShortcutsOverlay = false" />
</div>
</template>
@@ -122,6 +136,7 @@ import MapControls from '~/components/map/MapControls.vue'
import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue'
import MapContextMenu from '~/components/map/MapContextMenu.vue'
import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue'
import MapBookmarkNameModal from '~/components/map/MapBookmarkNameModal.vue'
import MapShortcutsOverlay from '~/components/map/MapShortcutsOverlay.vue'
import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
@@ -146,10 +161,59 @@ const props = withDefaults(
const mapContainerRef = ref<HTMLElement | null>(null)
const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi()
const toast = useToast()
/** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */
const mapLive = useState<boolean>('mapLive', () => false)
const mapLogic = useMapLogic()
const mapBookmarks = useMapBookmarks()
const { resolvePath } = useAppPaths()
/** Payload for bookmark name modal: add new bookmark or edit existing by id. */
type BookmarkModalPayload =
| { kind: 'add'; mapId: number; x: number; y: number; zoom?: number }
| { kind: 'edit'; editId: string }
const bookmarkNameModalOpen = ref(false)
const bookmarkNameModalDefaultName = ref('')
const bookmarkNameModalTitle = ref('Add bookmark')
const bookmarkNameModalPendingData = ref<BookmarkModalPayload | null>(null)
function openBookmarkModal(defaultName: string, title: string, data: BookmarkModalPayload) {
bookmarkNameModalDefaultName.value = defaultName
bookmarkNameModalTitle.value = title
bookmarkNameModalPendingData.value = data
bookmarkNameModalOpen.value = true
}
function onBookmarkNameModalSubmit(name: string) {
const data = bookmarkNameModalPendingData.value
bookmarkNameModalOpen.value = false
bookmarkNameModalPendingData.value = null
if (!data) return
if (data.kind === 'add') {
mapBookmarks.add({ name, mapId: data.mapId, x: data.x, y: data.y, zoom: data.zoom })
} else {
mapBookmarks.update(data.editId, { name })
}
}
function onAddMarkerToBookmarks() {
mapLogic.closeContextMenus()
const id = mapLogic.contextMenu.marker.data?.id
if (id == null || !layersManager) return
const marker = layersManager.findMarkerById(id)
if (!marker) return
const x = Math.floor(marker.position.x / TileSize)
const y = Math.floor(marker.position.y / TileSize)
openBookmarkModal(marker.name, 'Add bookmark', {
kind: 'add',
mapId: marker.map,
x,
y,
})
}
provide('openBookmarkModal', openBookmarkModal)
const mapNavigate = useMapNavigate()
const fullscreen = useFullscreen(mapContainerRef)
const showShortcutsOverlay = ref(false)
@@ -185,12 +249,36 @@ const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([])
const auths = ref<string[]>([])
/** Single source of truth: layout updates me, we derive auths for context menu. */
const me = useState<MeResponse | null>('me', () => null)
const auths = computed(() => me.value?.auths ?? [])
let leafletMap: L.Map | null = null
let mapInit: MapInitResult | null = null
let updatesHandle: UseMapUpdatesReturn | null = null
let layersManager: MapLayersManager | null = null
/** Selected marker as bookmark target: grid coords + name, or null. Passed to MapControls → MapBookmarks. */
const selectedMarkerForBookmark = ref<{ mapId: number; x: number; y: number; name: string } | null>(null)
function updateSelectedMarkerForBookmark() {
const id = mapLogic.state.selectedMarkerId.value
if (id == null || !layersManager) {
selectedMarkerForBookmark.value = null
return
}
const marker = layersManager.findMarkerById(id)
if (!marker) {
selectedMarkerForBookmark.value = null
return
}
selectedMarkerForBookmark.value = {
mapId: marker.map,
x: Math.floor(marker.position.x / TileSize),
y: Math.floor(marker.position.y / TileSize),
name: marker.name,
}
}
let intervalId: ReturnType<typeof setInterval> | null = null
let autoMode = false
let mapContainer: HTMLElement | null = null
@@ -202,10 +290,16 @@ function toLatLng(x: number, y: number) {
return leafletMap!.unproject([x, y], HnHMaxZoom)
}
function onWipeTile(coords: { x: number; y: number } | undefined) {
async function onWipeTile(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
try {
await api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
toast.success('Tile wiped')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to wipe tile'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
@@ -214,23 +308,39 @@ function onRewriteCoords(coords: { x: number; y: number } | undefined) {
mapLogic.openCoordSet(coords)
}
function onHideMarker(id: number | undefined) {
async function onHideMarker(id: number | undefined) {
if (id == null) return
mapLogic.closeContextMenus()
api.adminHideMarker({ id })
const m = layersManager?.findMarkerById(id)
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
try {
await api.adminHideMarker({ id })
const m = layersManager?.findMarkerById(id)
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
toast.success('Marker hidden')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to hide marker'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
api.adminSetCoords({
map: mapLogic.state.mapid.value,
fx: from.x,
fy: from.y,
tx: to.x,
ty: to.y,
})
mapLogic.closeCoordSetModal()
async function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
try {
await api.adminSetCoords({
map: mapLogic.state.mapid.value,
fx: from.x,
fy: from.y,
tx: to.x,
ty: to.y,
})
mapLogic.closeCoordSetModal()
toast.success('Coordinates updated')
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to set coordinates'
toast.error(msg === 'Forbidden' ? 'No permission' : msg)
}
}
function reloadPage() {
if (import.meta.client) window.location.reload()
}
function onKeydown(e: KeyboardEvent) {
@@ -253,13 +363,18 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault()
return
}
if (e.key === 'G') {
mapLogic.state.showGridCoordinates.value = !mapLogic.state.showGridCoordinates.value
if (e.key === 'f' || e.key === 'F') {
mapNavigate.focusSearch()
e.preventDefault()
return
}
if (e.key === 'f' || e.key === 'F') {
mapNavigate.focusSearch()
if (e.key === '+' || e.key === '=') {
mapLogic.zoomIn(leafletMap)
e.preventDefault()
return
}
if (e.key === '-') {
mapLogic.zoomOutControl(leafletMap)
e.preventDefault()
return
}
@@ -274,12 +389,11 @@ onMounted(async () => {
const L = (await import('leaflet')).default
// Load maps, characters, config and me in parallel so map can show sooner.
const [charactersData, mapsData, config, user] = await Promise.all([
// Load maps, characters and config in parallel. User (me) is loaded by layout.
const [charactersData, mapsData, config] = await Promise.all([
api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []),
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
api.getConfig().catch(() => ({})) as Promise<ConfigResponse>,
api.me().catch(() => null) as Promise<MeResponse | null>,
])
const mapsList: MapInfo[] = []
@@ -302,7 +416,6 @@ onMounted(async () => {
mapsLoaded.value = true
if (config?.title) document.title = config.title
auths.value = user?.auths ?? config?.auths ?? []
const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
@@ -316,8 +429,9 @@ onMounted(async () => {
contextMenuHandler = (ev: MouseEvent) => {
const target = ev.target as Node
if (!mapContainer?.contains(target)) return
// Right-click on a marker: let the marker's context menu open instead of tile menu
if ((target as Element).closest?.('.leaflet-marker-icon')) return
const isAdmin = auths.value.includes('admin')
if (import.meta.dev) console.log('[MapView contextmenu]', { isAdmin, auths: auths.value })
if (isAdmin) {
ev.preventDefault()
ev.stopPropagation()
@@ -414,6 +528,7 @@ onMounted(async () => {
if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
questGivers.value = layersManager!.getQuestGivers()
updateSelectedMarkerForBookmark()
})
const CHARACTER_POLL_MS = 4000
@@ -453,85 +568,73 @@ onMounted(async () => {
document.addEventListener('visibilitychange', visibilityChangeHandler)
}
watch(mapLogic.state.showGridCoordinates, (v) => {
if (!mapInit?.coordLayer || !leafletMap) return
const coordLayer = mapInit.coordLayer
const layerWithMap = coordLayer as L.GridLayer & { _map?: L.Map }
if (v) {
;(coordLayer.options as { visible?: boolean }).visible = true
if (!layerWithMap._map) {
coordLayer.addTo(leafletMap)
coordLayer.setZIndex(500)
coordLayer.setOpacity(1)
coordLayer.bringToFront?.()
}
leafletMap.invalidateSize()
nextTick(() => {
coordLayer.redraw?.()
})
} else {
coordLayer.setOpacity(0)
coordLayer.remove()
}
})
watch(
() => fullscreen.isFullscreen.value,
() => {
if (!mounted) return
nextTick(() => {
requestAnimationFrame(() => {
if (leafletMap) leafletMap.invalidateSize()
if (mounted && leafletMap) leafletMap.invalidateSize()
})
})
}
)
watch(mapLogic.state.hideMarkers, (v) => {
layersManager?.refreshMarkersVisibility(v)
if (!mounted || !layersManager) return
layersManager.refreshMarkersVisibility(v)
})
watch(mapLogic.state.trackingCharacterId, (value) => {
if (!mounted || !leafletMap || !layersManager) return
if (value === -1) return
const character = layersManager?.findCharacterById(value)
const character = layersManager.findCharacterById(value)
if (character) {
layersManager!.changeMap(character.map)
const latlng = leafletMap!.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMap!.setView(latlng, HnHMaxZoom)
layersManager.changeMap(character.map)
const latlng = leafletMap.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMap.setView(latlng, HnHMaxZoom)
autoMode = true
} else {
leafletMap!.setView([0, 0], HnHMinZoom)
leafletMap.setView([0, 0], HnHMinZoom)
mapLogic.state.trackingCharacterId.value = -1
}
})
watch(mapLogic.state.selectedMapId, (value) => {
if (!mounted || !leafletMap || !layersManager) return
if (value == null) return
layersManager?.changeMap(value)
const zoom = leafletMap!.getZoom()
leafletMap!.setView([0, 0], zoom)
layersManager.changeMap(value)
const zoom = leafletMap.getZoom()
leafletMap.setView([0, 0], zoom)
})
watch(mapLogic.state.overlayMapId, (value) => {
layersManager?.refreshOverlayMarkers(value ?? -1)
if (!mounted || !layersManager) return
layersManager.refreshOverlayMarkers(value ?? -1)
})
watch(mapLogic.state.selectedMarkerId, (value) => {
if (!mounted) return
updateSelectedMarkerForBookmark()
if (!leafletMap || !layersManager) return
if (value == null) return
const marker = layersManager?.findMarkerById(value)
if (marker?.leafletMarker) leafletMap!.setView(marker.leafletMarker.getLatLng(), leafletMap!.getZoom())
const marker = layersManager.findMarkerById(value)
if (marker?.leafletMarker) leafletMap.setView(marker.leafletMarker.getLatLng(), leafletMap.getZoom())
})
watch(mapLogic.state.selectedPlayerId, (value) => {
if (!mounted) return
if (value != null) mapLogic.state.trackingCharacterId.value = value
})
mapNavigate.setGoTo((mapId: number, x: number, y: number, zoom?: number) => {
if (!mounted || !leafletMap) return
if (mapLogic.state.mapid.value !== mapId && layersManager) {
layersManager.changeMap(mapId)
mapLogic.state.selectedMapId.value = mapId
}
const latLng = toLatLng(x * TileSize, y * TileSize)
leafletMap!.setView(latLng, zoom ?? leafletMap!.getZoom(), { animate: true })
leafletMap.setView(latLng, zoom ?? leafletMap.getZoom(), { animate: true })
})
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
@@ -567,6 +670,7 @@ onMounted(async () => {
})
watch([measurePointA, measurePointB], () => {
if (!mounted || !leafletMap) return
const layer = measureLayer.value
if (!layer) return
layer.clearLayers()
@@ -611,23 +715,4 @@ onBeforeUnmount(() => {
mix-blend-mode: normal;
visibility: visible !important;
}
:deep(.map-tile) {
width: 100px;
height: 100px;
min-width: 100px;
min-height: 100px;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
font-size: 11px;
line-height: 1.2;
pointer-events: none;
text-shadow: 0 0 2px #000, 0 1px 2px #000;
}
:deep(.map-tile-text) {
position: absolute;
left: 2px;
top: 2px;
}
</style>