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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user