From ba81a66f8c5b181e3a2128a14b6105586d9a594f Mon Sep 17 00:00:00 2001 From: Tim Purdum Date: Fri, 22 May 2026 11:54:46 -0500 Subject: [PATCH 1/3] Add SketchWidget as Custom Editor Pro sample Demonstrates driving the SketchWidget programmatically from C# while presenting a fully custom UI: a Move button arms an OnMapClick handler that hit-tests a target FeatureLayer, stages the picked Graphic into a companion GraphicsLayer, and hands it to SketchWidget.Update with a SketchToolUpdateOptions configured for SketchTool.Move. A Save button calls Complete() to finalize the edit (forwarded through FeatureLayer.ApplyEdits); a Cancel button (or the Esc key) calls Cancel() and skips ApplyEdits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wwwroot/images/sketchEditor.svg | 19 ++ .../Pages/SketchAsEditor.razor | 234 ++++++++++++++++++ .../Pages/SketchAsEditor.razor.css | 36 +++ .../Shared/ProNavMenu.cs | 1 + .../_Imports.razor | 1 + 5 files changed, 291 insertions(+) create mode 100644 samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/images/sketchEditor.svg create mode 100644 samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor create mode 100644 samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor.css diff --git a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/images/sketchEditor.svg b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/images/sketchEditor.svg new file mode 100644 index 0000000..101640b --- /dev/null +++ b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/images/sketchEditor.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor new file mode 100644 index 0000000..94ffa91 --- /dev/null +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor @@ -0,0 +1,234 @@ +@page "/sketch-as-editor" +@inherits SamplePage + +Sketch Widget as Custom Editor +

Sketch Widget as Custom Editor

+ +

+ This sample drives a SketchWidget programmatically from C# while presenting a fully custom UI. + The widget's container is parked in a hidden <div> via ContainerId, so its + built-in toolbar stays out of view and the only controls you see are the custom Move, + Save, and Cancel buttons below. +

+

+ Click the Move button to arm the editor, then click any polygon on the map to grab it. + Drag the highlighted feature to a new location, then click Save to commit the move — the new + geometry is sent through FeatureLayer.ApplyEdits, the staging graphic is cleared, and the layer + is refreshed so the feature redraws at its updated position. Click Cancel (or press + Esc) at any point to abandon the edit. If you click somewhere that isn't a feature, the + editor disarms and reports "No feature at that point" — click Move again to retry. +

