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

Commit 765a357

Browse files
Add multi-select and automatically multi-select when finishing a gridify action
1 parent 77cc5f3 commit 765a357

15 files changed

Lines changed: 879 additions & 33 deletions

Alidade.Map/MapInteropService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ public ValueTask ClearBackgroundImageryAsync()
114114
/// <param name="screenX">Click X position in CSS pixels.</param>
115115
/// <param name="screenY">Click Y position in CSS pixels.</param>
116116
/// <param name="elementId">The feature ID string under the click, if any.</param>
117-
/// <param name="shiftKey">Whether the Shift key was held during the click.</param>
117+
/// <param name="addToSelection">Whether a modifier key (Shift, Ctrl, or Cmd) was held during the click.</param>
118118
[JSInvokable]
119-
public void OnMapClick(double lat, double lon, double screenX, double screenY, string? elementId, bool shiftKey = false)
120-
=> _ = mediator.Publish(new MapClicked.Notification(new MapClickEvent(lat, lon, screenX, screenY, elementId, shiftKey)));
119+
public void OnMapClick(double lat, double lon, double screenX, double screenY, string? elementId, bool addToSelection = false)
120+
=> _ = mediator.Publish(new MapClicked.Notification(new MapClickEvent(lat, lon, screenX, screenY, elementId, addToSelection)));
121121

122122
/// <summary>
123123
/// Called from JS when the user double-clicks the map canvas on an OSM feature.

Alidade.Map/Models/MapClickEvent.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ namespace Alidade.Map.Models;
1111
/// The feature ID of the topmost OSM element under the click (e.g. <c>"node/12345"</c>),
1212
/// or null if the click landed on empty map space.
1313
/// </param>
14-
/// <param name="ShiftKey">Whether the Shift key was held at the time of the click (used for multi-select).</param>
15-
public record MapClickEvent(double Lat, double Lon, double ScreenX, double ScreenY, string? ElementId, bool ShiftKey = false);
14+
/// <param name="AddToSelection">Whether a modifier key (Shift, Ctrl, or Cmd) was held at the time of the click, adding the element to the current selection.</param>
15+
public record MapClickEvent(double Lat, double Lon, double ScreenX, double ScreenY, string? ElementId, bool AddToSelection = false);

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,7 +1222,8 @@ window.mapInterop = (() => {
12221222
style: blankStyle,
12231223
center: saved ? [saved.lon, saved.lat] : [0, 20],
12241224
zoom: saved ? saved.zoom : 2,
1225-
attributionControl: false
1225+
attributionControl: false,
1226+
boxZoom: false
12261227
});
12271228

12281229
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
@@ -1285,7 +1286,7 @@ window.mapInterop = (() => {
12851286
e.lngLat.lat, e.lngLat.lng,
12861287
e.point.x, e.point.y,
12871288
elementId,
1288-
e.originalEvent?.shiftKey ?? false
1289+
(e.originalEvent?.shiftKey || e.originalEvent?.ctrlKey || e.originalEvent?.metaKey) ?? false
12891290
).catch(console.error);
12901291
});
12911292

Alidade.Osm/Handlers/Editing/GridifyWay.cs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,25 @@ namespace Alidade.Osm.Handlers.Editing;
22

