Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 0ca4bd2

Browse files
Right-click context menu
1 parent ed0d91a commit 0ca4bd2

17 files changed

Lines changed: 354 additions & 6 deletions

File tree

Alidade.Map/MapInteropService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ public void OnMapClick(double lat, double lon, double screenX, double screenY, s
142142
public void OnMapDblClick(double lat, double lon, double screenX, double screenY, string? elementId)
143143
=> _ = mediator.Publish(new MapDblClicked.Notification(new MapClickEvent(lat, lon, screenX, screenY, elementId, false)));
144144

145+
/// <summary>
146+
/// Called from JS when the user right-clicks the map canvas.
147+
/// </summary>
148+
/// <param name="screenX">Right-click X position in CSS pixels.</param>
149+
/// <param name="screenY">Right-click Y position in CSS pixels.</param>
150+
/// <param name="elementIds">
151+
/// Feature ID strings for all OSM features under the cursor, ordered top-to-bottom.
152+
/// Empty when no feature is under the cursor.
153+
/// </param>
154+
[JSInvokable]
155+
public void OnMapRightClick(double screenX, double screenY, string[] elementIds)
156+
=> _ = mediator.Publish(new MapRightClicked.Notification(screenX, screenY, elementIds));
157+
145158
/// <summary>
146159
/// Called from JS after a pan or zoom gesture ends.
147160
/// </summary>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Alidade.Map.Notifications;
2+
3+
/// <summary>
4+
/// Published by <see cref="MapInteropService"/> when the user right-clicks the map canvas.
5+
/// </summary>
6+
public static class MapRightClicked
7+
{
8+
/// <summary>
9+
/// Carries the screen position of the right-click and all OSM element IDs under the cursor,
10+
/// ordered top-to-bottom by paint order.
11+
/// </summary>
12+
/// <param name="ScreenX">The X position of the right-click in CSS pixels.</param>
13+
/// <param name="ScreenY">The Y position of the right-click in CSS pixels.</param>
14+
/// <param name="ElementIds">
15+
/// Feature ID strings (e.g. <c>"way/123"</c>) for all rendered OSM features under the cursor,
16+
/// ordered by paint layer (top first). Empty when no feature is under the cursor.
17+
/// </param>
18+
public record Notification(double ScreenX, double ScreenY, string[] ElementIds) : NotificationBase;
19+
}

Alidade.Map/wwwroot/assets/js/map-interop.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,26 @@ window.mapInterop = (() => {
13291329
).catch(console.error);
13301330
});
13311331

