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..8db7c5b
--- /dev/null
+++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Pages/SketchAsEditor.razor
@@ -0,0 +1,236 @@
+@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)
+ {
+ Save
+ Cancel
+ }
+ else
+ {
+
+ @(_moveArmed ? "Click a feature… (Esc to cancel)" : "Move")
+
+ }
+ @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 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.";
+
+ private void ArmMove()
+ {
+ _moveArmed = true;
+ _statusMessage = null;
+ }
+
+ private async Task OnMapClick(ClickEvent evt)
+ {
+ if (!_moveArmed || _mapView is null || _featureLayer is null || _sketchWidget is null || _sketchLayer 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