@@ -44,6 +44,7 @@ import {
4444 Search ,
4545 Server ,
4646 ServerOff ,
47+ SquareDashed ,
4748 X ,
4849} from "lucide-react" ;
4950import {
@@ -63,6 +64,7 @@ import {
6364 type FileDialogFilter ,
6465} from "../../lib/tauri-io" ;
6566import { fetchableUrl } from "../../lib/url-utils" ;
67+ import { clearPrintExtent , drawPrintExtent } from "../../lib/print-extent" ;
6668import { startGeoLibreSidecar , stopGeoLibreSidecar } from "../../lib/sidecar" ;
6769import { SidecarHelpBanner } from "./SidecarHelpBanner" ;
6870
@@ -475,6 +477,9 @@ export function ProcessingDialog({
475477 left : number ;
476478 top : number ;
477479 } | null > ( null ) ;
480+ // True while a "Draw on map" rubber-band is in progress.
481+ const [ drawing , setDrawing ] = useState ( false ) ;
482+ const drawAbortRef = useRef < AbortController | null > ( null ) ;
478483
479484 const onDragStart = ( event : React . PointerEvent ) => {
480485 // Never begin a drag from an interactive control: the pointer capture would
@@ -556,17 +561,19 @@ export function ProcessingDialog({
556561 } ;
557562
558563 // Escape closes the panel, preserving the affordance the Radix modal provided.
559- // Guarded on `open` so it doesn't intercept Escape for the rest of the app.
564+ // Guarded on `open` so it doesn't intercept Escape for the rest of the app, and
565+ // suppressed while drawing so Escape cancels the in-progress rubber-band (the
566+ // draw helper's own Escape handler) instead of closing the whole panel.
560567 useEffect ( ( ) => {
561568 if ( ! open ) return ;
562569 const onKey = ( event : KeyboardEvent ) => {
563- if ( event . key === "Escape" && ! event . defaultPrevented ) {
570+ if ( event . key === "Escape" && ! event . defaultPrevented && ! drawing ) {
564571 setProcessingOpen ( false ) ;
565572 }
566573 } ;
567574 window . addEventListener ( "keydown" , onKey ) ;
568575 return ( ) => window . removeEventListener ( "keydown" , onKey ) ;
569- } , [ open , setProcessingOpen ] ) ;
576+ } , [ open , drawing , setProcessingOpen ] ) ;
570577
571578 // Re-clamp an explicit size and dragged position when the viewport shrinks, so
572579 // a resized/moved panel can't be left oversized or partly off-screen after a
@@ -1001,21 +1008,21 @@ export function ProcessingDialog({
10011008 // view (GeoLibre#1213). The map reads in EPSG:4326, so the bbox is written as
10021009 // WGS84 `west,south,east,north` and the CRS is set to 4326 in the same gesture
10031010 // to keep the pair consistent (a stale `bbox_crs` would misread the extent).
1004- const handleUseMapExtent = ( ) => {
1005- // Clear any stale banner (e.g. a prior "map not ready") so a later success
1006- // doesn't leave it lingering, mirroring RasterSubsetPanel.handleUseView.
1011+ // Validate a WGS84 box and write it into the `bbox`/`bbox_crs` fields. Shared
1012+ // by "Use map extent" (current view) and "Draw on map" (rubber-band). Rejects
1013+ // an unnormalized box - a view/box wrapping 180° yields west >= east, and at
1014+ // low zoom (multiple world copies) getBounds() corners can fall outside
1015+ // ±180°/±90° while still ordered - which the subset extractors mis-clip or
1016+ // reject, matching RasterSubsetPanel.parseBbox's ordering + range checks.
1017+ const applyBboxExtent = (
1018+ bounds : [ number , number , number , number ] | undefined ,
1019+ ) : void => {
10071020 setError ( null ) ;
1008- const bounds = mapControllerRef . current ?. readView ( ) . bbox ;
10091021 if ( ! bounds ) {
10101022 setError ( t ( "processing.whitebox.mapExtentUnavailable" ) ) ;
10111023 return ;
10121024 }
10131025 const [ west , south , east , north ] = bounds ;
1014- // getBounds() is not normalized: a view wrapping 180° yields west >= east,
1015- // and at low zoom (multiple world copies) the corners can fall outside
1016- // ±180°/±90° while still ordered. Either produces a box the subset
1017- // extractors mis-clip or reject, so block it rather than filling a silently
1018- // wrong bbox, matching RasterSubsetPanel.parseBbox's ordering + range checks.
10191026 if (
10201027 ! ( west < east ) ||
10211028 ! ( south < north ) ||
@@ -1034,6 +1041,49 @@ export function ProcessingDialog({
10341041 updateValue ( "bbox_crs" , String ( 4326 ) ) ;
10351042 } ;
10361043
1044+ const handleUseMapExtent = ( ) => {
1045+ applyBboxExtent ( mapControllerRef . current ?. readView ( ) . bbox ) ;
1046+ } ;
1047+
1048+ // Rubber-band a box on the map to fill the bbox (only workable because the
1049+ // panel is now non-modal). Reuses the print-extent draw helper, which suspends
1050+ // pan/zoom during the drag, previews the box, and handles Escape/blur. Toggling
1051+ // the button (or closing the panel) aborts an in-flight draw.
1052+ const handleDrawBbox = async ( ) => {
1053+ const map = mapControllerRef . current ?. getMap ( ) ;
1054+ if ( ! map ) {
1055+ setError ( t ( "processing.whitebox.mapExtentUnavailable" ) ) ;
1056+ return ;
1057+ }
1058+ if ( drawing ) {
1059+ drawAbortRef . current ?. abort ( ) ;
1060+ return ;
1061+ }
1062+ setError ( null ) ;
1063+ const controller = new AbortController ( ) ;
1064+ drawAbortRef . current = controller ;
1065+ setDrawing ( true ) ;
1066+ try {
1067+ const extent = await drawPrintExtent ( map , { signal : controller . signal } ) ;
1068+ if ( controller . signal . aborted ) return ;
1069+ if ( extent ) applyBboxExtent ( extent ) ;
1070+ } finally {
1071+ clearPrintExtent ( map ) ;
1072+ if ( drawAbortRef . current === controller ) {
1073+ drawAbortRef . current = null ;
1074+ setDrawing ( false ) ;
1075+ }
1076+ }
1077+ } ;
1078+
1079+ // Abort an in-flight draw when the panel closes or the component unmounts, so
1080+ // the map isn't left in draw mode after the panel is gone.
1081+ useEffect ( ( ) => {
1082+ if ( open ) return ;
1083+ drawAbortRef . current ?. abort ( ) ;
1084+ } , [ open ] ) ;
1085+ useEffect ( ( ) => ( ) => drawAbortRef . current ?. abort ( ) , [ ] ) ;
1086+
10371087 const handleRunLocalChange = ( nextRunLocal : boolean ) => {
10381088 setRunLocal ( nextRunLocal ) ;
10391089 // A `vector_out` param holds an output-format string in WASM mode but a
@@ -1605,6 +1655,12 @@ export function ProcessingDialog({
16051655 ? handleUseMapExtent
16061656 : undefined
16071657 }
1658+ onDrawMapExtent = {
1659+ isMapExtentParameter ( selectedTool , param )
1660+ ? handleDrawBbox
1661+ : undefined
1662+ }
1663+ drawingMapExtent = { drawing }
16081664 />
16091665 ) )
16101666 ) }
@@ -1717,6 +1773,11 @@ interface ParameterFieldProps {
17171773 /** When set, renders a "Use map extent" button that fills this bbox field
17181774 * (and its companion CRS) from the current map view. */
17191775 onUseMapExtent ?: ( ) => void ;
1776+ /** When set, renders a "Draw on map" button that fills this bbox field by
1777+ * rubber-banding a box on the map. */
1778+ onDrawMapExtent ?: ( ) => void ;
1779+ /** Whether a draw is currently in progress (toggles the button's label/state). */
1780+ drawingMapExtent ?: boolean ;
17201781 toolId : string ;
17211782 runLocal : boolean ;
17221783 value : unknown ;
@@ -1728,6 +1789,8 @@ function ParameterField({
17281789 onChange,
17291790 onPickFile,
17301791 onUseMapExtent,
1792+ onDrawMapExtent,
1793+ drawingMapExtent,
17311794 toolId,
17321795 runLocal,
17331796 value,
@@ -1788,16 +1851,36 @@ function ParameterField({
17881851 onChange ( event . target . value )
17891852 }
17901853 />
1791- < Button
1792- type = "button"
1793- variant = "outline"
1794- size = "sm"
1795- className = "justify-self-start"
1796- onClick = { onUseMapExtent }
1797- >
1798- < Scan className = "h-3.5 w-3.5" aria-hidden = "true" />
1799- { t ( "processing.whitebox.useMapExtent" ) }
1800- </ Button >
1854+ < div className = "flex flex-wrap gap-2" >
1855+ < Button
1856+ type = "button"
1857+ variant = "outline"
1858+ size = "sm"
1859+ onClick = { onUseMapExtent }
1860+ >
1861+ < Scan className = "h-3.5 w-3.5" aria-hidden = "true" />
1862+ { t ( "processing.whitebox.useMapExtent" ) }
1863+ </ Button >
1864+ { onDrawMapExtent ? (
1865+ < Button
1866+ type = "button"
1867+ variant = { drawingMapExtent ? "secondary" : "outline" }
1868+ size = "sm"
1869+ aria-pressed = { drawingMapExtent }
1870+ onClick = { onDrawMapExtent }
1871+ >
1872+ < SquareDashed className = "h-3.5 w-3.5" aria-hidden = "true" />
1873+ { drawingMapExtent
1874+ ? t ( "processing.whitebox.drawingBbox" )
1875+ : t ( "processing.whitebox.drawBbox" ) }
1876+ </ Button >
1877+ ) : null }
1878+ </ div >
1879+ { drawingMapExtent ? (
1880+ < p className = "text-xs text-muted-foreground" >
1881+ { t ( "processing.whitebox.drawBboxHint" ) }
1882+ </ p >
1883+ ) : null }
18011884 </ div >
18021885 ) : isDataInputParameter ( param ) && availableLayers . length > 0 ? (
18031886 < LayerOrPathInput
0 commit comments