Markers sidebar section
This commit is contained in:
parent
9265f8a02a
commit
91739d513a
@ -92,6 +92,12 @@
|
||||
chatErrorUnknown: 'Unexpected error while sending chat message',
|
||||
chatErrorDisabled: 'Chat is not enabled',
|
||||
serversHeading: 'Servers',
|
||||
markersHeading: 'Markers',
|
||||
markersSearchPlaceholder: 'Search markers...',
|
||||
markersSkeleton: 'No markers exist for the current world',
|
||||
markersSetSkeleton: 'This marker set is empty',
|
||||
markersSearchSkeleton: 'No matching markers found',
|
||||
markersUnnamed: '(Unnamed marker)',
|
||||
worldsSkeleton: 'No maps have been configured',
|
||||
playersSkeleton: 'No players are currently online',
|
||||
playersTitle: 'Click to center on player\nDouble-click to follow player',
|
||||
@ -142,6 +148,7 @@
|
||||
logoutSuccess: 'Logged out successfully',
|
||||
|
||||
closeTitle: 'Close',
|
||||
showMore: 'Show more'
|
||||
},
|
||||
|
||||
ui: {
|
||||
|
@ -23,6 +23,12 @@ export const globalMessages = [
|
||||
'chatErrorDisabled',
|
||||
'chatErrorUnknown',
|
||||
'serversHeading',
|
||||
'markersHeading',
|
||||
'markersSkeleton',
|
||||
'markersSetSkeleton',
|
||||
'markersUnnamed',
|
||||
'markersSearchPlaceholder',
|
||||
'markersSearchSkeleton',
|
||||
'worldsSkeleton',
|
||||
'playersSkeleton',
|
||||
'playersTitle',
|
||||
@ -67,6 +73,7 @@ export const globalMessages = [
|
||||
'logoutErrorUnknown',
|
||||
'logoutSuccess',
|
||||
'closeTitle',
|
||||
'showMore',
|
||||
] as const;
|
||||
|
||||
export const serverMessages = [
|
||||
|
42
package-lock.json
generated
42
package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@kyvg/vue3-notification": "2.3.0",
|
||||
"@soerenmartius/vue3-clipboard": "^0.1",
|
||||
"leaflet": "git+https://github.com/JLyne/leaflet.git",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"modern-normalize": "^1.1.0",
|
||||
"vue": "^3.2.21",
|
||||
"vuex": "^4.0"
|
||||
@ -21,6 +22,7 @@
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/jest-in-case": "^1.0.5",
|
||||
"@types/leaflet": "1.7.8",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/node": "^17.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^5.9",
|
||||
"@typescript-eslint/parser": "^5.9",
|
||||
@ -1611,6 +1613,21 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.178",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz",
|
||||
"integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash.debounce": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz",
|
||||
"integrity": "sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/minimatch": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||
@ -8257,6 +8274,11 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
@ -13347,6 +13369,21 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.178",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz",
|
||||
"integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/lodash.debounce": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz",
|
||||
"integrity": "sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||
@ -18368,6 +18405,11 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
|
||||
},
|
||||
"lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
|
@ -18,6 +18,7 @@
|
||||
"@kyvg/vue3-notification": "2.3.0",
|
||||
"@soerenmartius/vue3-clipboard": "^0.1",
|
||||
"leaflet": "git+https://github.com/JLyne/leaflet.git",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"modern-normalize": "^1.1.0",
|
||||
"vue": "^3.2.21",
|
||||
"vuex": "^4.0"
|
||||
@ -27,6 +28,7 @@
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/jest-in-case": "^1.0.5",
|
||||
"@types/leaflet": "1.7.8",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/node": "^17.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^5.9",
|
||||
"@typescript-eslint/parser": "^5.9",
|
||||
|
@ -143,6 +143,9 @@ export default defineComponent({
|
||||
case 'M':
|
||||
element = 'maps';
|
||||
break;
|
||||
case 'I':
|
||||
element = 'markers';
|
||||
break;
|
||||
case 'C':
|
||||
element = 'chat';
|
||||
break;
|
||||
|
1
src/assets/icons/marker_area.svg
Normal file
1
src/assets/icons/marker_area.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="286.89pt" height="286.89pt" viewBox="0 0 286.89 286.89" xmlns="http://www.w3.org/2000/svg"><path d="m80.789 0.381-78.122 110.68s84.697 175.47 87.791 173.92c3.094-1.5426 194.15-35.093 194.15-35.093l-23.978-169.3z"/></svg>
|
After Width: | Height: | Size: 233 B |
1
src/assets/icons/marker_circle.svg
Normal file
1
src/assets/icons/marker_circle.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="286.89pt" height="286.89pt" viewBox="0 0 286.89 286.89" xmlns="http://www.w3.org/2000/svg"><circle cx="142.68" cy="143.06" r="137.35" /></svg>
|
After Width: | Height: | Size: 155 B |
1
src/assets/icons/marker_line.svg
Normal file
1
src/assets/icons/marker_line.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="286.89pt" height="286.89pt" version="1.0" viewBox="0 0 286.89 286.89" xmlns="http://www.w3.org/2000/svg"><path d="m138.25 1.7383a8.5 8.5 0 0 0-6.125 2.1953l-115.74 105.05a8.5008 8.5008 0 0 0 2.084 13.98l222.54 105.04-84.184 41.338a8.5 8.5 0 0 0-3.8848 11.377 8.5 8.5 0 0 0 11.377 3.8828l100.03-49.119a8.5008 8.5008 0 0 0-0.11718-15.316l-227-107.15 106.32-96.5a8.5 8.5 0 0 0 0.58204-12.006 8.5 8.5 0 0 0-5.8828-2.7773z"/></svg>
|
After Width: | Height: | Size: 439 B |
3
src/assets/icons/marker_point.svg
Normal file
3
src/assets/icons/marker_point.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="286.89163pt" height="286.89163pt" version="1.0" viewBox="0 0 286.89162 286.89163" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-59.399373,-0.17905527)" d="m 202.21483,0.17905527 c -49.2926,0 -89.25212,39.95956873 -89.25212,89.25210973 0,49.292555 82.37686,197.611115 89.25212,197.611115 7.99783,0 89.25211,-148.31856 89.25211,-197.611115 0,-49.292541 -39.95957,-89.25210973 -89.25211,-89.25210973 z m 0,51.39320173 c 20.91016,-0.0011 37.86223,16.948751 37.86389,37.858908 0.001,20.912125 -16.95177,37.865025 -37.86389,37.863935 -20.91214,0.001 -37.86506,-16.9518 -37.86394,-37.863935 0.002,-20.910176 16.95376,-37.86003 37.86394,-37.858908 z" />
|
||||
</svg>
|
After Width: | Height: | Size: 686 B |
@ -24,6 +24,13 @@
|
||||
@click="handleSectionClick" @keydown="handleSectionKeydown">
|
||||
<SvgIcon :name="mapCount > 1 ? 'maps' : 'servers'"></SvgIcon>
|
||||
</button>
|
||||
<button class="button--markers" data-section="markers"
|
||||
:title="messageMarkers"
|
||||
:aria-label="messageMarkers"
|
||||
:aria-expanded="markersVisible"
|
||||
@click="handleSectionClick" @keydown="handleSectionKeydown">
|
||||
<SvgIcon name="marker_point"></SvgIcon>
|
||||
</button>
|
||||
<button v-if="playerMakersEnabled" class="button--players" data-section="players"
|
||||
:title="messagePlayers" :aria-label="messagePlayers" :aria-expanded="playersVisible"
|
||||
@click="handleSectionClick" @keydown="handleSectionKeydown">
|
||||
@ -33,6 +40,7 @@
|
||||
<div class="sidebar__content" @keydown="handleSidebarKeydown">
|
||||
<ServersSection v-if="serverCount > 1" :hidden="!mapsVisible"></ServersSection>
|
||||
<WorldsSection v-if="mapCount > 1" :hidden="!mapsVisible"></WorldsSection>
|
||||
<MarkersSection v-if="previouslyVisible.has('markers')" :hidden="!markersVisible"></MarkersSection>
|
||||
<PlayersSection v-if="playerMakersEnabled && previouslyVisible.has('players')" :hidden="!playersVisible"></PlayersSection>
|
||||
<FollowTargetSection v-if="following" :hidden="!followVisible" :target="following"></FollowTargetSection>
|
||||
</div>
|
||||
@ -45,12 +53,14 @@ import FollowTargetSection from './sidebar/FollowTargetSection.vue';
|
||||
import PlayersSection from "@/components/sidebar/PlayersSection.vue";
|
||||
import ServersSection from "@/components/sidebar/ServersSection.vue";
|
||||
import WorldsSection from "@/components/sidebar/WorldsSection.vue";
|
||||
import MarkersSection from "@/components/sidebar/MarkersSection.vue";
|
||||
import {useStore} from "@/store";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import {MutationTypes} from "@/store/mutation-types";
|
||||
import "@/assets/icons/players.svg";
|
||||
import "@/assets/icons/maps.svg";
|
||||
import "@/assets/icons/servers.svg";
|
||||
import "@/assets/icons/marker_point.svg";
|
||||
import {nextTick, ref, watch} from "vue";
|
||||
import {handleKeyboardEvent} from "@/util/events";
|
||||
import {focus} from "@/util";
|
||||
@ -58,6 +68,7 @@ import {LiveAtlasSidebarSection} from "@/index";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MarkersSection,
|
||||
WorldsSection,
|
||||
ServersSection,
|
||||
PlayersSection,
|
||||
@ -79,12 +90,14 @@ export default defineComponent({
|
||||
|
||||
messageWorlds = computed(() => store.state.messages.worldsHeading),
|
||||
messageServers = computed(() => store.state.messages.serversHeading),
|
||||
messageMarkers = computed(() => store.state.messages.markersHeading),
|
||||
messagePlayers = computed(() => store.getters.playersHeading),
|
||||
|
||||
playerMakersEnabled = computed(() => !!store.state.components.playerMarkers),
|
||||
|
||||
playersVisible = computed(() => currentlyVisible.value.has('players')),
|
||||
mapsVisible = computed(() => currentlyVisible.value.has('maps')),
|
||||
markersVisible = computed(() => currentlyVisible.value.has('markers')),
|
||||
followVisible = computed(() => {
|
||||
//Show following alongside playerlist on small screens
|
||||
return (!smallScreen.value && following.value)
|
||||
@ -132,6 +145,7 @@ export default defineComponent({
|
||||
//Focus sidebar sections when they become visible, except on initial load
|
||||
watch(playersVisible, newValue => newValue && !firstLoad.value && nextTick(() => focusSection('players')));
|
||||
watch(mapsVisible, newValue => newValue && !firstLoad.value && nextTick(() => focusSection('maps')));
|
||||
watch(markersVisible, newValue => newValue && !firstLoad.value && nextTick(() => focusSection('markers')));
|
||||
|
||||
return {
|
||||
sidebar,
|
||||
@ -142,11 +156,13 @@ export default defineComponent({
|
||||
|
||||
messageWorlds,
|
||||
messageServers,
|
||||
messageMarkers,
|
||||
messagePlayers,
|
||||
|
||||
previouslyVisible,
|
||||
playersVisible,
|
||||
mapsVisible,
|
||||
markersVisible,
|
||||
followVisible,
|
||||
playerMakersEnabled,
|
||||
|
||||
|
168
src/components/list/MarkerList.vue
Normal file
168
src/components/list/MarkerList.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<!--
|
||||
- Copyright 2022 James Lyne
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<input ref="searchInput" v-if="search && unfilteredTotal" id="markers__search" class="section__search" type="text"
|
||||
name="search" :value="searchQuery" :placeholder="messageMarkersSearchPlaceholder"
|
||||
@keydown="e => e.stopImmediatePropagation()" @input="onSearchInput">
|
||||
<RadioList v-if="markers.size" v-bind="$attrs" @keydown="onListKeydown">
|
||||
<MarkerListItem v-for="[id, marker] in markers" :key="id" :marker="marker" :id="id"></MarkerListItem>
|
||||
<button type="button" ref="showMoreButton" v-if="viewLimit < total" @click.prevent="showMore">{{ messageShowMore }}</button>
|
||||
</RadioList>
|
||||
<div v-else-if="searchQuery" class="section__skeleton" v-bind="$attrs">{{ messageSkeletonMarkersSearch }}</div>
|
||||
<div v-else class="section__skeleton" v-bind="$attrs">{{ messageSkeletonMarkers }}</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted, reactive, ref} from 'vue';
|
||||
import debounce from 'lodash.debounce';
|
||||
import RadioList from "@/components/util/RadioList.vue";
|
||||
import {LiveAtlasMarkerSet, LiveAtlasMarker} from "@/index";
|
||||
import {nonReactiveState} from "@/store/state";
|
||||
import {computed, onUnmounted, watch} from "@vue/runtime-core";
|
||||
import {useStore} from "@/store";
|
||||
import MarkerListItem from "@/components/list/MarkerListItem.vue";
|
||||
import {registerSetUpdateHandler, unregisterSetUpdateHandler} from "@/util/markers";
|
||||
import {DynmapMarkerUpdate} from "@/dynmap";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MarkerList',
|
||||
components: {
|
||||
MarkerListItem,
|
||||
RadioList,
|
||||
},
|
||||
|
||||
props: {
|
||||
markerSet: {
|
||||
type: Object as () => LiveAtlasMarkerSet,
|
||||
required: true
|
||||
},
|
||||
search: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
let focusFrame = 0;
|
||||
|
||||
const store = useStore(),
|
||||
messageShowMore = computed(() => store.state.messages.showMore),
|
||||
messageMarkersSearchPlaceholder = computed(() => store.state.messages.markersSearchPlaceholder),
|
||||
messageSkeletonMarkersSearch = computed(() => store.state.messages.markersSearchSkeleton),
|
||||
messageSkeletonMarkers = computed(() => store.state.messages.markersSetSkeleton),
|
||||
setContents = nonReactiveState.markers.get(props.markerSet.id)!,
|
||||
searchQuery = ref(""),
|
||||
markers = ref<Map<string, LiveAtlasMarker>>(new Map()),
|
||||
showMoreButton = ref<HTMLButtonElement | null>(null),
|
||||
total = ref(0),
|
||||
unfilteredTotal = ref(0),
|
||||
viewLimit = ref(50),
|
||||
searchInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const onListKeydown = (e: KeyboardEvent) => {
|
||||
if(e.key === 'f' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
searchInput.value!.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const getMarkers = () => {
|
||||
markers.value.clear();
|
||||
|
||||
if(!setContents) {
|
||||
return;
|
||||
}
|
||||
|
||||
unfilteredTotal.value = setContents.size;
|
||||
let count = 0;
|
||||
|
||||
setContents.forEach((marker, id) => {
|
||||
if(searchQuery.value && !marker.tooltip.toLowerCase().includes(searchQuery.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
count++;
|
||||
|
||||
if(count < viewLimit.value) {
|
||||
markers.value.set(id, reactive(marker));
|
||||
}
|
||||
});
|
||||
|
||||
total.value = setContents.size;
|
||||
};
|
||||
|
||||
const showMore = () => {
|
||||
const lastLabel = (showMoreButton.value as HTMLButtonElement).previousElementSibling as HTMLLabelElement;
|
||||
viewLimit.value += 50;
|
||||
//Focus first new list item
|
||||
focusFrame = requestAnimationFrame(() => (lastLabel.nextElementSibling as HTMLInputElement).focus());
|
||||
};
|
||||
|
||||
const handleUpdate = (update: DynmapMarkerUpdate) => {
|
||||
unfilteredTotal.value = setContents.size;
|
||||
|
||||
if(update.removed ||
|
||||
(searchQuery.value && !update.payload.tooltip.toLowerCase().includes(searchQuery.value))) {
|
||||
markers.value.delete(update.id);
|
||||
} else if(markers.value.has(update.id) || markers.value.size < viewLimit.value) {
|
||||
markers.value.set(update.id, update.payload);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchInput = (e: Event) => {
|
||||
searchQuery.value = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
viewLimit.value = 50;
|
||||
getMarkers();
|
||||
searchInput.value!.nextElementSibling!.scrollIntoView();
|
||||
}, 100);
|
||||
|
||||
getMarkers();
|
||||
watch(viewLimit, () => getMarkers());
|
||||
watch(searchQuery, () => debouncedSearch());
|
||||
|
||||
onMounted(() => registerSetUpdateHandler(handleUpdate, props.markerSet.id));
|
||||
onUnmounted(() => {
|
||||
if(focusFrame) {
|
||||
cancelAnimationFrame(focusFrame);
|
||||
}
|
||||
|
||||
debouncedSearch.cancel();
|
||||
unregisterSetUpdateHandler(handleUpdate, props.markerSet.id);
|
||||
});
|
||||
|
||||
return {
|
||||
messageShowMore,
|
||||
messageMarkersSearchPlaceholder,
|
||||
messageSkeletonMarkersSearch,
|
||||
messageSkeletonMarkers,
|
||||
searchQuery,
|
||||
markers,
|
||||
showMoreButton,
|
||||
viewLimit,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
searchInput,
|
||||
showMore,
|
||||
onListKeydown,
|
||||
onSearchInput
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
120
src/components/list/MarkerListItem.vue
Normal file
120
src/components/list/MarkerListItem.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<!--
|
||||
- Copyright 2022 James Lyne
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<input :id="`marker-${id}`" type="radio" name="marker" v-bind:value="id" @click.prevent="pan">
|
||||
<label :for="`marker-${id}`" class="marker" :title="marker.tooltip" @click.prevent="pan">
|
||||
<img width="16" height="16" v-if="icon" class="marker__icon" :src="icon" alt="" />
|
||||
<SvgIcon v-else :name="defaultIcon" class="marker__icon"></SvgIcon>
|
||||
<span class="marker__label">{{ marker.tooltip || messageUnnamed }}</span>
|
||||
<span class="marker__location">X: {{ marker.location.x }}, Z: {{ marker.location.z }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {LiveAtlasMarker, LiveAtlasPathMarker, LiveAtlasPointMarker} from "@/index";
|
||||
import {computed} from "@vue/runtime-core";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import "@/assets/icons/marker_point.svg";
|
||||
import "@/assets/icons/marker_line.svg";
|
||||
import "@/assets/icons/marker_area.svg";
|
||||
import "@/assets/icons/marker_circle.svg";
|
||||
import {useStore} from "vuex";
|
||||
import {LiveAtlasMarkerType} from "@/util/markers";
|
||||
import {MutationTypes} from "@/store/mutation-types";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MarkerListItem',
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
marker: {
|
||||
type: Object as () => LiveAtlasMarker,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const store = useStore(),
|
||||
messageUnnamed = computed(() => store.state.messages.markersUnnamed),
|
||||
icon = computed(() => {
|
||||
if('icon' in props.marker) {
|
||||
return store.state.currentMapProvider!.getMarkerIconUrl((props.marker as LiveAtlasPointMarker).icon);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
defaultIcon = computed(() => {
|
||||
switch(props.marker.type) {
|
||||
case LiveAtlasMarkerType.POINT:
|
||||
return 'marker_point';
|
||||
case LiveAtlasMarkerType.AREA:
|
||||
return 'marker_area';
|
||||
case LiveAtlasMarkerType.LINE:
|
||||
return 'marker_line';
|
||||
case LiveAtlasMarkerType.CIRCLE:
|
||||
return 'marker_circle';
|
||||
}
|
||||
});
|
||||
|
||||
const pan = () => {
|
||||
if(props.marker.type === LiveAtlasMarkerType.POINT) {
|
||||
store.commit(MutationTypes.SET_VIEW_TARGET, {
|
||||
location: props.marker.location,
|
||||
});
|
||||
} else {
|
||||
store.commit(MutationTypes.SET_VIEW_TARGET, {
|
||||
location: (props.marker as LiveAtlasPathMarker).bounds,
|
||||
options: {
|
||||
padding: [10, 10]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon,
|
||||
defaultIcon,
|
||||
messageUnnamed,
|
||||
pan,
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
input[type=radio] + .marker {
|
||||
padding-left: 3.9rem;
|
||||
|
||||
.marker__icon {
|
||||
max-width: 1.6rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0.8rem;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.marker__location {
|
||||
font-size: 1.4rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
</style>
|
162
src/components/list/MarkerSetList.vue
Normal file
162
src/components/list/MarkerSetList.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<!--
|
||||
- Copyright 2022 James Lyne
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<RadioList ref="list" v-if="!currentSet" :aria-labelledby="ariaLabelledby">
|
||||
<template v-for="[id, markerSet] in markerSets" :key="id">
|
||||
<input :id="`marker-set-${id}`" type="radio" name="marker-set" v-model="currentSet" v-bind:value="markerSet">
|
||||
<label :for="`marker-set-${id}`">
|
||||
<span>{{ markerSet.label || id }}</span>
|
||||
<span>{{ markerCounts.get(markerSet) }} Marker(s)</span>
|
||||
</label>
|
||||
</template>
|
||||
</RadioList>
|
||||
|
||||
<template v-else>
|
||||
<div ref="subHeader" class="markers__header">
|
||||
<button ref="backButton" class="markers__back" @click.prevent="currentSet = undefined">
|
||||
<SvgIcon name="arrow"></SvgIcon>
|
||||
</button>
|
||||
<h3 class="markers__set">{{ currentSet.label }}</h3>
|
||||
</div>
|
||||
<MarkerList ref="submenu" :marker-set="currentSet" @keydown="onSubmenuKeydown"></MarkerList>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {ComponentPublicInstance, defineComponent, nextTick, onMounted, ref} from 'vue';
|
||||
import RadioList from "@/components/util/RadioList.vue";
|
||||
import {LiveAtlasMarkerSet} from "@/index";
|
||||
import {nonReactiveState} from "@/store/state";
|
||||
import {onUnmounted, watch} from "@vue/runtime-core";
|
||||
import MarkerList from "@/components/list/MarkerList.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import {registerUpdateHandler, unregisterUpdateHandler} from "@/util/markers";
|
||||
import {DynmapMarkerUpdate} from "@/dynmap";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MarkerSetList',
|
||||
components: {
|
||||
SvgIcon,
|
||||
MarkerList,
|
||||
RadioList,
|
||||
},
|
||||
|
||||
props: {
|
||||
markerSets: {
|
||||
type: Object as () => Map<string, LiveAtlasMarkerSet>,
|
||||
required: true
|
||||
},
|
||||
ariaLabelledby: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const markerCounts = ref<Map<LiveAtlasMarkerSet, number>>(new Map()),
|
||||
currentSet = ref<LiveAtlasMarkerSet | undefined>(undefined),
|
||||
list = ref<ComponentPublicInstance | null>(null),
|
||||
subHeader = ref<HTMLElement | null>(null),
|
||||
backButton = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
const checkSets = () => {
|
||||
props.markerSets?.forEach((set) => checkSet(set));
|
||||
};
|
||||
|
||||
const checkSet = (set: LiveAtlasMarkerSet) => {
|
||||
const markers = nonReactiveState.markers.get(set.id),
|
||||
markerCount = markers ? markers.size : 0;
|
||||
|
||||
markerCounts.value.set(set, markerCount);
|
||||
};
|
||||
|
||||
const handleUpdate = (update: DynmapMarkerUpdate) => {
|
||||
checkSet(props.markerSets.get(update.set)!);
|
||||
}
|
||||
|
||||
const onSubmenuKeydown = (e: KeyboardEvent) => {
|
||||
if(e.key === 'Backspace') {
|
||||
currentSet.value = undefined;
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
const updateFocus = (newValue?: LiveAtlasMarkerSet, oldValue?: LiveAtlasMarkerSet) => {
|
||||
let focusTarget;
|
||||
|
||||
if(newValue) {
|
||||
focusTarget = subHeader.value!.parentNode!.querySelector('.menu input') || backButton.value;
|
||||
} else if(oldValue) {
|
||||
focusTarget = list.value!.$el.parentNode.querySelector(`[id="marker-set-${oldValue.id}"]`);
|
||||
}
|
||||
|
||||
if(focusTarget) {
|
||||
(focusTarget as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
watch(props.markerSets, () => checkSets);
|
||||
watch(currentSet, (newValue, oldValue) => nextTick(() => updateFocus(newValue, oldValue)));
|
||||
|
||||
onMounted(() => {
|
||||
checkSets();
|
||||
registerUpdateHandler(handleUpdate);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
unregisterUpdateHandler(handleUpdate);
|
||||
});
|
||||
|
||||
return {
|
||||
markerCounts,
|
||||
currentSet,
|
||||
list,
|
||||
subHeader,
|
||||
backButton,
|
||||
onSubmenuKeydown,
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.markers__back {
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
flex-grow: 0;
|
||||
margin-right: 1rem;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.markers__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
position: sticky;
|
||||
top: 4.8rem;
|
||||
background-color: var(--background-base);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.markers__set {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
61
src/components/sidebar/MarkersSection.vue
Normal file
61
src/components/sidebar/MarkersSection.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<!--
|
||||
- Copyright 2022 James Lyne
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<SidebarSection v-if="markerSets.size" name="markers" class="markers">
|
||||
<template v-slot:heading>{{ heading }}</template>
|
||||
<template v-slot:default>
|
||||
<MarkerSetList :markerSets="markerSets" aria-labelledby="markers-heading"></MarkerSetList>
|
||||
</template>
|
||||
</SidebarSection>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from 'vue';
|
||||
import {useStore} from "@/store";
|
||||
import SidebarSection from "@/components/sidebar/SidebarSection.vue";
|
||||
import MarkerSetList from "@/components/list/MarkerSetList.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MarkersSection',
|
||||
components: {
|
||||
MarkerSetList,
|
||||
SidebarSection,
|
||||
},
|
||||
|
||||
setup() {
|
||||
const store = useStore(),
|
||||
heading = computed(() => store.state.messages.markersHeading),
|
||||
markerSets = computed(() => store.state.markerSets);
|
||||
|
||||
return {
|
||||
heading,
|
||||
markerSets
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep(.menu), ::v-deep(.menu input) {
|
||||
scroll-margin-top: 14.4rem;
|
||||
scroll-margin-bottom: 6.5rem;
|
||||
}
|
||||
|
||||
::v-deep(.section__search) {
|
||||
top: 9rem;
|
||||
}
|
||||
</style>
|
2
src/index.d.ts
vendored
2
src/index.d.ts
vendored
@ -126,7 +126,7 @@ interface LiveAtlasUIConfig {
|
||||
|
||||
export type LiveAtlasUIElement = 'layers' | 'chat' | LiveAtlasSidebarSection;
|
||||
export type LiveAtlasUIModal = 'login' | 'settings';
|
||||
export type LiveAtlasSidebarSection = 'servers' | 'players' | 'maps';
|
||||
export type LiveAtlasSidebarSection = 'servers' | 'players' | 'maps' | 'markers';
|
||||
export type LiveAtlasDimension = 'overworld' | 'nether' | 'end';
|
||||
|
||||
export type LiveAtlasSidebarSectionState = {
|
||||
|
@ -61,6 +61,13 @@
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
text-overflow: inherit;
|
||||
overflow: inherit;
|
||||
white-space: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-hovered {
|
||||
|
@ -18,7 +18,7 @@
|
||||
* players on the map
|
||||
*/
|
||||
|
||||
.marker {
|
||||
.map .marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
@ -262,13 +262,18 @@ input {
|
||||
}
|
||||
}
|
||||
|
||||
& > li > button, & > input[type=radio] + label {
|
||||
& > li > button, & > input[type=radio] + label, & > button {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
& > button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
//noinspection CssOverwrittenProperties
|
||||
|
@ -90,6 +90,7 @@ export const actions: ActionTree<State, State> & Actions = {
|
||||
//Make UI visible if configured, there's enough space to do so, and this is the first config load
|
||||
if(!state.ui.visibleElements.size && state.configuration.expandUI && !state.ui.smallScreen) {
|
||||
commit(MutationTypes.SET_UI_ELEMENT_VISIBILITY, {element: 'players', state: true});
|
||||
commit(MutationTypes.SET_UI_ELEMENT_VISIBILITY, {element: 'markers', state: true});
|
||||
commit(MutationTypes.SET_UI_ELEMENT_VISIBILITY, {element: 'maps', state: true});
|
||||
}
|
||||
|
||||
|
@ -215,6 +215,7 @@ export const state: State = {
|
||||
servers: {},
|
||||
players: {},
|
||||
maps: {},
|
||||
markers: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user