diff --git a/core/src/main/java/net/pl3x/map/core/configuration/Lang.java b/core/src/main/java/net/pl3x/map/core/configuration/Lang.java index ff7f22833..f1b1fde3f 100644 --- a/core/src/main/java/net/pl3x/map/core/configuration/Lang.java +++ b/core/src/main/java/net/pl3x/map/core/configuration/Lang.java @@ -215,6 +215,14 @@ public final class Lang extends AbstractConfig { public static String UI_BLOCKINFO_UNKNOWN_BLOCK = "Unknown block"; @Key("ui.blockinfo.unknown.biome") public static String UI_BLOCKINFO_UNKNOWN_BIOME = "Unknown biome"; + @Key("ui.contextmenu.label") + public static String UI_CONTEXTMENU_LABEL = "ContextMenu"; + @Key("ui.contextmenu.copy-coords") + public static String UI_CONTEXTMENU_COPY_COORDS = "X: Y: Z: "; + @Key("ui.contextmenu.copy-link") + public static String UI_CONTEXTMENU_COPY_LINK = "Copy link to here"; + @Key("ui.contextmenu.center-map") + public static String UI_CONTEXTMENU_CENTER_MAP = "Center map here"; @Key("ui.coords.label") public static String UI_COORDS_LABEL = "Coordinates"; @Key("ui.coords.value") diff --git a/core/src/main/java/net/pl3x/map/core/configuration/WorldConfig.java b/core/src/main/java/net/pl3x/map/core/configuration/WorldConfig.java index 350f57cba..f37622a15 100644 --- a/core/src/main/java/net/pl3x/map/core/configuration/WorldConfig.java +++ b/core/src/main/java/net/pl3x/map/core/configuration/WorldConfig.java @@ -126,6 +126,54 @@ public final class WorldConfig extends AbstractConfig { @Comment(""" The display position for the link box""") public String UI_LINK = "bottomright"; + + @Key("ui.context-menu.enabled") + @Comment(""" + Enable the context menu.""") + public boolean UI_CONTEXT_MENU_ENABLED = true; + + @Key("ui.context-menu.items") + @Comment(""" + Items to show in the context menu. + Available items are: + copy-coords, copy-link, center-map""") + public List<@NotNull String> UI_CONTEXT_MENU_ITEMS = new ArrayList<>() {{ + add("copy-coords"); + add("copy-link"); + add("center-map"); + }}; + + @Key("ui.context-menu.css") + @Comment(""" + Custom css for the context menu.""") + public static String UI_CONTEXT_MENU_CSS = """ + .leaflet-control-contextmenu { + display: none; + position: absolute; + display: flex; + flex-direction: column; + font-family: monospace; + top: 0; + left: 0; + text-align: left; + white-space: pre; + background-color: var(--ui-background); + border: var(--ui-border); + border-radius: var(--ui-border-radius); + overflow: hidden; + z-index: 10000; /* Ensure the menu appears over other map controls */ + } + + .leaflet-control-contextmenu-item { + padding: 5px; + color: var(--ui-text); + transition: background-color 0.3s ease-in-out; + + &:hover { + background-color: var(--ui-background-hover); + color: var(--ui-text-hover); + } + }"""; @Key("center.x") @Comment(""" diff --git a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java index cf36ac4d8..e69ba9d75 100644 --- a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java +++ b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java @@ -124,6 +124,11 @@ public void run() { ui.put("coords", config.UI_COORDS); ui.put("blockinfo", config.UI_BLOCKINFO); ui.put("attribution", config.UI_ATTRIBUTION); + ui.put("contextMenu", Map.of( + "enabled", config.UI_CONTEXT_MENU_ENABLED, + "items", config.UI_CONTEXT_MENU_ITEMS, + "css", config.UI_CONTEXT_MENU_CSS + )); Map settings = new LinkedHashMap<>(); settings.put("name", world.getName().replace(":", "-")); @@ -166,6 +171,12 @@ private void parseSettings() { "value", Lang.UI_BLOCKINFO_VALUE, "unknown", Map.of("block", Lang.UI_BLOCKINFO_UNKNOWN_BLOCK, "biome", Lang.UI_BLOCKINFO_UNKNOWN_BIOME)) ); + lang.put("contextMenu", Map.of( + "label", Lang.UI_CONTEXTMENU_LABEL, + "copyCoords", Lang.UI_CONTEXTMENU_COPY_COORDS, + "copyLink", Lang.UI_CONTEXTMENU_COPY_LINK, + "centerMap", Lang.UI_CONTEXTMENU_CENTER_MAP + )); lang.put("coords", Map.of("label", Lang.UI_COORDS_LABEL, "value", Lang.UI_COORDS_VALUE)); lang.put("layers", Map.of("label", Lang.UI_LAYERS_LABEL, "value", Lang.UI_LAYERS_VALUE)); lang.put("link", Map.of("label", Lang.UI_LINK_LABEL, "value", Lang.UI_LINK_VALUE)); diff --git a/core/src/main/resources/locale/lang-pl.yml b/core/src/main/resources/locale/lang-pl.yml index a0125b896..60717d2b5 100644 --- a/core/src/main/resources/locale/lang-pl.yml +++ b/core/src/main/resources/locale/lang-pl.yml @@ -116,6 +116,11 @@ ui: unknown: block: Nieznany blok biome: Nieznany biom + contextmenu: + label: ContextMenu + copy-coords: 'X: Y: Z: ' + copy-link: Skopiuj link do tego miejsca + center-map: Wyśrodkuj mapę w tym miejscu coords: label: Współrzędne value: , , diff --git a/webmap/src/control/ContextMenuControl.ts b/webmap/src/control/ContextMenuControl.ts new file mode 100644 index 000000000..07b154226 --- /dev/null +++ b/webmap/src/control/ContextMenuControl.ts @@ -0,0 +1,127 @@ +import * as L from "leaflet"; +import {Pl3xMap} from "../Pl3xMap"; +import {ContextMenuItemType} from "../settings/WorldSettings"; +import {insertCss, removeCss} from "../util/Util"; + +type ContextMenuCallback = { + label: () => string; + callback: (e: L.LeafletMouseEvent) => void; +}; + +export default class ContextMenuControl extends L.Control { + private readonly _pl3xmap: Pl3xMap; + private _dom: HTMLDivElement = L.DomUtil.create('div'); + private _id: string = 'pl3xmap-contextmenu'; + private _items: Map = new Map([ + [ContextMenuItemType.copyCoords, { + label: () => { + const {x, y, z} = this._pl3xmap.controlManager.coordsControl ?? {x: 0, y: 0, z: 0}; + return this._pl3xmap.settings!.lang.contextMenu.copyCoords + .replace(//g, x.toString()) + .replace(//g, y?.toString() ?? '???') + .replace(//g, z.toString()) + }, + callback: () => { + const {x, y, z} = this._pl3xmap.controlManager.coordsControl ?? {x: 0, y: 0, z: 0}; + const coords = `(${x}, ${y ?? '???'}, ${z})`; + navigator.clipboard.writeText(coords) + }, + }], + [ContextMenuItemType.copyLink, { + label: () => this._pl3xmap.settings!.lang.contextMenu.copyLink, + callback : () => { + const {x, z} = this._pl3xmap.controlManager.coordsControl ?? {x: 0, y: 0, z: 0}; + const world = this._pl3xmap.worldManager.currentWorld; + navigator.clipboard.writeText( + window.location.href + + this._pl3xmap.controlManager.linkControl?.getUrlFromCoords( + x, z, + this._pl3xmap.map.getCurrentZoom(), + world + ) + ) + }, + }], + [ContextMenuItemType.centerMap, { + label: () => this._pl3xmap.settings!.lang.contextMenu.centerMap, + callback: (event: L.LeafletMouseEvent) => { + this._pl3xmap.map.panTo(event.latlng); + }, + }] + + ]); + + constructor(pl3xmap: Pl3xMap) { + super(); + this._pl3xmap = pl3xmap; + if (this._pl3xmap.worldManager.currentWorld?.settings.ui.contextMenu.enabled) { + this._init(); + } + } + + private _init(): void { + this._pl3xmap.map.on('contextmenu', this._show, this); + this._pl3xmap.map.on('click', this._hide, this); + + const css = this._pl3xmap.worldManager.currentWorld?.settings.ui.contextMenu.css; + if (css !== undefined) { + insertCss(css, this._id); + } + } + + onAdd(): HTMLDivElement { + this._dom = L.DomUtil.create('div', 'leaflet-control leaflet-control-contextmenu'); + this._dom.dataset.label = this._pl3xmap.settings!.lang.contextMenu.label; + return this._dom; + } + + + onRemove(): void { + removeCss(this._id); + } + + private _show(event: L.LeafletMouseEvent): void { + // Ignore right-clicks on controls (https://github.com/JLyne/LiveAtlas/blob/0819cdf2728b49d361f9adfda09ff08311a59337/src/components/map/MapContextMenu.vue#L188-L194) + if(event.originalEvent.target && (event.originalEvent.target as HTMLElement).closest('.leaflet-control')) { + return; + } + + event.originalEvent.stopImmediatePropagation(); + event.originalEvent.preventDefault(); + + this._dom.style.visibility = 'visible'; + + this._dom.innerHTML = ''; + + const world = this._pl3xmap.worldManager.currentWorld; + world?.settings.ui.contextMenu?.items?.forEach(itemType => { + const item: ContextMenuCallback | undefined = this._items.get(itemType); + if (item === undefined) return; + + const menuItem = L.DomUtil.create('button', 'leaflet-control-contextmenu-item', this._dom); + menuItem.innerHTML = item.label(); + L.DomEvent.on(menuItem, 'click', (ev) => { + L.DomEvent.stopPropagation(ev); + item.callback(event); + this._hide(); + }); + }); + + // Don't position offscreen (https://github.com/JLyne/LiveAtlas/blob/0819cdf2728b49d361f9adfda09ff08311a59337/src/components/map/MapContextMenu.vue#L123-L135) + const x = Math.min( + window.innerWidth - this._dom.offsetWidth - 10, + event.originalEvent.clientX + ), + y = Math.min( + window.innerHeight - this._dom.offsetHeight - 10, + event.originalEvent.clientY + ); + + this._dom.style.transform = `translate(${x}px, ${y}px)`; + } + + private _hide(): void { + this._dom.style.visibility = 'hidden'; + this._dom.style.left = '-1000'; + } +} diff --git a/webmap/src/control/ControlManager.ts b/webmap/src/control/ControlManager.ts index 0ff0eb9b4..87678eb1f 100644 --- a/webmap/src/control/ControlManager.ts +++ b/webmap/src/control/ControlManager.ts @@ -2,11 +2,13 @@ import {Pl3xMap} from "../Pl3xMap"; import {BlockInfoControl} from "./BlockInfoControl"; import {CoordsControl} from "./CoordsControl"; import {LinkControl} from "./LinkControl"; +import ContextMenuControl from "./ContextMenuControl"; import SidebarControl from "./SidebarControl"; export class ControlManager { private readonly _pl3xmap: Pl3xMap; + private _contextMenuControl?: ContextMenuControl; private _sidebarControl?: SidebarControl private _blockInfoControl?: BlockInfoControl; private _coordsControl?: CoordsControl; @@ -16,6 +18,16 @@ export class ControlManager { this._pl3xmap = pl3xmap; } + get contextMenuControl(): ContextMenuControl | undefined { + return this._contextMenuControl; + } + + set contextMenuControl(menu: ContextMenuControl | undefined) { + this._contextMenuControl?.remove(); + this._contextMenuControl = menu; + this._contextMenuControl?.addTo(this._pl3xmap.map); + } + get sidebarControl(): SidebarControl | undefined { return this._sidebarControl; } diff --git a/webmap/src/control/LinkControl.ts b/webmap/src/control/LinkControl.ts index dbed4f04e..421bee0b3 100644 --- a/webmap/src/control/LinkControl.ts +++ b/webmap/src/control/LinkControl.ts @@ -43,6 +43,10 @@ export class LinkControl extends ControlBox { const zoom: number = this._pl3xmap.map.getCurrentZoom(); const x: number = Math.floor(center[0]); const z: number = Math.floor(center[1]); + return this.getUrlFromCoords(x, z, zoom, world); + } + + public getUrlFromCoords(x: number, z: number, zoom: number, world?: World): string { let url: string = `?`; if (world !== undefined) { url += `world=${world.name}&renderer=${world.currentRenderer?.label ?? 'basic'}`; diff --git a/webmap/src/settings/Lang.ts b/webmap/src/settings/Lang.ts index dffaecc7b..6d2479b2e 100644 --- a/webmap/src/settings/Lang.ts +++ b/webmap/src/settings/Lang.ts @@ -4,6 +4,7 @@ export class Lang { private readonly _title: string; private readonly _langFile: string; + private readonly _contextMenu: ContextMenu; private readonly _coords: Label; private readonly _blockInfo: BlockInfo; private readonly _layers: Label; @@ -12,9 +13,10 @@ export class Lang { private readonly _players: Label; private readonly _worlds: Label; - constructor(title: string, langFile: string, coords: Label, blockInfo: BlockInfo, layers: Label, link: Label, markers: Label, players: Label, worlds: Label) { + constructor(title: string, langFile: string, contextMenu: ContextMenu, coords: Label, blockInfo: BlockInfo, layers: Label, link: Label, markers: Label, players: Label, worlds: Label) { this._title = title; this._langFile = langFile; + this._contextMenu = contextMenu; this._coords = coords; this._blockInfo = blockInfo; this._layers = layers; @@ -32,6 +34,10 @@ export class Lang { return this._langFile; } + get contextMenu(): ContextMenu { + return this._contextMenu; + } + get coords(): Label { return this._coords; } @@ -118,3 +124,33 @@ export class BlockInfo extends Label { return this._unknown; } } + +export class ContextMenu { + private readonly _label: string; + private readonly _copyCoords: string; + private readonly _copyLink: string; + private readonly _centerMap: string; + + constructor(label: string, copyCoords: string, copyLink: string, centerMap: string) { + this._label = label; + this._copyCoords = copyCoords; + this._copyLink = copyLink; + this._centerMap = centerMap; + } + + get label(): string { + return this._label; + } + + get copyCoords(): string { + return this._copyCoords; + } + + get copyLink(): string { + return this._copyLink; + } + + get centerMap(): string { + return this._centerMap; + } +} diff --git a/webmap/src/settings/WorldSettings.ts b/webmap/src/settings/WorldSettings.ts index a4f413737..2f449ab05 100644 --- a/webmap/src/settings/WorldSettings.ts +++ b/webmap/src/settings/WorldSettings.ts @@ -135,6 +135,7 @@ export class UI { private _coords: string = 'bottomcenter'; private _blockinfo: string = 'bottomleft'; private _attribution: boolean = true; + private _contextMenu: ContextMenuSettings = new ContextMenuSettings(); get link(): string { return this._link; @@ -167,6 +168,60 @@ export class UI { set attribution(value: boolean) { this._attribution = value; } + + get contextMenu(): ContextMenuSettings { + return this._contextMenu; + } + + set contextMenu(value: ContextMenuSettings) { + this._contextMenu = value; + } +} + +/** + * Represents a world's context menu settings. + */ +export class ContextMenuSettings { + private _enabled: boolean = true; + private _items: ContextMenuItemType[] = [ + ContextMenuItemType.copyCoords, + ContextMenuItemType.copyLink, + ContextMenuItemType.centerMap, + ]; + private _css: string = ''; + + get enabled(): boolean { + return this._enabled; + } + + set enabled(value: boolean) { + this._enabled = value; + } + + get items(): ContextMenuItemType[] { + return this._items; + } + + set items(value: ContextMenuItemType[]) { + this._items = value; + } + + get css(): string { + return this._css; + } + + set css(value: string) { + this._css = value; + } +} + +/** + * Represents a world's context menu item. + */ +export enum ContextMenuItemType { + copyCoords = 'copy-coords', + copyLink = 'copy-link', + centerMap = 'center-map', } /** diff --git a/webmap/src/world/WorldManager.ts b/webmap/src/world/WorldManager.ts index 3cad9a98e..c23f2ef4f 100644 --- a/webmap/src/world/WorldManager.ts +++ b/webmap/src/world/WorldManager.ts @@ -6,6 +6,7 @@ import {Settings} from "../settings/Settings"; import {Renderer, World} from "./World"; import {fireCustomEvent, getUrlParam} from "../util/Util"; import {UI, WorldSettings} from "../settings/WorldSettings"; +import ContextMenuControl from "../control/ContextMenuControl"; /** * The world manager. Manages all loaded worlds. @@ -78,6 +79,7 @@ export class WorldManager { ); const ui: UI = world.settings.ui; + this._pl3xmap.controlManager.contextMenuControl = new ContextMenuControl(this._pl3xmap); this._pl3xmap.controlManager.linkControl = ui.link ? new LinkControl(this._pl3xmap, ui.link) : undefined; this._pl3xmap.controlManager.coordsControl = ui.coords ? new CoordsControl(this._pl3xmap, ui.coords) : undefined; this._pl3xmap.controlManager.blockInfoControl = ui.blockinfo ? new BlockInfoControl(this._pl3xmap, ui.blockinfo) : undefined;