Implement link copying and url handling

This commit is contained in:
James Lyne 2020-12-13 02:50:17 +00:00
parent a0326b00d1
commit 9e28e695ee
13 changed files with 252 additions and 39 deletions

44
package-lock.json generated
View File

@ -1317,6 +1317,12 @@
"@types/node": "*"
}
},
"@types/clipboard": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/clipboard/-/clipboard-2.0.1.tgz",
"integrity": "sha512-gJJX9Jjdt3bIAePQRRjYWG20dIhAgEqonguyHxXuqALxsoDsDLimihqrSg8fXgVTJ4KZCzkfglKtwsh/8dLfbA==",
"dev": true
},
"@types/connect": {
"version": "3.4.33",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz",
@ -3929,6 +3935,17 @@
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
"dev": true
},
"clipboard": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz",
"integrity": "sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg==",
"dev": true,
"requires": {
"good-listener": "^1.2.2",
"select": "^1.1.2",
"tiny-emitter": "^2.0.0"
}
},
"clipboardy": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz",
@ -4921,6 +4938,12 @@
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
},
"delegate": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
"integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==",
"dev": true
},
"depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -6616,6 +6639,15 @@
"slash": "^2.0.0"
}
},
"good-listener": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
"integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
"dev": true,
"requires": {
"delegate": "^3.1.2"
}
},
"graceful-fs": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
@ -10528,6 +10560,12 @@
"ajv-keywords": "^3.5.2"
}
},
"select": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
"integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=",
"dev": true
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@ -11846,6 +11884,12 @@
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=",
"dev": true
},
"tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"dev": true
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@ -12,6 +12,7 @@
"vue": "^3.0.0"
},
"devDependencies": {
"@types/clipboard": "^2.0.1",
"@types/leaflet": "^1.5.19",
"@typescript-eslint/eslint-plugin": "^4.1.0",
"@typescript-eslint/parser": "^4.1.0",
@ -22,6 +23,7 @@
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-typescript": "^5.0.2",
"babel-eslint": "^10.1.0",
"clipboard": "^2.0.6",
"eslint": "^7.5.0",
"eslint-plugin-vue": "^7.0.0-0",
"leaflet": "^1.7.1",

View File

@ -9,6 +9,8 @@ import Map from './components/Map.vue';
import Sidebar from './components/Sidebar.vue';
import {useStore} from "./store";
import {ActionTypes} from "@/store/action-types";
import Util from '@/util';
import {MutationTypes} from "@/store/mutation-types";
export default defineComponent({
name: 'App',
@ -18,9 +20,11 @@ export default defineComponent({
},
setup() {
const store = useStore(),
const initialUrl = window.location.hash.replace('#', ''),
store = useStore(),
updateInterval = computed(() => store.state.configuration.updateInterval),
title = computed(() => store.state.configuration.title),
currentUrl = computed(() => store.getters.url),
updatesEnabled = ref(false),
updateTimeout = ref(0),
@ -52,12 +56,28 @@ export default defineComponent({
}
updateTimeout.value = 0;
},
parseUrl = () => {
if(!initialUrl) {
return;
}
try {
const result = Util.parseMapHash(initialUrl);
store.commit(MutationTypes.SET_PARSED_URL, result);
} catch(e) {
console.warn('Ignoring invalid url ' + e);
}
};
watch(title, (title) => document.title = title);
watch(currentUrl, (url) => window.history.replaceState({}, '', url));
onMounted(() => loadConfiguration());
onBeforeUnmount(() => stopUpdates());
parseUrl();
},
});
</script>

View File

@ -12,8 +12,8 @@
</template>
<script lang="ts">
import {defineComponent, computed} from "@vue/runtime-core";
import {LatLng, CRS} from 'leaflet';
import {computed, defineComponent} from "@vue/runtime-core";
import {CRS, LatLng} from 'leaflet';
import {useStore} from '@/store';
import MapLayer from "@/components/map/layer/MapLayer.vue";
import PlayersLayer from "@/components/map/layer/PlayersLayer.vue";
@ -23,7 +23,7 @@ import ClockControl from "@/components/map/control/ClockControl.vue";
import LinkControl from "@/components/map/control/LinkControl.vue";
import LogoControl from "@/components/map/control/LogoControl.vue";
import {MutationTypes} from "@/store/mutation-types";
import {DynmapPlayer} from "@/dynmap";
import {Coordinate, DynmapPlayer} from "@/dynmap";
import {ActionTypes} from "@/store/action-types";
import DynmapMap from "@/leaflet/DynmapMap";
@ -85,9 +85,36 @@ export default defineComponent({
},
deep: true
},
currentWorld(newValue) {
currentWorld(newValue, oldValue) {
const store = useStore();
if(newValue) {
useStore().dispatch(ActionTypes.GET_MARKER_SETS, undefined);
let location: Coordinate;
let zoom: number;
store.dispatch(ActionTypes.GET_MARKER_SETS, undefined);
if(oldValue || !store.state.parsedUrl.location) {
location = newValue.center;
} else {
location = store.state.parsedUrl.location;
}
if(!oldValue) {
zoom = store.state.parsedUrl.zoom || store.state.configuration.defaultZoom;
}
//Delay the pan a frame, to allow the projection to be updated by the new world
requestAnimationFrame(() => {
this.leaflet!.panTo(this.currentProjection.locationToLatLng(location), {
animate: false,
noMoveStart: true,
});
this.leaflet!.setZoom(zoom, {
animate: false,
});
});
}
},
configuration: {
@ -100,7 +127,7 @@ export default defineComponent({
}
},
deep: true,
}
},
},
mounted() {
@ -119,16 +146,12 @@ export default defineComponent({
}));
this.leaflet.on('moveend', () => {
const location = this.currentProjection.latLngToLocation(this.leaflet!.getCenter(), 64),
locationString = `${Math.round(location.x)},${Math.round(location.y)},${Math.round(location.z)}`,
url = `#${this.currentWorld!.name};${this.currentMap!.name};${locationString}`;
useStore().commit(MutationTypes.SET_CURRENT_LOCATION, this.currentProjection.latLngToLocation(this.leaflet!.getCenter(), 64));
});
window.history.replaceState({
location,
world: this.currentWorld!.name,
map: this.currentMap!.name,
}, '', url);
})
this.leaflet.on('zoomend', () => {
useStore().commit(MutationTypes.SET_CURRENT_ZOOM, this.leaflet!.getZoom());
});
},
methods: {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {defineComponent, onMounted, onUnmounted, computed, watch} from "@vue/runtime-core";
import {defineComponent, onUnmounted, computed, watch} from "@vue/runtime-core";
import {DynmapWorldMap} from "@/dynmap";
import {Map} from 'leaflet';
import {useStore} from "@/store";
@ -36,12 +36,9 @@ export default defineComponent({
active = computed(() => props.map === store.state.currentMap),
enableLayer = () => {
console.log('Set current projection');
useStore().commit(MutationTypes.SET_CURRENT_PROJECTION, layer.getProjection());
props.leaflet.addLayer(layer);
props.leaflet.panTo(layer.getProjection().locationToLatLng(props.map.world.center), {
noMoveStart: true,
animate: false,
});
stopUpdateWatch = watch(pendingUpdates, (newValue, oldValue) => {
if(newValue && !oldValue && !updateFrame) {
@ -75,13 +72,11 @@ export default defineComponent({
});
};
watch(active, (newValue) => newValue ? enableLayer() : disableLayer());
onMounted(() => {
if(active.value) {
enableLayer();
}
});
watch(active, (newValue) => newValue ? enableLayer() : disableLayer());
onUnmounted(() => {
disableLayer();

7
src/dynmap.d.ts vendored
View File

@ -251,3 +251,10 @@ interface DynmapTileUpdate {
name: string
timestamp: number
}
interface DynmapParsedUrl {
world?: string;
map?: string;
location?: Coordinate;
zoom?: number;
}

View File

@ -1,6 +1,7 @@
import {Control, ControlOptions, DomUtil, Map} from 'leaflet';
import {useStore} from "@/store";
import linkIcon from '@/assets/icons/link.svg';
import ClipboardJS from 'clipboard';
export class LinkControl extends Control {
// @ts-ignore
@ -22,9 +23,8 @@ export class LinkControl extends Control {
<use xlink:href="${linkIcon.url}" />
</svg>`;
linkButton.addEventListener('click', () => {
const projection = useStore().state.currentProjection;
console.log(projection.latLngToLocation(this._map!.getCenter(), 64));
new ClipboardJS(linkButton, {
text: () => window.location.href.split("#")[0] + useStore().getters.url,
});
return linkButton;

View File

@ -9,8 +9,8 @@ import {
DynmapConfigurationResponse, DynmapLineUpdate,
DynmapMarkerSet,
DynmapMarkerUpdate,
DynmapPlayer, DynmapServerConfig, DynmapTileUpdate,
DynmapUpdateResponse
DynmapPlayer, DynmapTileUpdate,
DynmapUpdateResponse, DynmapWorld
} from "@/dynmap";
type AugmentedActionContext = {
@ -57,17 +57,52 @@ export interface Actions {
}
export const actions: ActionTree<State, State> & Actions = {
[ActionTypes.LOAD_CONFIGURATION]({commit}): Promise<DynmapConfigurationResponse> {
[ActionTypes.LOAD_CONFIGURATION]({commit, state}): Promise<DynmapConfigurationResponse> {
return API.getConfiguration().then(config => {
commit(MutationTypes.SET_CONFIGURATION, config.config);
commit(MutationTypes.SET_MESSAGES, config.messages);
commit(MutationTypes.SET_WORLDS, config.worlds);
commit(MutationTypes.SET_COMPONENTS, config.components);
if(config.config.defaultWorld && config.config.defaultMap) {
let worldName, mapName;
// Use config default world if it exists
if(config.config.defaultWorld && state.worlds.has(config.config.defaultWorld)) {
worldName = config.config.defaultWorld;
}
// Prefer world from parsed url if present and it exists
if(state.parsedUrl.world && state.worlds.has(state.parsedUrl.world)) {
worldName = state.parsedUrl.world;
}
// Use first world, if any, if neither of the above exist
if(!worldName) {
worldName = state.worlds.size ? state.worlds.entries().next().value.name : undefined;
}
if(worldName) {
const world = state.worlds.get(worldName) as DynmapWorld;
// Use config default map if it exists
if(config.config.defaultMap && world.maps.has(config.config.defaultMap)) {
mapName = config.config.defaultMap;
}
// Prefer map from parsed url if present and it exists
if(state.parsedUrl.map && world.maps.has(state.parsedUrl.map)) {
mapName = state.parsedUrl.map;
}
// Use first map, if any, if neither of the above exist
if(!mapName) {
mapName = world.maps.size ? world.maps.entries().next().value.name : undefined;
}
}
if(worldName && mapName) {
commit(MutationTypes.SET_CURRENT_MAP, {
worldName: config.config.defaultWorld,
mapName: config.config.defaultMap
worldName, mapName
});
}

View File

@ -8,6 +8,7 @@ export type Getters = {
clockControlEnabled(state: State): boolean;
night(state: State): boolean;
mapBackground(state: State, getters: GetterTree<State, State> & Getters): string;
url(state: State, getters: GetterTree<State, State> & Getters): string;
}
export const getters: GetterTree<State, State> & Getters = {
@ -41,5 +42,19 @@ export const getters: GetterTree<State, State> & Getters = {
}
return state.currentMap.background || 'transparent';
},
url(state: State): string {
const x = Math.round(state.currentLocation.x),
y = Math.round(state.currentLocation.y),
z = Math.round(state.currentLocation.z),
locationString = `${x},${y},${z}`,
zoom = state.currentZoom;
if(!state.currentWorld || !state.currentMap) {
return '';
}
return `#${state.currentWorld.name};${state.currentMap.name};${locationString};${zoom}`;
}
}

View File

@ -20,6 +20,9 @@ export enum MutationTypes {
SYNC_PLAYERS = 'syncPlayers',
SET_CURRENT_MAP = 'setCurrentMap',
SET_CURRENT_PROJECTION = 'setCurrentProjection',
SET_CURRENT_LOCATION = 'setCurrentLocation',
SET_CURRENT_ZOOM = 'setCurrentZoom',
SET_PARSED_URL = 'setParsedUrl',
FOLLOW_PLAYER = 'followPlayer',
CLEAR_FOLLOW = 'clearFollow',
}

View File

@ -8,7 +8,7 @@ import {
DynmapCircleUpdate,
DynmapComponentConfig,
DynmapLine,
DynmapLineUpdate,
DynmapLineUpdate, Coordinate,
DynmapMarker,
DynmapMarkerSet,
DynmapMarkerSetUpdates,
@ -17,7 +17,7 @@ import {
DynmapPlayer,
DynmapServerConfig, DynmapTileUpdate,
DynmapWorld,
DynmapWorldState
DynmapWorldState, DynmapParsedUrl
} from "@/dynmap";
import {DynmapProjection} from "@/leaflet/projection/DynmapProjection";
@ -49,6 +49,9 @@ export type Mutations<S = State> = {
[MutationTypes.SYNC_PLAYERS](state: S, keep: Set<string>): void
[MutationTypes.SET_CURRENT_MAP](state: S, payload: CurrentMapPayload): void
[MutationTypes.SET_CURRENT_PROJECTION](state: S, payload: DynmapProjection): void
[MutationTypes.SET_CURRENT_LOCATION](state: S, payload: Coordinate): void
[MutationTypes.SET_CURRENT_ZOOM](state: S, payload: number): void
[MutationTypes.SET_PARSED_URL](state: S, payload: DynmapParsedUrl): void
[MutationTypes.FOLLOW_PLAYER](state: S, payload: DynmapPlayer): void
[MutationTypes.CLEAR_FOLLOW](state: S, a?: void): void
}
@ -288,6 +291,18 @@ export const mutations: MutationTree<State> & Mutations = {
state.currentProjection = projection;
},
[MutationTypes.SET_CURRENT_LOCATION](state: State, payload: Coordinate) {
state.currentLocation = payload;
},
[MutationTypes.SET_CURRENT_ZOOM](state: State, payload: number) {
state.currentZoom = payload;
},
[MutationTypes.SET_PARSED_URL](state: State, payload: DynmapParsedUrl) {
state.parsedUrl = payload;
},
[MutationTypes.FOLLOW_PLAYER](state: State, player: DynmapPlayer) {
state.following = player;
},

View File

@ -4,7 +4,7 @@ import {
DynmapMessageConfig,
DynmapPlayer,
DynmapServerConfig, DynmapTileUpdate,
DynmapWorld, DynmapWorldState
DynmapWorld, DynmapWorldState, Coordinate, DynmapParsedUrl
} from "@/dynmap";
import {DynmapProjection} from "@/leaflet/projection/DynmapProjection";
@ -26,10 +26,14 @@ export type State = {
currentWorldState: DynmapWorldState;
currentWorld?: DynmapWorld;
currentMap?: DynmapWorldMap;
currentLocation: Coordinate;
currentZoom: number;
currentProjection: DynmapProjection;
updateRequestId: number;
updateTimestamp: Date;
parsedUrl: DynmapParsedUrl;
}
export const state: State = {
@ -100,6 +104,13 @@ export const state: State = {
currentWorld: undefined,
currentMap: undefined,
currentLocation: {
x: 0,
y: 0,
z: 0,
},
currentZoom: 0,
currentProjection: new DynmapProjection(), //Projection for converting location <-> latlg. Object itself isn't reactive for performance reasons
currentWorldState: {
raining: false,
@ -109,4 +120,11 @@ export const state: State = {
updateRequestId: 0,
updateTimestamp: new Date(),
parsedUrl: {
world: undefined,
map: undefined,
location: undefined,
zoom: undefined,
}
};

View File

@ -58,5 +58,41 @@ export default {
return (x: number, y: number, z: number) => {
return projection.locationToLatLng({x, y, z});
};
},
parseMapHash(hash: string) {
const parts = hash.replace('#', '').split(';');
if(parts.length < 3) {
throw new TypeError('Not enough parts');
}
const world = parts[0],
map = parts[1],
location = parts[2].split(',').map(item => parseFloat(item)),
zoom = parts[3] ? parseInt(parts[3]) : undefined;
if(location.length !== 3) {
throw new TypeError('Location should contain exactly 3 numbers');
}
if(location.filter(item => isNaN(item) || !isFinite(item)).length) {
throw new TypeError('Invalid value in location');
}
if(typeof zoom !== 'undefined' && (isNaN(zoom) || zoom < 0 || !isFinite(zoom))) {
throw new TypeError('Invalid value for zoom');
}
return {
world,
map,
location: {
x: location[0],
y: location[1],
z: location[2],
},
zoom,
}
}
}