33
/// <inheritdoc />
44
public sealed class GridifyWay(EditBufferStateService editBufferState)
5-
: IRequestHandler<GridifyWay.Command, CommandResult>
5+
: IRequestHandler<GridifyWay.Query, QueryResult<IReadOnlyList<OsmElementRef>>>
66
{
7-
static GridifyWay() => UndoDescriptions.Register<Command>("Gridify");
7+
static GridifyWay() => UndoDescriptions.Register<Query>("Gridify");
88

99
/// <summary>
1010
/// Splits a closed way into a grid of equal rectangular sub-areas.
1111
/// The grid is rotated by <see cref="RotationDeg"/> degrees (clockwise from east in
12-
/// the local flat-Earth projection). The original way is replaced by the new cell ways.
12+
/// the local flat-Earth projection). The original way is replaced by the new cell ways,
13+
/// whose refs are returned on success.
1314
/// </summary>
1415
/// <param name="WayId">The ID of the closed way to gridify.</param>
1516
/// <param name="Rows">Number of rows in the output grid.</param>
1617
/// <param name="Cols">Number of columns in the output grid.</param>
1718
/// <param name="RotationDeg">Grid rotation in degrees.</param>
18-
public record Command(long WayId, int Rows, int Cols, double RotationDeg)
19-
: IRequest<CommandResult>, IUndoableCommand;
19+
public record Query(long WayId, int Rows, int Cols, double RotationDeg)
20+
: IRequest<QueryResult<IReadOnlyList<OsmElementRef>>>, IUndoableCommand;
2021

2122
/// <inheritdoc />
22-
public Task<CommandResult> Handle(Command request, CancellationToken cancellationToken)
23+
public Task<QueryResult<IReadOnlyList<OsmElementRef>>> Handle(Query request, CancellationToken cancellationToken)
2324
{
2425
GridifyResult result = GeometryService.Gridify(
2526
request.WayId,
@@ -31,13 +32,13 @@ public Task<CommandResult> Handle(Command request, CancellationToken cancellatio
3132

3233
if (result.CellNodeRefs.Count == 0)
3334
{
34-
return Task.FromResult(CommandResult.Fail("The selected way cannot be gridified. Select a single closed way."));
35+
return Task.FromResult(QueryResult<IReadOnlyList<OsmElementRef>>.Fail("The selected way cannot be gridified. Select a single closed way."));
3536
}
3637

3738
EditBufferState state = editBufferState.State;
3839
if (!state.Ways.TryGetValue(request.WayId, out OsmWay? originalWay))
3940
{
40-
return Task.FromResult(CommandResult.Pass());
41+
return Task.FromResult(QueryResult<IReadOnlyList<OsmElementRef>>.Fail());
4142
}
4243

4344
ImmutableDictionary<long, OsmNode> nodeDict = state.Nodes;
@@ -57,13 +58,15 @@ public Task<CommandResult> Handle(Command request, CancellationToken cancellatio
5758
}
5859

5960
IReadOnlyDictionary<string, string> tags = originalWay.Tags;
60-
foreach (IReadOnlyList<GridifyNodeRef> cellRefs in result.CellNodeRefs)
61+
List<OsmElementRef> cellRefs = [];
62+
foreach (IReadOnlyList<GridifyNodeRef> cellNodeRefs in result.CellNodeRefs)
6163
{
62-
long[] cellNodeIds = [.. cellRefs.Select(r =>
64+
long[] cellNodeIds = [.. cellNodeRefs.Select(r =>
6365
r.IsExisting ? r.ExistingNodeId : newNodeIds[r.NewNodeIndex])];
6466
OsmWay cellWay = new(nextId, 1, null, null, null, cellNodeIds, tags.ToImmutableDictionary());
6567
wayDict = wayDict.SetItem(nextId, cellWay);
6668
editStates = editStates.SetItem(cellWay.Ref, EditState.Created);
69+
cellRefs.Add(cellWay.Ref);
6770
nextId--;
6871
}
6972

@@ -86,6 +89,7 @@ public Task<CommandResult> Handle(Command request, CancellationToken cancellatio
8689
EditStates = editStates,
8790
NextNegativeId = nextId
8891
});
89-
return Task.FromResult(CommandResult.Pass());
92+
93+
return Task.FromResult<QueryResult<IReadOnlyList<OsmElementRef>>>(cellRefs);
9094
}
9195
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace Alidade.Osm.Handlers.Tagging;
2+
3+
/// <inheritdoc />
4+
public sealed class BulkUpdateTags(EditBufferStateService editBufferState)
5+
: IRequestHandler<BulkUpdateTags.Command, CommandResult>
6+
{
7+
static BulkUpdateTags() => UndoDescriptions.Register<Command>("Edit tags");
8+
9+
/// <summary>
10+
/// Replaces the tag set on each target element in a single atomic operation,
11+
/// producing one entry on the undo stack regardless of how many elements are updated.
12+
/// </summary>
13+
/// <param name="Updates">
14+
/// The list of elements to update, each paired with its complete new tag set.
15+
/// </param>
16+
public record Command(IReadOnlyList<(OsmElementRef Target, IReadOnlyDictionary<string, string> NewTags)> Updates)
17+
: IRequest<CommandResult>, IUndoableCommand;
18+
19+
/// <inheritdoc />
20+
public Task<CommandResult> Handle(Command request, CancellationToken cancellationToken)
21+
{
22+
EditBufferState state = editBufferState.State;
23+
24+
foreach ((OsmElementRef target, IReadOnlyDictionary<string, string> newTags) in request.Updates)
25+
{
26+
ImmutableDictionary<string, string> tags = newTags.ToImmutableDictionary();
27+
28+
EditState es = state.EditStates.TryGetValue(target, out EditState existing) && existing == EditState.Created
29+
? EditState.Created
30+
: EditState.Modified;
31+
32+
state = target.Type switch
33+
{
34+
OsmElementTypes.Node when state.Nodes.TryGetValue(target.Id, out OsmNode? n) =>
35+
state with
36+
{
37+
Nodes = state.Nodes.SetItem(n.Id, n with { Tags = tags }),
38+
EditStates = state.EditStates.SetItem(target, es)
39+
},
40+
OsmElementTypes.Way when state.Ways.TryGetValue(target.Id, out OsmWay? w) =>
41+
state with
42+
{
43+
Ways = state.Ways.SetItem(w.Id, w with { Tags = tags }),
44+
EditStates = state.EditStates.SetItem(target, es)
45+
},
46+
OsmElementTypes.Relation when state.Relations.TryGetValue(target.Id, out OsmRelation? r) =>
47+
state with
48+
{
49+
Relations = state.Relations.SetItem(r.Id, r with { Tags = tags }),
50+
EditStates = state.EditStates.SetItem(target, es)
51+
},
52+
_ => state
53+
};
54+
}
55+
56+
editBufferState.SetState(state);
57+
return Task.FromResult(CommandResult.Pass());
58+
}
59+
}

Alidade.Osm/Handlers/Editing/UpdateTags.cs renamed to Alidade.Osm/Handlers/Tagging/UpdateTags.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
namespace Alidade.Osm.Handlers.Editing;
1+
namespace Alidade.Osm.Handlers.Tagging;
22

33
/// <inheritdoc />
4-
public class UpdateTags(EditBufferStateService editBufferState) : IRequestHandler<UpdateTags.Command, CommandResult>
4+
public class UpdateTags(EditBufferStateService editBufferState)
5+
: IRequestHandler<UpdateTags.Command, CommandResult>
56
{
67
static UpdateTags() => UndoDescriptions.Register<Command>("Edit tags");
78

Alidade/Components/Dialogs/ConflictResolutionDialog.razor.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Alidade.Osm.Handlers.Editing;
2+
using Alidade.Osm.Handlers.Tagging;
23

34
namespace Alidade.Components.Dialogs;
45

Alidade/Components/Dialogs/GridifyDialog.razor.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,16 @@ private async Task ApplyAsync()
206206
}
207207

208208
await ClearPreviewAsync();
209-
await mediator.Send(new GridifyWay.Command(_wayId.Value, _rows, _cols, _rotation));
209+
210+
IReadOnlyList<OsmElementRef>? cellWays = (await mediator.Send(
211+
new GridifyWay.Query(_wayId.Value, _rows, _cols, _rotation))).Result;
212+
210213
await mediator.Send(new ToggleGridifyDialog.Command());
211-
await mediator.Send(new ClearSelection.Command());
214+
215+
ImmutableHashSet<OsmElementRef> selection = cellWays is not null
216+
? [.. cellWays]
217+
: [];
218+
selectionState.SetState(selectionState.State with { Selected = selection });
212219
}
213220

214221
private void Cancel()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<div class="inspector-panel @(_selected.Count <= 1 ? "inspector-panel-hidden" : string.Empty)">
2+
3+
<div class="inspector-header">
4+
<span class="inspector-title">@_selected.Count Features</span>
5+
<div class="inspector-header-actions">
6+
<button class="inspector-close" title="Deselect (Escape)" @onclick="OnClose">✕</button>
7+
</div>
8+
</div>
9+
10+
@if (_selected.Count > 1)
11+
{
12+
@* Individual feature list with per-element deselect *@
13+
<ul class="multi-inspector-features">
14+
@foreach (FeatureItem feature in _features)
15+
{
16+
FeatureItem captured = feature;
17+
<li class="multi-inspector-feature">
18+
<span class="multi-inspector-feature-name">@captured.Label</span>
19+
<button class="multi-inspector-feature-remove" title="Deselect"
20+
@onclick="() => OnDeselectFeature(captured.Ref)">✕</button>
21+
</li>
22+
}
23+
</ul>
24+
25+
@* Feature Type row with optional preset search *@
26+
<div class="inspector-preset-row">
27+
@if (_isSearching)
28+
{
29+
<input @ref="_searchInput"
30+
type="text"
31+
placeholder="Search presets…"
32+
value="@_query"
33+
@oninput="OnQueryChanged"
34+
@onkeydown="OnSearchKeyDown"
35+
class="inspector-search-input" />
36+
<button class="inspector-cancel-search" @onclick="CancelSearch" title="Cancel search">✕</button>
37+
}
38+
else
39+
{
40+
<span class="inspector-preset-name">@_featureTypeLabel</span>
41+
@if (_commonGeometry is not null)
42+
{
43+
<button class="inspector-change-preset" @onclick="StartPresetSearch">Change…</button>
44+
}
45+
}
46+
</div>
47+
48+
@if (_results.Count > 0)
49+
{
50+
<ul class="inspector-preset-results">
51+
@foreach (Preset p in _results)
52+
{
53+
Preset captured = p;
54+
<li @onclick="() => ApplyPresetToAllAsync(captured)" class="inspector-preset-result">
55+
<span class="inspector-preset-result-name">@(captured.Name ?? captured.Id)</span>
56+
</li>
57+
}
58+
</ul>
59+
}
60+
61+
@* Merged tag table *@
62+
<div class="inspector-tags-section">
63+
<div class="inspector-section-header">Tags (@_mergedTags.Count)</div>
64+
<div class="tag-editor">
65+
<table class="tag-table">
66+
<thead>
67+
<tr>
68+
<th>Key</th>
69+
<th>Value</th>
70+
<th></th>
71+
</tr>
72+
</thead>
73+
<tbody>
74+
@foreach (MergedTag tag in _mergedTags)
75+
{
76+
string key = tag.Key;
77+
string displayValue = tag.IsConsistent ? (tag.CommonValue ?? string.Empty) : string.Empty;
78+
string placeholder = tag.IsConsistent ? string.Empty : "*";
79+
<tr>
80+
<td class="tag-key">
81+
<input type="text" value="@key"
82+
@onchange="@(e => OnKeyChanged(key, e.Value?.ToString() ?? string.Empty))" />
83+
</td>
84+
<td class="tag-value-cell">
85+
<input type="text"
86+
value="@displayValue"
87+
placeholder="@placeholder"
88+
@onchange="@(e => OnValueChanged(key, e.Value?.ToString() ?? string.Empty))"
89+
class="tag-value @(tag.IsConsistent ? string.Empty : "tag-value--multiple")" />
90+
</td>
91+
<td>
92+
<button class="tag-delete" @onclick="@(() => OnDeleteTag(key))">✕</button>
93+
</td>
94+
</tr>
95+
}
96+
</tbody>
97+
</table>
98+
<button class="tag-add" @onclick="OnAddTag">+ Add tag</button>
99+
</div>
100+
</div>
101+
}
102+
103+
</div>

0 commit comments

Comments
 (0)