+ +
+ @if (_editing) + { + + + } + else + { + + } + @if (_statusMessage is not null) + { + @_statusMessage + } +
+ +@* Host element for the SketchWidget; hidden because this sample drives the widget programmatically. *@ + + + + + + + + + + + + + +@code { + + public override List PageLinks => + [ + new("https://developers.arcgis.com/javascript/latest/references/core/widgets/Sketch/", "ArcGIS Maps SDK for JavaScript") + ]; + + public override string Description => + "This GeoBlazor Pro sample, written in Blazor for .NET developers, demonstrates how to repurpose the " + + "SketchWidget as a programmatic move editor while presenting a fully custom UI. The widget's container is " + + "parked in a hidden div via ContainerId, so its built-in toolbar stays out of the view and the only " + + "controls the user sees are the custom Move, Save, and Cancel buttons. A custom Move button arms an " + + "OnMapClick handler that hit-tests the target FeatureLayer, stages the picked Graphic into a companion " + + "GraphicsLayer, and hands it to SketchWidget.Update with a SketchToolUpdateOptions configured for " + + "SketchTool.Move. A Save button calls SketchWidget.Complete() to finish the active update; the resulting " + + "SketchUpdateEvent is forwarded to FeatureLayer.ApplyEdits to persist the new geometry on the feature " + + "service, then the staging graphic is removed and the feature layer is refreshed so it redraws at the " + + "updated location. A Cancel button (and the Esc key) route through OnKeyDown to SketchWidget.Cancel(), " + + "which fires a Complete event with Aborted=true so ApplyEdits is skipped. The pattern is useful any time " + + "you need SketchViewModel's interactive move semantics inside a custom workflow with your own editor UI."; + + private void ArmMove() + { + _moveArmed = true; + _statusMessage = null; + } + + private async Task OnMapClick(ClickEvent evt) + { + if (!_moveArmed || _mapView is null || _featureLayer is null || _sketchWidget is null) + { + return; + } + + // One-shot: disarm immediately so a missed click doesn't stay sticky. + _moveArmed = false; + + HitTestOptions options = new() + { + IncludeByGeoBlazorId = [_featureLayer.Id] + }; + HitTestResult hit = await _mapView.HitTest(evt, options); + Graphic? graphic = hit.Results.OfType().FirstOrDefault()?.Graphic; + + if (graphic is null) + { + _statusMessage = "No feature at that point."; + return; + } + + // Stage the picked graphic into the Sketch's own GraphicsLayer so the widget can operate on it. + await _sketchLayer!.Add(graphic); + await _sketchWidget.Update([graphic], _updateOptions); + _editing = true; + _statusMessage = null; + } + + private async Task SaveMove() + { + if (_sketchWidget is null) return; + // Tell SketchViewModel to finish the active update; HandleSketchEvent will run ApplyEdits. + await _sketchWidget.Complete(); + } + + private async Task CancelMove() + { + if (_sketchWidget is null) return; + await _sketchWidget.Cancel(); + } + + private async Task OnKeyDown(KeyDownEvent evt) + { + if (evt.Key != "Escape" || _sketchWidget is null) return; + + if (_editing) + { + await _sketchWidget.Cancel(); + } + else if (_moveArmed) + { + _moveArmed = false; + _statusMessage = "Cancelled."; + } + } + + private async Task HandleSketchEvent(SketchUpdateEvent evt) + { + if (evt.State != SketchEventState.Complete) + { + return; + } + + _editing = false; + Graphic? moved = evt.Graphics?.FirstOrDefault(); + + // Cancel() fires Complete with Aborted=true; skip ApplyEdits but still clean up the staging graphic. + if (evt.Aborted == true) + { + if (moved is not null) + { + await _sketchLayer!.Remove(moved); + } + _statusMessage = "Cancelled."; + StateHasChanged(); + return; + } + + if (moved is null || _featureLayer is null) return; + + try + { + FeatureEditsResult result = await _featureLayer.ApplyEdits(new FeatureEdits + { + UpdateFeatures = [moved] + }); + + FeatureEditResult? firstError = result.UpdateFeatureResults + .FirstOrDefault(r => r.Error is not null); + + if (firstError is not null) + { + _statusMessage = $"Edit rejected: {firstError.Error!.Message}"; + } + else + { + _statusMessage = moved.Attributes.ContainsKey("OBJECTID") + ? $"Moved OBJECTID {moved.Attributes["OBJECTID"]}." + : "Moved."; + } + } + catch (Exception ex) + { + _statusMessage = $"ApplyEdits failed: {ex.Message}"; + } + finally + { + // Clean up the staging graphic and reload the feature layer so it shows the new geometry. + await _sketchLayer!.Remove(moved); + await _featureLayer.Refresh(); + StateHasChanged(); + } + } + + // Configure the SketchWidget to perform a Move update; all other options use their defaults. + private readonly SketchToolUpdateOptions _updateOptions = + new(SketchTool.Move, null, null, null, + null, null, null, + null!, null!); + + private MapView? _mapView; + private FeatureLayer? _featureLayer; + private GraphicsLayer? _sketchLayer; + private SketchWidget? _sketchWidget; + + private bool _moveArmed; + private bool _editing; + private string? _statusMessage; + + private readonly List _featureSource = + [ + new(new Polygon([ + new MapPath(new MapPoint(0, 0), + new MapPoint(10, 0), new MapPoint(10, 10), + new MapPoint(0, 10), new MapPoint(0, 0)) + ]), attributes: new AttributesDictionary(new Dictionary { ["OBJECTID"] = 1 })) + ]; +} diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor.css b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor.css new file mode 100644 index 0000000..a773ddf --- /dev/null +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor.css @@ -0,0 +1,36 @@ +.editor-toolbar { + justify-content: flex-start; + gap: 1rem; + padding: 0.75rem 1rem; +} + +.editor-toolbar .btn { + min-width: 18rem; + transition: background-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.editor-toolbar .btn.btn-accent { + box-shadow: 0 0 0 0.2rem var(--geoblazor-accent-hover); + animation: editor-pulse 1.4s ease-in-out infinite; +} + +@keyframes editor-pulse { + 0%, 100% { + box-shadow: 0 0 0 0.15rem var(--geoblazor-accent-hover); + } + 50% { + box-shadow: 0 0 0 0.35rem var(--geoblazor-accent-hover); + } +} + +.status-pill { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.9rem; + border-radius: var(--box-radius); + background-color: var(--background-grey-2); + color: var(--text); + border: 1px solid var(--background-grey-3); + font-size: 1rem; + line-height: 1.2; +} diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.cs b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.cs index d3de2f7..568f995 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.cs +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.cs @@ -25,6 +25,7 @@ public class ProNavMenu : NavMenu new("popup-edit", "PRO: Popup Edit Data", "oi-pencil", null, true, Categories.Widgets), new("update-feature-attributes", "PRO: Update Attributes", "oi-brush", null, true, Categories.Interaction), new("apply-edits", "PRO: Apply Edits", "oi-check", null, true, Categories.Interaction), + new("sketch-as-editor", "PRO: Sketch as Editor", null, "sketchEditor.svg", true, Categories.Interaction), new("spatial-relationships", "PRO Relationships", "oi-link-intact", null, true, Categories.Queries), new("demographic-data", "PRO: Demographics", "oi-people", null, true, Categories.Location), new("length-and-area", "PRO: Length & Area", "oi-graph", null, true, Categories.Location), diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/_Imports.razor b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/_Imports.razor index 0299f36..7b6a4fb 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/_Imports.razor +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/_Imports.razor @@ -35,6 +35,7 @@ @using dymaptic.GeoBlazor.Pro.Enums @using dymaptic.GeoBlazor.Pro.Events @using dymaptic.GeoBlazor.Pro.Model +@using dymaptic.GeoBlazor.Pro.Options @using dymaptic.GeoBlazor.Pro.Results @using dymaptic.GeoBlazor.Pro.Sample @using dymaptic.GeoBlazor.Pro.Sample.Shared From 30fe1e47095ea399d4e04610ad521672c3f7053d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 18:00:55 +0000 Subject: [PATCH 2/3] Add _sketchLayer null guard in OnMapClick early-return Agent-Logs-Url: https://github.com/dymaptic/GeoBlazor-Samples/sessions/8c2e5f83-5053-4908-bab5-cae978cfdaaf Co-authored-by: TimPurdum <17970404+TimPurdum@users.noreply.github.com> --- .../Pages/SketchAsEditor.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor index 94ffa91..4e67858 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor @@ -94,7 +94,7 @@ private async Task OnMapClick(ClickEvent evt) { - if (!_moveArmed || _mapView is null || _featureLayer is null || _sketchWidget is null) + if (!_moveArmed || _mapView is null || _featureLayer is null || _sketchWidget is null || _sketchLayer is null) { return; } @@ -116,7 +116,7 @@ } // Stage the picked graphic into the Sketch's own GraphicsLayer so the widget can operate on it. - await _sketchLayer!.Add(graphic); + await _sketchLayer.Add(graphic); await _sketchWidget.Update([graphic], _updateOptions); _editing = true; _statusMessage = null; From 5cd3f629863a7c57b894307ed6eb8997cf3fe74d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 18:02:25 +0000 Subject: [PATCH 3/3] Fix Description wording: in-memory source, note hosted FeatureService works too Agent-Logs-Url: https://github.com/dymaptic/GeoBlazor-Samples/sessions/4039a7e9-6520-4247-a188-2823964dc4da Co-authored-by: TimPurdum <17970404+TimPurdum@users.noreply.github.com> --- .../Pages/SketchAsEditor.razor | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor index 4e67858..8db7c5b 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor @@ -80,9 +80,11 @@ "OnMapClick handler that hit-tests the target FeatureLayer, stages the picked Graphic into a companion " + "GraphicsLayer, and hands it to SketchWidget.Update with a SketchToolUpdateOptions configured for " + "SketchTool.Move. A Save button calls SketchWidget.Complete() to finish the active update; the resulting " + - "SketchUpdateEvent is forwarded to FeatureLayer.ApplyEdits to persist the new geometry on the feature " + - "service, then the staging graphic is removed and the feature layer is refreshed so it redraws at the " + - "updated location. A Cancel button (and the Esc key) route through OnKeyDown to SketchWidget.Cancel(), " + + "SketchUpdateEvent is forwarded to FeatureLayer.ApplyEdits to persist the new geometry in the client-side " + + "in-memory source layer, then the staging graphic is removed and the feature layer is refreshed so it redraws " + + "at the updated location. The same ApplyEdits call works equally well against a hosted FeatureService — simply " + + "replace the in-memory Source with a Url pointing to your service and the rest of the pattern is unchanged. " + + "A Cancel button (and the Esc key) route through OnKeyDown to SketchWidget.Cancel(), " + "which fires a Complete event with Aborted=true so ApplyEdits is skipped. The pattern is useful any time " + "you need SketchViewModel's interactive move semantics inside a custom workflow with your own editor UI.";