1332+
// Right-click: show the context menu. Pass all OSM element IDs under the cursor
1333+
// (in paint order, top first) so C# can prefer the currently selected one.
1334+
map.on('contextmenu', e => {
1335+
e.preventDefault();
1336+
if (!dotnetRef)
1337+
{
1338+
return;
1339+
}
1340+
const queryLayers = [
1341+
'layer-vertices', 'layer-ways-hit', 'layer-nodes', 'layer-notes'
1342+
].filter(id => map.getLayer(id));
1343+
const features = queryLayers.length > 0
1344+
? map.queryRenderedFeatures(e.point, { layers: queryLayers })
1345+
: [];
1346+
const elementIds = features
1347+
.map(f => elementIdFromFeature(f))
1348+
.filter(id => id !== null);
1349+
dotnetRef.invokeMethodAsync('OnMapRightClick', e.point.x, e.point.y, elementIds).catch(console.error);
1350+
});
1351+
13321352
// Proximity reveal: show normally-hidden way-nodes within PROXIMITY_PX of the cursor
13331353
// by temporarily widening the layer-nodes filter to include their IDs. Runs every
13341354
// mousemove (no debounce) so the reveal feels instant; map.setFilter is cheap.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
@if (mapState.State.ContextMenuVisible)
2+
{
3+
@* Transparent backdrop — clicking it closes the menu without taking any action *@
4+
<div class="context-menu-backdrop" @onclick="OnBackdropClick"></div>
5+
6+
<ul class="context-menu"
7+
style="left: @(mapState.State.ContextMenuX)px; top: @(mapState.State.ContextMenuY)px">
8+
<li>
9+
<button class="context-menu-item" @onclick="OnSquare">Square</button>
10+
</li>
11+
<li>
12+
<button class="context-menu-item" @onclick="OnCircularize">Circularize</button>
13+
</li>
14+
<li>
15+
<button class="context-menu-item" @onclick="OnGridify">Gridify</button>
16+
</li>
17+
</ul>
18+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Alidade.Handlers.Map;
2+
using Alidade.Handlers.Selection;
3+
using Alidade.Handlers.Tool;
4+
5+
namespace Alidade.Components;
6+
7+
/// <summary>
8+
/// Right-click context menu offering Square, Circularize, and Gridify for the target element.
9+
/// Positioned at the coordinates stored in <see cref="MapStateService"/> and dismissed by
10+
/// clicking the backdrop or selecting an action.
11+
/// </summary>
12+
public partial class ContextMenu(
13+
MapStateService mapState,
14+
SelectionStateService selectionState,
15+
IMediator mediator) : IDisposable
16+
{
17+
/// <inheritdoc />
18+
protected override void OnInitialized()
19+
=> mapState.StateChanged += OnStateChanged;
20+
21+
private void OnStateChanged(object? sender, EventArgs e)
22+
=> StateHasChanged();
23+
24+
private async Task OnSquare()
25+
{
26+
await EnsureTargetSelected();
27+
await mediator.Publish(new Square.Notification());
28+
await mediator.Send(new HideContextMenu.Command());
29+
}
30+
31+
private async Task OnCircularize()
32+
{
33+
await EnsureTargetSelected();
34+
await mediator.Send(new ToggleCircularizeDialog.Command());
35+
await mediator.Send(new HideContextMenu.Command());
36+
}
37+
38+
private async Task OnGridify()
39+
{
40+
await EnsureTargetSelected();
41+
await mediator.Send(new ToggleGridifyDialog.Command());
42+
await mediator.Send(new HideContextMenu.Command());
43+
}
44+
45+
private async Task OnBackdropClick()
46+
=> await mediator.Send(new HideContextMenu.Command());
47+
48+
private async Task EnsureTargetSelected()
49+
{
50+
OsmElementRef? target = mapState.State.ContextMenuTargetElement;
51+
if (target is not null && !selectionState.State.Selected.Contains(target))
52+
{
53+
await mediator.Send(new Select.Command(target, false));
54+
}
55+
}
56+
57+
/// <inheritdoc />
58+
public void Dispose()
59+
=> mapState.StateChanged -= OnStateChanged;
60+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
.context-menu-backdrop {
2+
position: fixed;
3+
inset: 0;
4+
z-index: 900;
5+
}
6+
7+
.context-menu {
8+
position: fixed;
9+
z-index: 901;
10+
list-style: none;
11+
margin: 0;
12+
padding: 4px 0;
13+
background: var(--panel-bg, #fff);
14+
border: 1px solid var(--panel-border, #ccc);
15+
border-radius: 4px;
16+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
17+
min-width: 140px;
18+
}
19+
20+
.context-menu-item {
21+
display: block;
22+
width: 100%;
23+
padding: 6px 14px;
24+
background: none;
25+
border: none;
26+
text-align: left;
27+
cursor: pointer;
28+
font-size: 0.875rem;
29+
color: inherit;
30+
}
31+
32+
.context-menu-item:hover {
33+
background: var(--panel-hover, #f0f0f0);
34+
}

Alidade/Components/Panel.razor

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
@typeparam TPanel where TPanel : IPanel
22

33
<div class="panel @Class @(Visible ? string.Empty : "panel--hidden")" @ref="_panelEl">
4-
<div class="panel-header" @ref="_headerEl">
4+
<div class="panel-header"
5+
@ref="_headerEl"
6+
@oncontextmenu="HandleHeaderContextMenu"
7+
@oncontextmenu:preventDefault>
58
<span class="panel-title">
69
@if (PinnedRef is not null)
710
{

Alidade/Components/Panel.razor.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Alidade.Components.Panels;
22
using Microsoft.AspNetCore.Components;
3+
using Microsoft.AspNetCore.Components.Web;
34
using Microsoft.JSInterop;
45

56
namespace Alidade.Components;
@@ -83,6 +84,21 @@ public partial class Panel<TPanel>(IJSRuntime js) where TPanel : IPanel
8384
[Parameter]
8485
public string? Title { get; set; }
8586

87+
/// <summary>
88+
/// When set, fires when the user right-clicks the panel header. The browser default context
89+
/// menu is always suppressed on panel headers regardless of whether this is assigned.
90+
/// </summary>
91+
[Parameter]
92+
public EventCallback<MouseEventArgs> OnHeaderContextMenu { get; set; }
93+
94+
private async Task HandleHeaderContextMenu(MouseEventArgs e)
95+
{
96+
if (OnHeaderContextMenu.HasDelegate)
97+
{
98+
await OnHeaderContextMenu.InvokeAsync(e);
99+
}
100+
}
101+
86102
private string DisplayTitle => Title ?? TPanel.Title;
87103

88104
private ElementReference _panelEl;

Alidade/Components/Panels/InspectorPanel.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
PinnedRef="PinnedRef"
66
OnClose="OnClose"
77
OnPin="PinCurrent"
8+
OnHeaderContextMenu="OnTitleBarContextMenu"
89
Visible="@(_targetRef is not null || IsPinned)"
910
Class="@(IsPinned ? "panel--pinned" : null)"
1011
Title="@_elementLabel">

Alidade/Components/Panels/InspectorPanel.razor.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,16 @@ OsmElementTypes.Relation when buf.Relations.TryGetValue(_targetRef.Id, out OsmRe
273273
_nsiResults = [];
274274
}
275275

276+
private void OnTitleBarContextMenu(MouseEventArgs e)
277+
{
278+
if (_targetRef is null)
279+
{
280+
return;
281+
}
282+
283+
_ = mediator.Send(new ShowContextMenu.Command(e.ClientX, e.ClientY, _targetRef));
284+
}
285+
276286
private void PinCurrent()
277287
{
278288
if (_targetRef is not null)

0 commit comments

Comments
 (0)