diff --git a/assets/src/components/Digitizing.js b/assets/src/components/Digitizing.js index f3f86eb773..ddf01aaa0e 100644 --- a/assets/src/components/Digitizing.js +++ b/assets/src/components/Digitizing.js @@ -64,6 +64,18 @@ export default class Digitizing extends HTMLElement { this._availableTools = DigitizingAvailableTools.slice(1); } + /** + * Show an editing message popup for the selected tool + * @param {string} messageKey - The lizDict key for the message + */ + _showEditingMessage(messageKey) { + const msg = lizDict[messageKey]; + if (!msg) return; + // Remove any previous editing message + $('#lizmap-editing-message').remove(); + lizMap.addMessage(msg, 'info', true, 10000).attr('id', 'lizmap-editing-message'); + } + connectedCallback() { // Update available tools from attribute @@ -244,34 +256,45 @@ export default class Digitizing extends HTMLElement { `; - const mainTemplate = (toolSelected) => html` + const mainTemplate = (toolSelected) => { + // Evaluate on every render so it reflects current edition state + const isEditionPoint = this.context === 'edition' && mainLizmap.edition?.layerGeometry === 'point'; + + // For point layers in edition, no toolbar needed — drawing starts automatically + if (isEditionPoint) { + this.style.display = 'none'; + return html``; + } + this.style.display = ''; + + return html`
- ${toolButtonTemplate(this._availableTools, toolSelected)} - mainLizmap.digitizing._userChangedColor(event.target.value)} data-bs-toggle="tooltip" data-bs-title="${lizDict['digitizing.toolbar.color']}" - > - - - - ` : ''} + ${this.context !== 'edition' ? html` - ${this.measureAvailable ? measureButtonTemplate( + ` : ''} + ${this.measureAvailable && !isEditionPoint ? measureButtonTemplate( mainLizmap.digitizing.hasMeasureVisible, ) : ''} ${this.saveAvailable ? saveButtonTemplate( @@ -413,65 +451,29 @@ export default class Digitizing extends HTMLElement {
${lizDict['digitizing.toolbar.save.state']}
-
-
- - ${lizDict['digitizing.constraint.title']} - - ${lizDict['digitizing.constraint.details']} -
-
- mainLizmap.digitizing.distanceConstraint = event.target.value - } - > - m -
-
- mainLizmap.digitizing.angleConstraint = event.target.value - } - > - ° -
-
${this.textToolsAvailable ? textToolsTemplate( mainLizmap.digitizing.editedFeatures.length != 0 ) : ''}
`; + }; - render( - mainTemplate( - this.toolSelected, - ), - this, - ); - - const tooltipTriggerList = this.querySelectorAll('[data-bs-toggle="tooltip"]'); - [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl, { - trigger: 'hover' - })); + this._renderTemplate = () => { + render(mainTemplate(this.toolSelected), this); + this._initTooltips(); + this._initDropdowns(); + }; mainEventDispatcher.addListener( () => { + // Sync component tool state with module when context matches + if (mainLizmap.digitizing.context === this.context) { + const moduleTool = mainLizmap.digitizing.toolSelected; + if (this._availableTools.includes(moduleTool)) { + this._toolSelected = moduleTool; + } + } if (!this.disabled) { - render( - mainTemplate( - this.toolSelected, - ), - this, - ); + this._renderTemplate(); } }, [ @@ -495,11 +497,39 @@ export default class Digitizing extends HTMLElement { 'digitizing.visibility', ] ); + + this._renderTemplate(); } disconnectedCallback() { } + + _initTooltips() { + // Dispose existing tooltips to avoid duplicates + this.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { + // Skip elements whose title resolves to null (e.g. missing lizDict key) + // to prevent Bootstrap from throwing a type-check error. + const title = el.getAttribute('data-bs-title') || el.getAttribute('title'); + if (!title) return; + const existing = bootstrap.Tooltip.getInstance(el); + if (existing) existing.dispose(); + new bootstrap.Tooltip(el, { trigger: 'hover' }); + }); + } + + _initDropdowns() { + // Use strategy:'fixed' so Popper positions the dropdown relative to the + // viewport, allowing it to escape overflow:auto containers (#mini-dock). + this.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(el => { + if (!bootstrap.Dropdown.getInstance(el)) { + new bootstrap.Dropdown(el, { + popperConfig: { strategy: 'fixed' } + }); + } + }); + } + /** * Digitizing context * The element attribute: context diff --git a/assets/src/components/Snapping.js b/assets/src/components/Snapping.js index 48c69a63c0..e4d72dc1d0 100644 --- a/assets/src/components/Snapping.js +++ b/assets/src/components/Snapping.js @@ -26,23 +26,12 @@ export default class Snapping extends HTMLElement {

${lizDict['snapping.title']}

-
- - -
+
${mainLizmap.snapping.active ? html`
@@ -91,8 +80,7 @@ export default class Snapping extends HTMLElement { }, [ 'snapping.config', - 'snapping.active', - 'snapping.refreshable' + 'snapping.active' ] ); } diff --git a/assets/src/components/edition/ReverseGeom.js b/assets/src/components/edition/ReverseGeom.js index 69213a436f..07fa90c932 100644 --- a/assets/src/components/edition/ReverseGeom.js +++ b/assets/src/components/edition/ReverseGeom.js @@ -6,7 +6,7 @@ * @license MPL-2.0 */ -import { mainLizmap } from '../../modules/Globals.js'; +import { mainLizmap, mainEventDispatcher } from '../../modules/Globals.js'; /** * Web component used to reverse vertices order for a modified feature @@ -25,25 +25,32 @@ export default class reverseGeom extends HTMLElement { } _reverse(){ - if (!mainLizmap.edition.modifyFeatureControl - || !mainLizmap.edition.modifyFeatureControl.active - || mainLizmap.edition.modifyFeatureControl.vertices.length == 0){ - return; - } - - const lonLat = []; + // OL10 path: operate on the feature currently in the digitizing draw layer. + // (The legacy OL2 modifyFeatureControl is no longer activated.) + const features = mainLizmap.digitizing?.featureDrawn; + if (!features || features.length === 0) return; - for (const vertice of mainLizmap.edition.modifyFeatureControl.vertices) { - lonLat.push([vertice.geometry.x, vertice.geometry.y]); - } - - lonLat.reverse(); + const feature = features[0]; + const geom = feature.getGeometry(); + const type = geom.getType(); - for (let index = 0; index < lonLat.length; index++) { - mainLizmap.edition.modifyFeatureControl.vertices[index].move(new OpenLayers.LonLat(lonLat[index][0], lonLat[index][1])); + if (type === 'LineString' || type === 'MultiPoint') { + geom.setCoordinates(geom.getCoordinates().slice().reverse()); + } else if (type === 'Polygon') { + geom.setCoordinates(geom.getCoordinates().map(ring => ring.slice().reverse())); + } else if (type === 'MultiLineString') { + geom.setCoordinates(geom.getCoordinates().map(line => line.slice().reverse())); + } else if (type === 'MultiPolygon') { + geom.setCoordinates( + geom.getCoordinates().map(poly => poly.map(ring => ring.slice().reverse())) + ); + } else { + // Point geometries have nothing to reverse. + return; } - mainLizmap.edition.modifyFeatureControl.layer.events.triggerEvent("featuremodified", { feature: mainLizmap.edition.modifyFeatureControl.feature }); + // Sync the hidden geom form field via the digitizing change event. + mainEventDispatcher.dispatch('digitizing.geometryChanged'); // Tell user reverse is done lizMap.addMessage(lizDict['edition.revertgeom.success'], 'success', true).attr('id', 'lizmap-edition-message'); diff --git a/assets/src/legacy/edition.js b/assets/src/legacy/edition.js index 7db5e84df9..dd9ed711aa 100644 --- a/assets/src/legacy/edition.js +++ b/assets/src/legacy/edition.js @@ -281,133 +281,14 @@ var lizEdition = function() { if ( $EditionMessage.length != 0 ) { $EditionMessage.remove(); } + // Also drop the OL10-edition help toast (e.g. "Draw on the map") so it + // does not coexist with this status/error message in #message — tests + // assert #message > div is a single element with a specific class. + $('#lizmap-editing-message').remove(); lizMap.addMessage(aMessage, aType, aClose).attr('id','lizmap-edition-message'); editionMessageTimeoutId = window.setTimeout(cleanEditionMessage, 5000); } - /** - * - * @param evt - */ - function afterReshapeSpliting(evt) { - var splitFeatures = evt.features; - var geometryType = editionLayer.geometryType; - var removableFeat = null; - if ( geometryType == 'line' ) { - if ( splitFeatures[0].geometry.getLength() < splitFeatures[1].geometry.getLength() ) - removableFeat = splitFeatures[0]; - else - removableFeat = splitFeatures[1]; - } - else if ( geometryType == 'polygon' ) { - if ( splitFeatures[0].geometry.getArea() < splitFeatures[1].geometry.getArea() ) - removableFeat = splitFeatures[0]; - else - removableFeat = splitFeatures[1]; - } - if (removableFeat) { - editionLayer.removeEditedFeature(removableFeat); - } - // Update form geometry field from added geometry - var feat = editionLayer.getFeature(); - if (feat) { - updateGeometryColumnFromFeature(feat); - } - $('#edition-geomtool-nodetool').click(); - return false; - } - - - /** - * - * @param evt - */ - function beforeFeatureSpliting(evt) { - - var form = $('#edition-form-container form'); - if (checkFormBeforeSubmit(form) !== 'ok') { - // content of the form is not good, we couldn't create a new feature - addEditionMessage(lizDict['edition.splitfeat.form.error'],'error',true); - return false; - } - if (!form.attr('data-new-feature-action')) { - addEditionMessage(lizDict['edition.splitfeat.tech.error'],'error',true); - return false; - } - return true; - } - - - /** - * - * @param evt - */ - function afterFeatureSpliting(evt) { - - // determine the two new geometry - var splitFeatures = evt.features; - var geometryType = editionLayer.geometryType; - var newFeature = null; - if ( geometryType == 'line' ) { - if ( splitFeatures[0].geometry.getLength() < splitFeatures[1].geometry.getLength() ) - newFeature = splitFeatures[0]; - else - newFeature = splitFeatures[1]; - } - else if ( geometryType == 'polygon' ) { - if ( splitFeatures[0].geometry.getArea() < splitFeatures[1].geometry.getArea() ) - newFeature = splitFeatures[0]; - else - newFeature = splitFeatures[1]; - } - - // store one of the new geometry (the most little one), as a new feature - if (newFeature) { - var form = $('#edition-form-container form'); - // Get edition datasource geometry column name - var gColumn = form.find('input[name="liz_geometryColumn"]').val(); - var geom = ''; - // create a new form that will be used to store the new feature - var data = new FormData(form.get(0)); - if ('set' in data) { - data.set('liz_featureId', ''); - data.set('__JFORMS_TOKEN__', ''); - if (gColumn) { - geom = calculateGeometryColumnFromFeature(newFeature); - data.set(gColumn, geom); - } - } - else { - // IE/Edge<12 workaround - no support of FormData.set() - var featureIdField = form.find('input[name="liz_featureId"]'); - var geomField = form.find('input[name="'+gColumn+'"]'); - var tokenField = form.find('input[name="__JFORMS_TOKEN__"]'); - var oldFeatureId = featureIdField.val(); - var oldGeom = geomField.val(); - var oldToken = tokenField.val(); - featureIdField.val(''); - tokenField.val(''); - if (gColumn) { - geom = calculateGeometryColumnFromFeature(newFeature); - geomField.val(geom); - } - data = new FormData(form.get(0)); - featureIdField.val(oldFeatureId); - geomField.val(oldGeom); - tokenField.val(oldToken) - } - // move new feature into the temporary layer - editionLayer.moveEditedFeatureToSplitLayer(newFeature, data); - } - - // Update geometry column with the other geometry - var feat = editionLayer.getFeature(); - if (feat) { - updateGeometryColumnFromFeature( feat ); - } - $('#edition-geomtool-nodetool').click(); - return false; - } /** @@ -487,10 +368,14 @@ var lizEdition = function() { var mapSrid = mapProjCode.replace('EPSG:',''); $('#edition-point-coord-crs-map').html(lizDict['edition.point.coord.crs.map']+' - EPSG:'+mapSrid).val(mapSrid).show(); - if ( editionLayer.geometryType == 'point' ) - $('#edition-point-coord-add').hide(); - else - $('#edition-point-coord-add').show(); + if ( editionLayer.geometryType == 'point' ) { + // Hide Add/Finalize buttons and their parent form-group to avoid whitespace + $('#edition-point-coord-add').closest('.form-group').hide(); + $('#edition-segment-length').parents('.form-group').addClass('hidden'); + $('#edition-segment-angle').parents('.form-group').addClass('hidden'); + } else { + $('#edition-point-coord-add').closest('.form-group').show(); + } $('#handle-point-coord').show(); @@ -510,25 +395,20 @@ var lizEdition = function() { var x = parseFloat($('#edition-point-coord-x').val()); var y = parseFloat($('#edition-point-coord-y').val()); if ( !isNaN(x) && !isNaN(y) ) { - var vertex = new OpenLayers.Geometry.Point(x,y); - // Get SRID and transform geometry + // Get SRID and transform to map projection var srid = $('#edition-point-coord-crs').val(); - vertex.transform( 'EPSG:'+srid, editionLayer['ol'].projection ); - var geometryType = editionLayer.geometryType; - if ( !editCtrls[geometryType].handler.point ) { - var px = editCtrls[geometryType].handler.layer.getViewPortPxFromLonLat({lon:vertex.x,lat:vertex.y}); - editCtrls[geometryType].handler.createFeature(px); - editCtrls[geometryType].handler.point.geometry.x = vertex.x; - editCtrls[geometryType].handler.point.geometry.y = vertex.y; - editCtrls[geometryType].handler.point.geometry.clearBounds(); - } else { - editCtrls[geometryType].handler.point.geometry.x = vertex.x; - editCtrls[geometryType].handler.point.geometry.y = vertex.y; - editCtrls[geometryType].handler.point.geometry.clearBounds(); + var mapProjection = lizMap.mainLizmap.map.getView().getProjection().getCode(); + var coord = lizMap.ol.proj.transform([x, y], 'EPSG:' + srid, mapProjection); - displaySegmentsLengthAndAngle(editCtrls[geometryType].handler.layer.features[0].geometry); + var geometryType = editionLayer.geometryType; + if (geometryType === 'point') { + // For point: create/move point feature at coord + var feature = new lizMap.ol.Feature(new lizMap.ol.geom.Point(coord)); + lizMap.mainLizmap.digitizing._drawSource.clear(); + lizMap.mainLizmap.digitizing._drawSource.addFeature(feature); + lizMap.mainEventDispatcher.dispatch('digitizing.geometryChanged'); } - editCtrls[geometryType].handler.drawFeature(); + // For line/polygon, the coordinate is used with the Add button (handled separately) } } @@ -654,8 +534,7 @@ var lizEdition = function() { */ function finishEdition() { - // Put old OL2 map at bottom - lizMap.mainLizmap.newOlMap = true; + // OL6 digitizing handles the drawing now // Lift the constraint on edition lizMap.editionPending = false; @@ -811,14 +690,7 @@ var lizEdition = function() { deactivate: deactivateDrawFeature } }), - modify: new OpenLayers.Control.ModifyFeature(editLayer), - reshape: new OpenLayers.Control.Split({layer:editLayer,eventListeners: {aftersplit:afterReshapeSpliting}}), - featsplit: new OpenLayers.Control.Split({ - layer:editLayer, - eventListeners: { - beforesplit:beforeFeatureSpliting, - aftersplit:afterFeatureSpliting - }}) + modify: new OpenLayers.Control.ModifyFeature(editLayer) }; for ( var ctrl in editCtrls ) { if ( ctrl != 'panel' ) @@ -836,86 +708,28 @@ var lizEdition = function() { // edit layer events + // OL2 edit layer events are no longer primary + // OL6 Digitizing module handles drawing, modification, and geometry updates + // These legacy events are kept as no-ops for backward compatibility editLayer.events.on({ - featureadded: function() { - // Deactivate draw control - if( !editCtrls ) - return false; - var geometryType = editionLayer.geometryType; - var drawWasActivated = editCtrls[geometryType].active; - if (drawWasActivated) - editCtrls[geometryType].deactivate(); - - // Get feature - var feat = editLayer.features[0]; - - // Update form geometry field from added geometry - updateGeometryColumnFromFeature( feat ); - - // Activate modify control - if (drawWasActivated || editionLayer['config'].capabilities.modifyGeometry == "True"){ - // activate edition - editCtrls.panel.activate(); - // then modify - editCtrls.modify.activate(); - $('#edition-geomtool-nodetool').click(); - editCtrls.modify.selectFeature( feat ); - if (geometryType === 'line'){ - $('#edition-geomtool-container button i').addClass('line'); - } - if (geometryType !== 'point'){ - $('#edition-geomtool-container').show(); - } - } - - // Display form tab and hide tool to handle coords for point geometry - // TODO : allow use of coords tool when editing point - if (geometryType === 'point') { - $('.edition-tabs a[href="#tabform"]').tab('show'); - $('#handle-point-coord').hide(); - } - - // Inform user he can now modify - addEditionMessage(lizDict['edition.select.modify.activate'],'info',true); - }, - - featuremodified: function(evt) { - if ( evt.feature.geometry == null ) - return; - // Update form geometry field from added geometry - updateGeometryColumnFromFeature( evt.feature ); - + // Handled by OL6 Digitizing module via digitizing.geometryChanged event }, - - featureselected: function(evt) { - if ( evt.feature.geometry == null ) - return; - - }, - - featureunselected: function(evt) { - if ( evt.feature.geometry == null ) - return; - - updateGeometryColumnFromFeature(evt.feature); + featuremodified: function() { + // Handled by OL6 Digitizing module via digitizing.geometryChanged event }, + featureselected: function() {}, + featureunselected: function() {}, + sketchmodified: function() {}, + vertexmodified: function() {} + }); - sketchmodified: function(evt) { - // Force drawing point on geolocation position - if ($('#edition-point-coord-geolocation').is(':checked')){ - var [lon, lat] = lizMap.mainLizmap.geolocation.getPositionInCRS(editionLayer['ol'].projection); - evt.vertex.x = lon; - evt.vertex.y = lat; - }else{ - var vertex = evt.vertex.clone(); - displayCoordinates(vertex); + // Handle split complete from OL6 Edition module + lizMap.events.on({ + lizmapeditionsplitcomplete: function(evt) { + if (evt.newFeatureFormData) { + editionLayer.currentFeature.newfeatures.push([null, evt.newFeatureFormData]); } - - displaySegmentsLengthAndAngle(evt.feature.geometry); - }, - - vertexmodified: function(evt) { } }); @@ -952,9 +766,16 @@ var lizEdition = function() { return false; }); $('#edition-point-coord-crs').change(function(){ - if (editCtrls[editionLayer.geometryType].handler.point !== null) { - var vertex = editCtrls[editionLayer.geometryType].handler.point.geometry.clone(); - displayCoordinates(vertex); + // Update displayed coordinates based on current digitizing feature + var features = lizMap.mainLizmap.digitizing.featureDrawn; + if (features && features.length > 0) { + var geom = features[0].getGeometry(); + if (geom.getType() === 'Point') { + displayCoordinates(geom.getCoordinates()); + } else { + var lastCoord = geom.getLastCoordinate(); + if (lastCoord) displayCoordinates(lastCoord); + } } }); $('#edition-point-coord-x').keyup(keyUpPointCoord); @@ -965,14 +786,10 @@ var lizEdition = function() { $('#edition-point-coord-y').attr('disabled','disabled'); if (lizMap.mainLizmap.geolocation.isTracking){ - var geometryType = editionLayer.geometryType; - var [lon, lat] = lizMap.mainLizmap.geolocation.getPositionInCRS(editionLayer['ol'].projection); - if (lon && lat){ - var px = editCtrls[geometryType].handler.layer.getViewPortPxFromLonLat({ lon: lon, lat: lat }); - editCtrls[geometryType].handler.modifyFeature(px); - - // Set X and Y input with geolocation position value as it is more precise than position given by edit controls - displayCoordinates(new OpenLayers.Geometry.Point(lon, lat)); + var mapProjection = lizMap.mainLizmap.map.getView().getProjection().getCode(); + var position = lizMap.mainLizmap.geolocation.getPositionInCRS(mapProjection); + if (position && position[0] && position[1]){ + displayCoordinates([position[0], position[1]]); } } } else { @@ -982,114 +799,60 @@ var lizEdition = function() { lizMap.mainLizmap.geolocation.isLinkedToEdition = $(this).is(':checked'); }); $('#edition-point-coord-add').click(function(){ - var geometryType = editionLayer.geometryType; - if (geometryType != 'point' && editCtrls[geometryType].handler.point) { - var node = editCtrls[geometryType].handler.point.geometry; - editCtrls[geometryType].handler.insertXY(node.x, node.y); + var x = parseFloat($('#edition-point-coord-x').val()); + var y = parseFloat($('#edition-point-coord-y').val()); + if (isNaN(x) || isNaN(y)) return; + + var srid = $('#edition-point-coord-crs').val(); + var mapProjection = lizMap.mainLizmap.map.getView().getProjection().getCode(); + var coord = lizMap.ol.proj.transform([x, y], 'EPSG:' + srid, mapProjection); + + // Append coordinate to line/polygon via draw interaction + var drawInteraction = lizMap.mainLizmap.digitizing._drawInteraction; + if (drawInteraction && typeof drawInteraction.appendCoordinates === 'function') { + drawInteraction.appendCoordinates([coord]); } }); $('#edition-point-coord-submit').click(function(){ var geometryType = editionLayer.geometryType; - // Assert we have a geometry - if (editCtrls[geometryType].handler.getGeometry()){ - if (geometryType === 'point') { - // Take average point if mode is enabled - if (lizMap.mainLizmap.geolocationSurvey.averageRecordMode && lizMap.mainLizmap.geolocationSurvey.positionAverageInMapCRS !== undefined){ - editCtrls[geometryType].handler.point.geometry.x = lizMap.mainLizmap.geolocationSurvey.positionAverageInMapCRS[0]; - editCtrls[geometryType].handler.point.geometry.y = lizMap.mainLizmap.geolocationSurvey.positionAverageInMapCRS[1]; - editCtrls[geometryType].handler.drawFeature(); - } - editCtrls[geometryType].handler.finalize(); - } else { - editCtrls[geometryType].handler.finishGeometry(); + if (geometryType === 'point') { + // Take average point if mode is enabled + if (lizMap.mainLizmap.geolocationSurvey.averageRecordMode && lizMap.mainLizmap.geolocationSurvey.positionAverageInMapCRS !== undefined){ + var avgCoord = lizMap.mainLizmap.geolocationSurvey.positionAverageInMapCRS; + var feature = new lizMap.ol.Feature(new lizMap.ol.geom.Point([avgCoord[0], avgCoord[1]])); + lizMap.mainLizmap.digitizing._drawSource.clear(); + lizMap.mainLizmap.digitizing._drawSource.addFeature(feature); + } + // For point, the feature is already placed; dispatch geometry change + lizMap.mainEventDispatcher.dispatch('digitizing.geometryChanged'); + // Switch to edit mode + lizMap.mainLizmap.digitizing.isEdited = true; + } else { + // For line/polygon, finish the draw interaction + var drawInteraction = lizMap.mainLizmap.digitizing._drawInteraction; + if (drawInteraction) { + drawInteraction.finishDrawing(); } } }); - $('#edition-geomtool-restart-drawing').click(function(){ - if ( !confirm( lizDict['edition.confirm.restart-drawing'] ) ) { - return false; - } - - var ctrl = editCtrls[editionLayer.geometryType]; - // Check drawing is active - if (ctrl.active) - return false; - - // Disable every modifying geometry controls - editCtrls.featsplit.deactivate(); - editCtrls.reshape.deactivate(); - editCtrls.modify.mode = OpenLayers.Control.ModifyFeature.RESHAPE; - editCtrls.modify.createVertices = true; - editCtrls.modify.deactivate(); - editCtrls.panel.deactivate(); - // Clear edition layers - editionLayer.clearLayers(); - // activate drawing control - ctrl.activate(); - }); - - $('#edition-geomtool-nodetool').click(function(){ - editCtrls.reshape.deactivate(); - editCtrls.featsplit.deactivate(); - editCtrls.modify.mode = OpenLayers.Control.ModifyFeature.RESHAPE; - editCtrls.modify.createVertices = true; - editCtrls.modify.activate(); - var feat = editionLayer.getFeature(); - if (feat.geometry) { - // we unselect then select, to trigger corresponding events - if ( editCtrls.modify.feature ) - editCtrls.modify.unselectFeature( feat ); - editCtrls.modify.selectFeature( feat ); - } +$('#edition-geomtool-nodetool').click(function(){ + // Node tool = edit mode in OL6 Digitizing + lizMap.mainLizmap.digitizing.isEdited = true; }); $('#edition-geomtool-drag').click(function(){ - editCtrls.reshape.deactivate(); - editCtrls.featsplit.deactivate(); - editCtrls.modify.mode = OpenLayers.Control.ModifyFeature.DRAG; - editCtrls.modify.createVertices = false; - editCtrls.modify.activate(); - var feat = editionLayer.getFeature(); - if (feat) { - // we unselect then select, to trigger corresponding events - if ( editCtrls.modify.feature ) - editCtrls.modify.unselectFeature( feat ); - editCtrls.modify.selectFeature( feat ); - } + // Drag = translate mode — edit mode handles translate via the Translate interaction + lizMap.mainLizmap.digitizing.isEdited = true; }); $('#edition-geomtool-rotate').click(function(){ - editCtrls.reshape.deactivate(); - editCtrls.featsplit.deactivate(); - editCtrls.modify.mode = OpenLayers.Control.ModifyFeature.ROTATE; - editCtrls.modify.createVertices = false; - editCtrls.modify.activate(); - var feat = editionLayer.getFeature(); - if (feat) { - // we unselect then select, to trigger corresponding events - if ( editCtrls.modify.feature ) - editCtrls.modify.unselectFeature( feat ); - editCtrls.modify.selectFeature( feat ); - } - }); - $('#edition-geomtool-reshape').click(function(){ - var feat = editionLayer.getFeature(); - if (feat && editCtrls.modify.feature) { - editCtrls.modify.unselectFeature(feat); - } - editCtrls.modify.deactivate(); - editCtrls.featsplit.deactivate(); - editCtrls.reshape.activate(); + // Rotate mode in OL6 Digitizing + lizMap.mainLizmap.digitizing.isRotate = true; }); $('#edition-geomtool-split').click(function(){ - var feat = editionLayer.getFeature(); - if (feat && editCtrls.modify.feature) { - editCtrls.modify.unselectFeature(feat); - } - editCtrls.modify.deactivate(); - editCtrls.reshape.deactivate(); - editCtrls.featsplit.activate(); + // Feature split uses the split tool in OL6 Digitizing + lizMap.mainLizmap.digitizing.isSplitting = true; }); $('#edition-geomtool-container button, lizmap-reverse-geom').tooltip( { @@ -1116,19 +879,18 @@ var lizEdition = function() { 'geolocation.isTracking' ); - // Make modifyFeature follow geolocation when active + // Geolocation position updates are now handled by OL6 Edition.js + // Just update coordinate display when geolocation is linked lizMap.mainEventDispatcher.addListener( () => { if (editionLayer && ('config' in editionLayer) ) { $('#edition-point-coord-geolocation').removeAttr('disabled'); - var geometryType = editionLayer.geometryType; - if ($('#edition-point-coord-geolocation').is(':checked') && editCtrls[geometryType].active ) { - // Move point - var [lon, lat] = lizMap.mainLizmap.geolocation.getPositionInCRS(editionLayer['ol'].projection); - var px = editCtrls[geometryType].handler.layer.getViewPortPxFromLonLat({ lon: lon, lat: lat}); - editCtrls[geometryType].handler.modifyFeature(px); - - displayCoordinates(new OpenLayers.Geometry.Point(lon, lat)); + if ($('#edition-point-coord-geolocation').is(':checked')) { + var mapProjection = lizMap.mainLizmap.map.getView().getProjection().getCode(); + var position = lizMap.mainLizmap.geolocation.getPositionInCRS(mapProjection); + if (position && position[0] && position[1]) { + displayCoordinates([position[0], position[1]]); + } } } }, @@ -1147,19 +909,18 @@ var lizEdition = function() { * * @param vertex */ - function displayCoordinates(vertex){ - // Get SRID and transform geometry + function displayCoordinates(coordInMapProj){ + // Get SRID and transform coordinate var srid = $('#edition-point-coord-crs').val(); - var displayProj = new OpenLayers.Projection('EPSG:' + srid); - vertex.transform(editionLayer['ol'].projection, displayProj); + var mapProjection = lizMap.mainLizmap.map.getView().getProjection().getCode(); + var coord = lizMap.ol.proj.transform(coordInMapProj, mapProjection, 'EPSG:' + srid); - if (displayProj.getUnits() === 'degrees') { - $('#edition-point-coord-x').val(vertex.x.toFixed(6)); - $('#edition-point-coord-y').val(vertex.y.toFixed(6)); - } else { - $('#edition-point-coord-x').val(vertex.x.toFixed(3)); - $('#edition-point-coord-y').val(vertex.y.toFixed(3)); - } + // Check if target is degrees (EPSG:4326 or similar) + var isDegrees = (srid === '4326'); + var decimals = isDegrees ? 6 : 3; + + $('#edition-point-coord-x').val(coord[0].toFixed(decimals)); + $('#edition-point-coord-y').val(coord[1].toFixed(decimals)); } /** @@ -1486,8 +1247,7 @@ var lizEdition = function() { // See "Check li (tabs) visibility" in displayEditionForm method displayEditionForm( data ); - // Put old OL2 map on top and synchronize position with new OL map - lizMap.mainLizmap.newOlMap = false; + // OL6 digitizing handles the drawing now if( aCallback ) aCallback( editionLayer['id'], featureId ); @@ -1720,31 +1480,13 @@ var lizEdition = function() { && geometryType in editCtrls ){ $('#edition-geomtool-container button i').removeClass('line'); $('#edition-geomtool-container').hide(); - editCtrls.modify.mode = OpenLayers.Control.ModifyFeature.RESHAPE; - editCtrls.modify.createVertices = true; - editCtrls.modify.deactivate(); - editionLayer.clearLayers(); - var ctrl = editCtrls[geometryType]; - if ( !ctrl.active ) { - ctrl.activate(); - - if(lizMap.checkMobile()){ - addEditionMessage(lizDict['edition.draw.activate.mobile'], 'info', true); - }else{ - addEditionMessage(lizDict['edition.draw.activate'],'info',true); - } - } - // Need to get geometry from form and add feature to the openlayer layer - if( feat ){ - editionLayer['ol'].addFeatures([feat]); - editCtrls.modify.activate(); - $('#edition-geomtool-nodetool').click(); - editCtrls.modify.selectFeature( feat ); - if (geometryType == 'line') - $('#edition-geomtool-container button i').addClass('line'); - if (geometryType != 'point') - $('#edition-geomtool-container').show(); - } + + // Fire event to let OL6 Edition.js handle the drawing + // (and show the appropriate edition.draw.activate message) + activateDrawFeature(); + + // Legacy geomtool buttons replaced by OL6 digitizing toolbar + $('#edition-geomtool-container').hide(); } else { $('.edition-tabs button[data-bs-target="#tabdigitization"]').hide(); } @@ -1754,22 +1496,15 @@ var lizEdition = function() { // Activate modification control if ( editionLayer['config'].capabilities.modifyGeometry == "True" && geometryType in editCtrls ){ - // Need to get geometry from form and add feature to the openlayer layer - feat = updateFeatureFromGeometryColumn(); - if( feat ){ - editCtrls.modify.activate(); - $('#edition-geomtool-nodetool').click(); - editCtrls.modify.selectFeature( feat ); - if (geometryType == 'line') - $('#edition-geomtool-container button i').addClass('line'); - if (geometryType != 'point') - $('#edition-geomtool-container').show(); - } + // Fire event to let OL6 Edition.js handle loading and editing + // (and show the appropriate edition.select.modify.activate message) + activateDrawFeature(); + + // Legacy geomtool buttons replaced by OL6 digitizing toolbar + $('#edition-geomtool-container').hide(); }else{ $('.edition-tabs button[data-bs-target="#tabdigitization"]').hide(); } - - addEditionMessage(lizDict['edition.select.modify.activate'],'info',true); } } @@ -2083,6 +1818,10 @@ var lizEdition = function() { }); sendFormPromise.then(() => { displayEditionForm( formResult ); + }).catch(e => { + console.error('Error saving split feature:', e); + $('#edition-waiter').hide(); + addEditionMessage(lizDict['edition.message.error.send.feature'], 'error', true); }); return false; @@ -2103,9 +1842,20 @@ var lizEdition = function() { request.open("POST", url); request.onload = function() { if (request.status == 200) { + try { + var json = JSON.parse(request.responseText); + if (json && json.success === false) { + var msg = json.message || 'unknown server error'; + console.warn('saveNewFeature returned success:false —', msg); + reject(new Error('saveNewFeature: ' + msg)); + return; + } + } catch (e) { + // Response is not JSON; treat as success (legacy behaviour) + } resolve(request.responseText); } else { - reject(new Error(`Nouveau message d'erreur`, { cause: request })); + reject(new Error(`saveNewFeature failed with status ${request.status}: ${request.responseText}`, { cause: request })); } }; request.addEventListener("error", reject); diff --git a/assets/src/legacy/map.js b/assets/src/legacy/map.js index ac9331fa9a..4f33496534 100644 --- a/assets/src/legacy/map.js +++ b/assets/src/legacy/map.js @@ -1635,6 +1635,21 @@ window.lizMap = function() { "layerSelectionChanged": function( evt ) { refreshGetFeatureInfo(evt); }, + "lizmapeditionfeaturecreated": function() { + // OL10 singleclick fires ~250ms after the draw click. If the + // edition form save completes inside that window the click + // guard against duplicated GFI requests is already lifted, so + // the next legitimate identify click is silently deduplicated. + // Clearing lastLonLatInfo here forces the next click to issue + // a fresh GetFeatureInfo request. + lastLonLatInfo = null; + }, + "lizmapeditionfeaturemodified": function() { + lastLonLatInfo = null; + }, + "lizmapeditionformclosed": function() { + lastLonLatInfo = null; + }, "lizmapeditionfeaturedeleted": function( evt ) { if ( $('div.lizmapPopupContent input.lizmap-popup-layer-feature-id').length > 1 ) { refreshGetFeatureInfo(evt); diff --git a/assets/src/modules/Digitizing.js b/assets/src/modules/Digitizing.js index 08d2dbafbe..b9ef9c2d68 100644 --- a/assets/src/modules/Digitizing.js +++ b/assets/src/modules/Digitizing.js @@ -4,7 +4,7 @@ * @copyright 2023 3Liz * @license MPL-2.0 */ -import { mainEventDispatcher } from '../modules/Globals.js'; +import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js'; import { deepFreeze } from './config/Tools.js'; import { createEnum } from './utils/Enums.js'; import { Utils } from './Utils.js'; @@ -15,10 +15,11 @@ import GPX from 'ol/format/GPX.js'; import KML from 'ol/format/KML.js'; import WKT from 'ol/format/WKT.js'; -import { Draw, Modify, Select, Translate } from 'ol/interaction.js'; +import { Draw, Modify, Select, Translate, DoubleClickZoom } from 'ol/interaction.js'; +import { click } from 'ol/events/condition.js'; import { createBox } from 'ol/interaction/Draw.js'; -import { Circle, Fill, Stroke, RegularShape, Style, Text } from 'ol/style.js'; +import { Circle, Fill, Stroke, Style, Text } from 'ol/style.js'; import { Vector as VectorSource } from 'ol/source.js'; import { Vector as VectorLayer } from 'ol/layer.js'; @@ -36,14 +37,12 @@ import { GeometryCollection } from 'ol/geom.js'; -import { circular, fromCircle } from 'ol/geom/Polygon.js'; +import { fromCircle } from 'ol/geom/Polygon.js'; import { getArea, getLength } from 'ol/sphere.js'; import Overlay from 'ol/Overlay.js'; import { unByKey } from 'ol/Observable.js'; -import { transform } from 'ol/proj.js'; - import shp from 'shpjs'; import * as flatgeobuf from 'flatgeobuf'; import { register } from "ol/proj/proj4.js"; @@ -132,6 +131,7 @@ export class Digitizing { this._isErasing = false; this._drawInteraction; + this._dblClickListener = null; this._segmentMeasureTooltipElement; this._totalMeasureTooltipElement; @@ -146,7 +146,10 @@ export class Digitizing { this._strokeWidth = 2; this._selectInteraction = new Select({ + condition: click, + hitTolerance: 5, wrapX: false, + layers: (layer) => layer === this._drawLayer, style: (feature) => { let color = feature.get('color') || this._drawColor; @@ -222,33 +225,55 @@ export class Digitizing { if (event.selected.length) { this.drawColor = event.selected[0].get('color'); } else { + // In edition context, prevent deselection — always keep the feature selected + if (this._context === 'edition' && this.featureDrawn && this.featureDrawn.length === 1) { + this._selectInteraction.getFeatures().push(this.featureDrawn[0]); + return; + } // When a feature is deselected, set the color from the first selected feature if any const selectedFeatures = this._selectInteraction.getFeatures().getArray(); if (selectedFeatures.length) { this.drawColor = selectedFeatures[0].get('color'); } } + mainEventDispatcher.dispatch('digitizing.editionBegins'); }); this._modifyInteraction = new Modify({ features: this._selectInteraction.getFeatures(), }); + this._modifyInteraction.on('modifyend', () => { + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + }); + this._translateInteraction = new Translate({ features: this._selectInteraction.getFeatures(), hitTolerance: 20 }); + this._translateInteraction.on('translateend', () => { + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + }); + this._transformRotateInteraction = new Transform({ rotate: true, scale: false, }); + this._transformRotateInteraction.on('rotateend', () => { + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + }); + this._transformScaleInteraction = new Transform({ rotate: false, scale: true, }); + this._transformScaleInteraction.on('scaleend', () => { + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + }); + this._drawStyleFunction = (feature) => { let color = feature.get('color') || this._drawColor; @@ -353,37 +378,6 @@ export class Digitizing { this._map.addToolLayer(this._drawLayer); - // Constraint layer - this._constraintLayer = new VectorLayer({ - source: new VectorSource({ wrapX: false }), - style: new Style({ - image: new RegularShape({ - fill: new Fill({ - color: 'black', - }), - stroke: new Stroke({ - color: 'black', - }), - points: 4, - radius: 10, - radius2: 0, - angle: 0, - }), - stroke: new Stroke({ - color: 'black', - lineDash: [10] - }), - }) - }); - this._constraintLayer.setProperties({ - name: 'LizmapDigitizingConstraintLayer' - }); - this._map.addToolLayer(this._constraintLayer); - - // Constraints values - this._distanceConstraint = 0; - this._angleConstraint = 0; - // Load and display saved feature if any this.loadFeatureDrawnToMap(); @@ -401,6 +395,7 @@ export class Digitizing { minidockclosed: (e) => { if (e.id == 'draw') { this.toolSelected = this._tools[0]; // DigitizingTools.Deactivate + this.toggleVisibility(false); } } }); @@ -595,10 +590,118 @@ export class Digitizing { * @param {string} tool - The tool to select * @fires digitizingToolSelected */ + /** + * Disable DoubleClickZoom interaction on the map + * @private + */ + _disableDoubleClickZoom() { + this._map.getInteractions().forEach(interaction => { + if (interaction instanceof DoubleClickZoom) { + interaction.setActive(false); + } + }); + } + + /** + * Enable DoubleClickZoom interaction on the map + * @private + */ + _enableDoubleClickZoom() { + // Delay re-enabling past OL's 250ms dblclick detection window + // to prevent the finishing double-click from also triggering a zoom + setTimeout(() => { + this._map.getInteractions().forEach(interaction => { + if (interaction instanceof DoubleClickZoom) { + interaction.setActive(true); + } + }); + }, 300); + } + + /** + * Restore edit mode if in edition context with a drawn feature. + * Called when other tools (rotate, scale, split) deactivate. + * @private + */ + _restoreEditionEditMode() { + if (this._context === 'edition' && this.featureDrawn) { + this.isEdited = true; + } + } + + /** + * Deactivate all tools by directly manipulating internal state. + * Avoids setter re-entrancy issues. Call before activating a new tool. + * @private + */ + _deactivateAllTools() { + // Deactivate edit mode + if (this._isEdited) { + this._isEdited = false; + this._selectInteraction.getFeatures().clear(); + this._map.removeInteraction(this._selectInteraction); + this._map.removeInteraction(this._modifyInteraction); + this.saveFeatureDrawn(); + mainEventDispatcher.dispatch('digitizing.editionEnds'); + } + + // Deactivate rotate + if (this._isRotate) { + this._isRotate = false; + this._transformRotateInteraction.getFeatures().clear(); + this._map.removeInteraction(this._transformRotateInteraction); + mainEventDispatcher.dispatch({ type: 'digitizing.rotate', isRotate: false }); + } + + // Deactivate scale + if (this._isScaling) { + this._isScaling = false; + this._transformScaleInteraction.getFeatures().clear(); + this._map.removeInteraction(this._transformScaleInteraction); + mainEventDispatcher.dispatch({ type: 'digitizing.scaling', isScaling: false }); + } + + // Deactivate split + if (this._isSplitting) { + this._isSplitting = false; + this._map.removeInteraction(this._splitInteraction); + if (this._splitSource) { + this._splitSource.clear(); + this._splitSource = null; + } + mainEventDispatcher.dispatch({ type: 'digitizing.split', isSplitting: false }); + } + + // Deactivate erasing + if (this._isErasing) { + this._map.un('singleclick', this._erasingCallBack); + this._isErasing = false; + mainEventDispatcher.dispatch('digitizing.erasingEnds'); + } + + // Deactivate draw tool + this._map.removeInteraction(this._drawInteraction); + if (this._dblClickListener) { + this._map.getViewport().removeEventListener('dblclick', this._dblClickListener); + this._dblClickListener = null; + } + this._drawSource.un('addfeature', this._addFeatureColorListener); + this._drawSource.un('addfeature', this._addFeatureTextListener); + this._drawSource.un('addfeature', this._addFeatureSinglePartGeometryListener); + this._drawSource.un('addfeature', this._addFeatureSaveDispatchListener); + this._toolSelected = this._tools[0]; // deactivate + + this._enableDoubleClickZoom(); + } + set toolSelected(tool) { if (this._tools.includes(tool)) { // Disable all tools this._map.removeInteraction(this._drawInteraction); + if (this._dblClickListener) { + this._map.getViewport().removeEventListener('dblclick', this._dblClickListener); + this._dblClickListener = null; + } this._drawSource.un('addfeature', this._addFeatureColorListener); this._drawSource.un('addfeature', this._addFeatureTextListener); this._drawSource.un('addfeature', this._addFeatureSinglePartGeometryListener); @@ -607,6 +710,7 @@ export class Digitizing { // If tool === 'deactivate' or current selected tool is selected again => deactivate if (tool === this._toolSelected || tool === this._tools[0]) { this._toolSelected = this._tools[0]; + this._enableDoubleClickZoom(); } else { const drawOptions = { source: this._drawLayer.getSource(), @@ -619,15 +723,9 @@ export class Digitizing { break; case this._tools[2]: drawOptions.type = 'LineString'; - drawOptions.geometryFunction = (coords, geom) => { - return this._contraintsHandler(coords, geom, drawOptions.type); - } break; case this._tools[3]: drawOptions.type = 'Polygon'; - drawOptions.geometryFunction = (coords, geom) => { - return this._contraintsHandler(coords, geom, drawOptions.type); - } break; case this._tools[4]: drawOptions.type = 'Circle'; @@ -676,14 +774,13 @@ export class Digitizing { this._drawInteraction.on('drawend', event => { const geom = event.feature.getGeometry(); - // Close linear ring if needed - if (geom instanceof Polygon) { - const coordsLinearRing = geom.getCoordinates()[0]; - if (coordsLinearRing[0] !== coordsLinearRing[coordsLinearRing.length - 1]) { - coordsLinearRing.push(coordsLinearRing[0]); - geom.setCoordinates([coordsLinearRing]); - } - } + // OL10's Draw already returns properly closed polygon rings — + // do NOT manually push another closing coordinate. (Earlier + // code compared the first/last coordinate arrays with !==, + // which is always true for distinct array instances regardless + // of value, so it kept double-closing the ring. The duplicate + // closing point shows up in WKT output and breaks tests that + // expect a single closure.) // Attach total overlay to its geom to update // content when the geom is modified @@ -693,8 +790,6 @@ export class Digitizing { this._setTooltipContentByGeom(geom); }); - this._constraintLayer.setVisible(false); - // Remove segment measure and change total measure tooltip style this._segmentMeasureTooltipElement.remove(); this._totalMeasureTooltipElement.className = 'ol-tooltip ol-tooltip-static'; @@ -713,9 +808,28 @@ export class Digitizing { Array.from(this._measureTooltips).pop()[1], ); } + + // Dispatch after microtask so the feature is in the source + Promise.resolve().then(() => { + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + }); }); this._map.addInteraction(this._drawInteraction); + this._disableDoubleClickZoom(); + + // With constraints active, OL's atFinish_() compares the actual click pixel + // against the constrained coordinate (which can be far from the click). This + // causes double-click to add a vertex instead of finishing. We intercept the + // DOM dblclick to remove the extra vertex OL added on the second pointer-up + // and call finishDrawing() ourselves. Safe when constraints are off too: + // finishDrawing() was already called by OL and is a no-op by then. + this._dblClickListener = () => { + this._drawInteraction.removeLastPoint(); + this._drawInteraction.finishDrawing(); + }; + this._map.getViewport().addEventListener('dblclick', this._dblClickListener); + this._drawSource.on('addfeature', this._addFeatureColorListener); this._drawSource.on('addfeature', this._addFeatureTextListener); this._drawSource.on('addfeature', this._addFeatureSinglePartGeometryListener); @@ -744,6 +858,11 @@ export class Digitizing { type: 'digitizing.toolSelected', tool: this._toolSelected, }); + + // Ensure snap interaction is ordered after the new draw interaction + if (mainLizmap.snapping) { + mainLizmap.snapping.reorderSnapInteraction(); + } } } @@ -836,27 +955,19 @@ export class Digitizing { */ set isEdited(edited) { if (this._isEdited !== edited) { - this._isEdited = edited; + if (edited) { + this._deactivateAllTools(); + this._isEdited = true; - if (this._isEdited) { // Automatically edit the feature if unique - if (this.featureDrawn.length === 1) { + if (this.featureDrawn && this.featureDrawn.length === 1) { this._selectInteraction.getFeatures().push(this.featureDrawn[0]); this.drawColor = this.featureDrawn[0].get('color'); } - this._map.removeInteraction(this._drawInteraction); - - this._map.addInteraction(this._translateInteraction); this._map.addInteraction(this._selectInteraction); this._map.addInteraction(this._modifyInteraction); - this.toolSelected = 'deactivate'; - this.isErasing = false; - this.isRotate = false; - this.isScaling = false; - this.isSplitting = false; - /** * @event digitizingEditionBegins * @type {object} @@ -868,9 +979,9 @@ export class Digitizing { */ mainEventDispatcher.dispatch('digitizing.editionBegins'); } else { + this._isEdited = false; // Clear selection this._selectInteraction.getFeatures().clear(); - this._map.removeInteraction(this._translateInteraction); this._map.removeInteraction(this._selectInteraction); this._map.removeInteraction(this._modifyInteraction); @@ -905,22 +1016,20 @@ export class Digitizing { */ set isRotate(isRotate) { if (this._isRotate !== isRotate) { - this._isRotate = isRotate; + if (isRotate) { + this._deactivateAllTools(); + this._isRotate = true; - if (this._isRotate) { - this.toolSelected = 'deactivate'; - this.isErasing = false; - this.isEdited = false; - this.isScaling = false; - this.isSplitting = false; - - // Automatically scaling the feature if unique - if (this.featureDrawn.length === 1) { + // Automatically select the feature if unique + if (this.featureDrawn && this.featureDrawn.length === 1) { this._transformRotateInteraction.getFeatures().push(this.featureDrawn[0]); } this._map.addInteraction(this._transformRotateInteraction); } else { + this._isRotate = false; + this._transformRotateInteraction.getFeatures().clear(); this._map.removeInteraction(this._transformRotateInteraction); + this._restoreEditionEditMode(); } /** @@ -954,22 +1063,20 @@ export class Digitizing { */ set isScaling(isScaling) { if (this._isScaling !== isScaling) { - this._isScaling = isScaling; + if (isScaling) { + this._deactivateAllTools(); + this._isScaling = true; - if (this._isScaling) { - this.toolSelected = 'deactivate'; - this.isErasing = false; - this.isEdited = false; - this.isRotate = false; - this.isSplitting = false; - - // Automatically scaling the feature if unique - if (this.featureDrawn.length === 1) { + // Automatically select the feature if unique + if (this.featureDrawn && this.featureDrawn.length === 1) { this._transformScaleInteraction.getFeatures().push(this.featureDrawn[0]); } this._map.addInteraction(this._transformScaleInteraction); } else { + this._isScaling = false; + this._transformScaleInteraction.getFeatures().clear(); this._map.removeInteraction(this._transformScaleInteraction); + this._restoreEditionEditMode(); } /** @@ -1003,35 +1110,44 @@ export class Digitizing { */ set isSplitting(isSplitting) { if (this._isSplitting !== isSplitting) { - this._isSplitting = isSplitting; - - if (this._isSplitting) { - // Disable other tools - this.toolSelected = 'deactivate'; - this.isEdited = false; - this.isRotate = false; - this.isScaling = false; - this.isErasing = false; + if (isSplitting) { + this._deactivateAllTools(); + this._isSplitting = true; + // Use a separate source for the split line so it doesn't trigger + // addfeature listeners (singlePartGeometry would remove existing features) + this._splitSource = new VectorSource(); this._splitInteraction = new Draw({ - source: this._drawSource, - type: 'LineString' + source: this._splitSource, + type: 'LineString', + style: this._drawStyleFunction }); this._splitInteraction.on('drawend', event => { + if (this._isOperationInProgress) return; + this._isOperationInProgress = true; + + // Get the split line geometry and clear the temporary source + const splitLineGeom = event.feature.getGeometry(); + this._splitSource.clear(); + + // Take a snapshot of existing features (avoid modifying during iteration) + const existingFeatures = [...this._drawSource.getFeatures()]; + + // Find features that intersect the split line + const featuresToSplit = existingFeatures.filter( + f => splitLineGeom.intersectsExtent(f.getGeometry().getExtent()) + ); + if (featuresToSplit.length === 0) { + this._isOperationInProgress = false; + return; + } + + // Lazy-load geometry libraries Promise.all([ - import( - /* webpackChunkName: 'OLparser' */ 'jsts/org/locationtech/jts/io/OL3Parser.js' - ), - import( - /* webpackChunkName: 'UnionOp' */ 'jsts/org/locationtech/jts/operation/union/UnionOp.js' - ), - import( - /* webpackChunkName: 'Polygonizer' */ - 'jsts/org/locationtech/jts/operation/polygonize/Polygonizer.js' - ), - import( - /* webpackChunkName: 'lineSplit' */ '@turf/line-split' - ), + import(/* webpackChunkName: 'OLparser' */ 'jsts/org/locationtech/jts/io/OL3Parser.js'), + import(/* webpackChunkName: 'UnionOp' */ 'jsts/org/locationtech/jts/operation/union/UnionOp.js'), + import(/* webpackChunkName: 'Polygonizer' */ 'jsts/org/locationtech/jts/operation/polygonize/Polygonizer.js'), + import(/* webpackChunkName: 'lineSplit' */ '@turf/line-split'), ]).then(([ { default: OLparser }, { default: UnionOp }, @@ -1039,83 +1155,90 @@ export class Digitizing { { default: lineSplit } ]) => { const parser = new OLparser(); - parser.inject( - Point, - LineString, - LinearRing, - Polygon, - MultiPoint, - MultiLineString, - MultiPolygon - ); - - const lineGeometry = event.feature.getGeometry(); + parser.inject(Point, LineString, LinearRing, Polygon, MultiPoint, MultiLineString, MultiPolygon); + const format = new GeoJSON(); - // Remove line used for splitting - this._drawSource.removeFeature(event.feature); + const allSplitFeatures = []; - for (const feature of this._drawSource.getFeatures()) { - // Check if split line intersects with drawn feature - if (!lineGeometry.intersectsExtent(feature.getGeometry().getExtent())) { - continue; - } + for (const feature of featuresToSplit) { const geomType = feature.getGeometry().getType(); - if ( geomType === 'Polygon') { - // Convert the OpenLayers geometry to a JSTS geometry - const jstsLine = parser.read(lineGeometry); - const jstsDrawnGeom = parser.read(feature.getGeometry()); - - // Perform union of Polygon and Line and use Polygonizer to split the polygon by line - let union = UnionOp.union(jstsDrawnGeom.getExteriorRing(), jstsLine); - let polygonizer = new Polygonizer(); + const featureColor = feature.get('color') || this._drawColor; + let newFeatures = null; - // Splitting polygon in two parts + if (geomType === 'Polygon') { + const jstsLine = parser.read(splitLineGeom); + const jstsDrawnGeom = parser.read(feature.getGeometry()); + const union = UnionOp.union(jstsDrawnGeom.getExteriorRing(), jstsLine); + const polygonizer = new Polygonizer(); polygonizer.add(union); - let polygons = polygonizer.getPolygons(); - - // This will execute only if polygon is successfully splitted into two parts - if (polygons.array.length == 2) { - // Remove original polygon - this._drawSource.removeFeature(feature); - - // Iterate through splitted polygons - polygons.array.forEach(geom => { - let splitted_polygon = new Feature({ - geometry: new Polygon(parser.write(geom).getCoordinates()) - }); - - // Add splitted polygon to vector layer - this._drawSource.addFeature(splitted_polygon); - this._selectInteraction.getFeatures().push(splitted_polygon); - }); + const polygons = polygonizer.getPolygons(); - this.isEdited = true; + if (polygons.array.length >= 2) { + newFeatures = polygons.array.map(geom => { + const f = new Feature({ geometry: new Polygon(parser.write(geom).getCoordinates()) }); + f.set('color', featureColor); + return f; + }); } } else if (geomType === 'LineString') { - const format = new GeoJSON(); - const turfDrawnFeature = format.writeFeatureObject(feature); - const turfSplitterFeature = format.writeFeatureObject(event.feature); - - const split = lineSplit(turfDrawnFeature, turfSplitterFeature); + const turfDrawn = format.writeFeatureObject(feature); + const turfSplitter = format.writeFeatureObject(event.feature); + const split = lineSplit(turfDrawn, turfSplitter); if (split.features.length > 1) { - // Remove original lineString - this._drawSource.removeFeature(feature); - - split.features.forEach((feature) => { - let splitted_line = format.readFeature(feature); - this._drawSource.addFeature(splitted_line); - this._selectInteraction.getFeatures().push(splitted_line); + newFeatures = split.features.map(sf => { + const f = format.readFeature(sf); + f.set('color', featureColor); + return f; }); } - this.isEdited = true; + } + + if (newFeatures && newFeatures.length > 1) { + // Disable single-part constraint before adding multiple features + // so the addfeature listener doesn't remove the first part + // when the second is added. + this._singlePartGeometry = false; + // Remove original, add all split parts + this._drawSource.removeFeature(feature); + newFeatures.forEach(f => this._drawSource.addFeature(f)); + allSplitFeatures.push(...newFeatures); } } + + if (allSplitFeatures.length > 0) { + // Multiple parts now exist — disable single part constraint + this._singlePartGeometry = false; + + // Switch to edit mode and select all split features + this.isEdited = true; + allSplitFeatures.forEach(f => { + this._selectInteraction.getFeatures().push(f); + }); + + this.isSplitting = false; + } + this._isOperationInProgress = false; + }).catch(error => { + console.error('Split operation failed:', error); + this._isOperationInProgress = false; }); }); this._map.addInteraction(this._splitInteraction); + this._disableDoubleClickZoom(); + // Re-order snap interaction so it processes before the split Draw + if (mainLizmap.snapping) { + mainLizmap.snapping.reorderSnapInteraction(); + } } else { + this._isSplitting = false; this._map.removeInteraction(this._splitInteraction); + if (this._splitSource) { + this._splitSource.clear(); + this._splitSource = null; + } + this._enableDoubleClickZoom(); + this._restoreEditionEditMode(); } /** @@ -1151,15 +1274,9 @@ export class Digitizing { */ set isErasing(isErasing) { if (this._isErasing !== isErasing) { - this._isErasing = isErasing; - - if (this._isErasing) { - // deactivate other tools - this.toolSelected = 'deactivate'; - this.isEdited = false; - this.isRotate = false; - this.isScaling = false; - this.isSplitting = false; + if (isErasing) { + this._deactivateAllTools(); + this._isErasing = true; this._erasingCallBack = event => { const features = this._map.getFeaturesAtPixel(event.pixel, { @@ -1192,6 +1309,7 @@ export class Digitizing { * }, 'digitizing.erase'); */ mainEventDispatcher.dispatch('digitizing.erase'); + mainEventDispatcher.dispatch('digitizing.geometryChanged'); } }; @@ -1227,6 +1345,7 @@ export class Digitizing { * console.log('The digitizing erasing tool ends'); * }, 'digitizing.erasingEnds'); */ + this._isErasing = false; mainEventDispatcher.dispatch('digitizing.erasingEnds'); } } @@ -1266,14 +1385,6 @@ export class Digitizing { }); } - /** - * Is the digitizing constraints panel visible or not - * @type {boolean} - */ - get hasConstraintsPanelVisible() { - return this._hasMeasureVisible && ['line', 'polygon'].includes(this.toolSelected); - } - /** * Is the digitizing save tool active or not? * @type {boolean} @@ -1282,38 +1393,6 @@ export class Digitizing { return this._isSaved; } - /** - * Get the distance constraint - * @type {number} - */ - get distanceConstraint(){ - return this._distanceConstraint; - } - - /** - * Set the distance constraint - * @type {number} - */ - set distanceConstraint(distanceConstraint){ - this._distanceConstraint = parseFloat(distanceConstraint) - } - - /** - * Get the angle constraint - * @type {number} - */ - get angleConstraint(){ - return this._angleConstraint; - } - - /** - * Set the angle constraint - * @type {number} - */ - set angleConstraint(angleConstraint){ - this._angleConstraint = parseFloat(angleConstraint) - } - /** * Erase a feature from the draw source * @private @@ -1358,142 +1437,6 @@ export class Digitizing { }); } - /** - * The constraints handler - * @private - * @param {*} coords - the mouse coordinates - * @param {*} geom - the geometry - * @param {*} geomType - the geometry type - * @returns {void} - */ - _contraintsHandler(coords, geom, geomType) { - // Create geom if undefined - if (!geom) { - if (geomType === 'Polygon') { - geom = new Polygon(coords); - } else { - geom = new LineString(coords); - } - } - - let _coords; - - if (geomType === 'Polygon') { - // Handle first linearRing in polygon - // TODO: Polygons with holes are not handled yet - _coords = coords[0]; - } else { - _coords = coords; - } - - if (this._distanceConstraint || this._angleConstraint) { - // Clear previous visual constraint features - this._constraintLayer.getSource().clear(); - // Display constraint layer - this._constraintLayer.setVisible(true); - - // Last point drawn on click - const lastDrawnPointCoords = _coords[_coords.length - 2]; - // Point under cursor - const cursorPointCoords = _coords[_coords.length - 1]; - - // Contraint where point will be drawn on click - let constrainedPointCoords = cursorPointCoords; - - const mapProjection = this._map.getView().getProjection(); - - if (this._distanceConstraint) { - // Draw circle with distanceConstraint as radius - const circle = circular( - transform(lastDrawnPointCoords, mapProjection, 'EPSG:4326'), - this._distanceConstraint, - 128 - ); - - constrainedPointCoords = transform( - circle.getClosestPoint( - transform(cursorPointCoords, mapProjection, 'EPSG:4326') - ), - 'EPSG:4326', - mapProjection, - ); - - // Draw visual constraint features - this._constraintLayer.getSource().addFeature( - new Feature({ - geometry: circle.transform('EPSG:4326', mapProjection) - }) - ); - - if (!this._angleConstraint) { - this._constraintLayer.getSource().addFeature( - new Feature({ - geometry: new Point(constrainedPointCoords) - }) - ); - } - } - - if (this._angleConstraint && _coords.length > 2) { - const constrainedAngleClockwise = new LineString([_coords[_coords.length - 3], lastDrawnPointCoords]); - const constrainedAngleAntiClockwise = constrainedAngleClockwise.clone(); - // Rotate clockwise - constrainedAngleClockwise.rotate(-1 * this._angleConstraint * (Math.PI / 180.0), lastDrawnPointCoords); - const closestClockwise = constrainedAngleClockwise.getClosestPoint(cursorPointCoords); - // Rotate anticlockwise - constrainedAngleAntiClockwise.rotate(this._angleConstraint * (Math.PI / 180.0), lastDrawnPointCoords); - const closestAntiClockwise = constrainedAngleAntiClockwise.getClosestPoint(cursorPointCoords); - - // Stretch lines - const scaleFactor = 50; - constrainedAngleClockwise.scale(scaleFactor, scaleFactor, lastDrawnPointCoords); - constrainedAngleAntiClockwise.scale(scaleFactor, scaleFactor, lastDrawnPointCoords); - - this._constraintLayer.getSource().addFeatures([ - new Feature({ - geometry: constrainedAngleClockwise - }), - new Feature({ - geometry: constrainedAngleAntiClockwise - }) - ]); - - let constrainedAngleLineString; - - // Display clockwise or anticlockwise angle - // Closest from cursor is displayed - if (this.getProjectedLength( - new LineString([closestClockwise, cursorPointCoords]) - ) < this.getProjectedLength( - new LineString([closestAntiClockwise, cursorPointCoords]) - )) { - constrainedAngleLineString = constrainedAngleClockwise.clone(); - } else { - constrainedAngleLineString = constrainedAngleAntiClockwise.clone(); - } - - if (this._distanceConstraint) { - const ratio = this._distanceConstraint / this.getProjectedLength(constrainedAngleLineString); - constrainedAngleLineString.scale(ratio, ratio, constrainedAngleLineString.getLastCoordinate()); - - constrainedPointCoords = constrainedAngleLineString.getFirstCoordinate(); - } else { - constrainedPointCoords = constrainedAngleLineString.getClosestPoint(cursorPointCoords); - } - - } - _coords[_coords.length - 1] = constrainedPointCoords; - } - - if (geomType === 'Polygon') { - geom.setCoordinates([_coords]); - } else { - geom.setCoordinates(_coords); - } - - return geom; - } - /** * The tooltips handler * @private @@ -1710,6 +1653,7 @@ export class Digitizing { } const color = this.featureDrawn[index].get('color') || this._drawColor; let opacityFactor = this.featureDrawn[index].get('mode') == 'textonly' ? 0 : 1; + let symbolizer; let strokeAndFill = ` ${color} @@ -1722,7 +1666,6 @@ export class Digitizing { `; // We consider LINESTRING and POLYGON together currently - let symbolizer; if (this.featureDrawn[index].getGeometry().getType() === 'Point') { symbolizer = ` @@ -1894,6 +1837,45 @@ export class Digitizing { */ mainEventDispatcher.dispatch('digitizing.erase.all'); mainEventDispatcher.dispatch('digitizing.erase'); + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + } + + /** + * Get a feature as WKT in the given SRID + * @param {string|number} srid - Target SRID (e.g. 4326) + * @param {Feature} [feature] - Optional specific feature. Defaults to first drawn feature. + * @returns {string} WKT string or empty string if no features + */ + getFeatureAsWKT(srid, feature) { + if (!feature) { + const features = this.featureDrawn; + if (!features || features.length === 0) return ''; + feature = features[0]; + } + const wktFormat = new WKT(); + const geom = feature.getGeometry().clone(); + geom.transform(this._map.getView().getProjection(), 'EPSG:' + srid); + return wktFormat.writeGeometry(geom); + } + + /** + * Load a feature from WKT string and add it to the draw source + * @param {string} wktString - WKT geometry string + * @param {string|number} srid - Source SRID of the WKT + * @returns {Feature|null} The loaded feature or null + */ + loadFeatureFromWKT(wktString, srid) { + if (!wktString) return null; + const wktFormat = new WKT(); + const feature = wktFormat.readFeature(wktString, { + dataProjection: 'EPSG:' + srid, + featureProjection: this._map.getView().getProjection() + }); + this.eraseAll(); + feature.set('color', this._drawColor); + this._drawSource.addFeature(feature); + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + return feature; } /** @@ -1922,10 +1904,18 @@ export class Digitizing { }); } } - localStorage.setItem( - this._repoAndProjectString + '_' + this._context + '_drawLayer', - JSON.stringify(savedFeatures), - ); + try { + localStorage.setItem( + this._repoAndProjectString + '_' + this._context + '_drawLayer', + JSON.stringify(savedFeatures), + ); + } catch (e) { + if (e.name === 'QuotaExceededError') { + this._lizmap3.addMessage(lizDict['digitizing.save.quota.error'] || 'Drawing storage quota exceeded', 'warning', true); + } else { + console.error('Failed to save drawing to localStorage:', e); + } + } } else { localStorage.removeItem(this._repoAndProjectString + '_' + this._context + '_drawLayer'); } @@ -1983,12 +1973,12 @@ export class Digitizing { } } catch(json_error) { // the saved data is an invalid JSON - console.log('`'+savedGeomJSON+'` is not a JSON!'); + console.warn('`'+savedGeomJSON+'` is not a JSON!'); // the saved data could be a WKT from previous lizmap version try { const formatWKT = new WKT(); loadedFeatures = formatWKT.readFeatures(savedGeomJSON); - console.log(loadedFeatures.length+' features read from WKT!'); + console.warn(loadedFeatures.length+' features read from WKT!'); // set color for(const loadedFeature of loadedFeatures){ // init measure tooltip @@ -2000,7 +1990,7 @@ export class Digitizing { localStorage.removeItem(this._repoAndProjectString + '_' + this._context + '_drawLayer'); } } catch(wkt_error) { - console.log('`'+savedGeomJSON+'` is not a WKT!'); + console.warn('`'+savedGeomJSON+'` is not a WKT!'); console.error(json_error); console.error(wkt_error); } @@ -2170,7 +2160,7 @@ export class Digitizing { OL6features = features; } } catch (error) { - this._lizmap3.addMessage(error, 'danger', true) + this._lizmap3.addMessage(error.message || String(error), 'danger', true); } if (OL6features) { diff --git a/assets/src/modules/Edition.js b/assets/src/modules/Edition.js index f1fdd2570a..732fd03a44 100644 --- a/assets/src/modules/Edition.js +++ b/assets/src/modules/Edition.js @@ -6,7 +6,12 @@ * @license MPL-2.0 */ -import { mainEventDispatcher } from '../modules/Globals.js'; +import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js'; +import { getArea, getLength } from 'ol/sphere.js'; +import { Feature } from 'ol'; +import { Point, LineString } from 'ol/geom.js'; +import proj4 from 'proj4'; +import { register } from 'ol/proj/proj4.js'; /** * @class @@ -26,12 +31,18 @@ export default class Edition { this.layerGeometry = undefined; this.drawControl = undefined; this._lastSegmentLength = undefined; + this._geometryChangedListener = null; + this._featureDrawnListener = null; + this._geolocationListener = null; + this._splitCompleteListener = null; + this._savedDrawColor = null; lizmap3.events.on({ lizmapeditiondrawfeatureactivated: (properties) => { this.drawFeatureActivated = true; this.layerGeometry = properties.editionConfig.geometryType; this.drawControl = properties.drawControl; + this.activateDigitizing(); mainEventDispatcher.dispatch('edition.drawFeatureActivated'); }, lizmapeditiondrawfeaturedeactivated: () => { @@ -39,18 +50,60 @@ export default class Edition { this.layerGeometry = undefined; this.drawControl = undefined; this.lastSegmentLength = undefined; + this.deactivateDigitizing(); mainEventDispatcher.dispatch('edition.drawFeatureActivated'); }, lizmapeditionformdisplayed: (evt) => { this._layerId = (evt['layerId']); + // When the layer allows geometry editing, lizmapeditiondrawfeatureactivated + // fires shortly after this event and activateDigitizing() will show the + // appropriate toast. When the layer only allows attribute modification, + // that event never fires (the legacy modify branch skips activateDrawFeature) + // so we surface the modify message here as a fallback. + setTimeout(() => { + if (!this.drawFeatureActivated && !document.getElementById('lizmap-editing-message')) { + this._showEditingMessage('edition.select.modify.activate'); + } + }, 50); mainEventDispatcher.dispatch('edition.formDisplayed'); }, lizmapeditionformclosed: () => { + this.deactivateDigitizing(); + // The legacy lizmapeditiondrawfeaturedeactivated event used to fire + // from the OL2 DrawFeature / ModifyFeature controls' "deactivate" + // listeners; those controls no longer activate, so we reset the + // mirroring state here instead. + this.drawFeatureActivated = false; + this.layerGeometry = undefined; + this.drawControl = undefined; + this.lastSegmentLength = undefined; + // Remove any lingering edit-mode toast so the next interaction + // (popup query, base-layer switch, etc.) starts from a clean state. + document.getElementById('lizmap-editing-message')?.remove(); + // Suppress the OL10 map for a tick so the browser's native click + // tracking doesn't fuse the next click with the most recent + // edition click into a dblclick — that would cancel OL10's + // singleclick event and the popup-on-click handler with it. + // Affects fast post-submit identify clicks (~<500ms apart) in + // automated tests; harmless in real interactive usage. + const newOlMap = document.getElementById('newOlMap'); + if (newOlMap) { + newOlMap.style.pointerEvents = 'none'; + setTimeout(() => { newOlMap.style.pointerEvents = ''; }, 350); + } mainEventDispatcher.dispatch('edition.formClosed'); } }); } + /** + * Get the digitizing web component element + * @returns {HTMLElement|null} + */ + get digitizingElement() { + return document.querySelector('lizmap-digitizing[context="edition"]'); + } + get layerId() { return this._layerId; } @@ -86,6 +139,351 @@ export default class Edition { } } + /** + * Show an editing message popup for the current tool + * @param {string} messageKey - The lizDict key for the message + */ + _showEditingMessage(messageKey) { + const msg = lizDict[messageKey]; + if (!msg) return; + $('#lizmap-editing-message').remove(); + lizMap.addMessage(msg, 'info', true, 10000).attr('id', 'lizmap-editing-message'); + } + + /** + * Activate the digitizing module for edition + */ + activateDigitizing() { + if (!this.layerGeometry) return; + + // Map edition geometry types to digitizing tools + const toolMap = { point: 'point', line: 'line', polygon: 'polygon' }; + const tool = toolMap[this.layerGeometry] || 'point'; + + // Set digitizing context to edition and ensure draw layer is visible + mainLizmap.digitizing.context = 'edition'; + mainLizmap.digitizing.toggleVisibility(true); + mainLizmap.digitizing.singlePartGeometry = true; + + // Ensure the measure tool is inactive when starting a new editing session + if (mainLizmap.digitizing.hasMeasureVisible) { + mainLizmap.digitizing.hasMeasureVisible = false; + } + + // Hide digitizing toolbar immediately for point layers + const digEl = this.digitizingElement; + if (digEl) { + digEl.style.display = this.layerGeometry === 'point' ? 'none' : ''; + } + + // Force blue color for edition (like OL2 style) without persisting to localStorage + this._savedDrawColor = mainLizmap.digitizing.drawColor; + mainLizmap.digitizing._drawColor = '#3388ff'; + mainEventDispatcher.dispatch({ type: 'digitizing.drawColor', color: '#3388ff' }); + + // Load existing geometry from form if editing an existing feature + const eform = document.querySelector('#edition-form-container form'); + if (eform) { + const gColumn = eform.querySelector('input[name="liz_geometryColumn"]')?.value; + const srid = eform.querySelector('input[name="liz_srid"]')?.value; + const proj4Def = eform.querySelector('input[name="liz_proj4"]')?.value; + + // Register the layer's SRID in proj4/OL6 if not already known + if (srid && proj4Def) { + const epsgCode = 'EPSG:' + srid; + if (!proj4.defs(epsgCode)) { + proj4.defs(epsgCode, proj4Def); + register(proj4); + } + } + + const wkt = gColumn ? eform.querySelector(`input[name="${gColumn}"]`)?.value : ''; + if (wkt) { + mainLizmap.digitizing.loadFeatureFromWKT(wkt, srid); + // Switch to edit mode since we have an existing feature + mainLizmap.digitizing.isEdited = true; + this._showEditingMessage('edition.select.modify.activate'); + } else { + // Activate the drawing tool for new geometry + mainLizmap.digitizing.toolSelected = tool; + this._showEditingMessage('edition.draw.activate'); + } + } else { + mainLizmap.digitizing.toolSelected = tool; + this._showEditingMessage('edition.draw.activate'); + } + + // Listen to geometry changes and update form + if (!this._geometryChangedListener) { + this._geometryChangedListener = () => { + this.updateFormGeometry(); + }; + mainEventDispatcher.addListener( + this._geometryChangedListener, + 'digitizing.geometryChanged' + ); + } + + // Auto-switch to edit mode after first feature is drawn (like OL2 behavior) + if (!this._featureDrawnListener) { + this._featureDrawnListener = () => { + if (mainLizmap.digitizing.context === 'edition' && mainLizmap.digitizing.featureDrawn) { + mainLizmap.digitizing.isEdited = true; + // Ensure draw layer stays visible after switching to edit mode + mainLizmap.digitizing.toggleVisibility(true); + this._showEditingMessage('edition.select.modify.activate'); + } + }; + mainEventDispatcher.addListener( + this._featureDrawnListener, + 'digitizing.featureDrawn' + ); + } + + // Listen to geolocation position for GPS-linked drawing + if (!this._geolocationListener) { + this._geolocationListener = () => { + this._handleGeolocationPosition(); + }; + mainEventDispatcher.addListener( + this._geolocationListener, + 'geolocation.position' + ); + } + + // Listen to split complete to store new features for saving + if (!this._splitCompleteListener) { + this._splitCompleteListener = () => { + this._handleSplitComplete(); + }; + mainEventDispatcher.addListener( + this._splitCompleteListener, + 'digitizing.splitComplete' + ); + } + } + + /** + * Handle geolocation position updates for GPS-linked edition + * @private + */ + _handleGeolocationPosition() { + if (!mainLizmap.geolocation.isLinkedToEdition) return; + if (!this.layerGeometry) return; + + const mapProjection = mainLizmap.map.getView().getProjection().getCode(); + const position = mainLizmap.geolocation.getPositionInCRS(mapProjection); + if (!position || !position[0] || !position[1]) return; + + const coord = [position[0], position[1]]; + + if (this.layerGeometry === 'point') { + // For point geometry: create/move point at GPS position + const feature = new Feature(new Point(coord)); + mainLizmap.digitizing._drawSource.clear(); + mainLizmap.digitizing._drawSource.addFeature(feature); + mainEventDispatcher.dispatch('digitizing.geometryChanged'); + } else if (this.layerGeometry === 'line' || this.layerGeometry === 'polygon') { + // For line/polygon: append coordinate if draw interaction is active + const drawInteraction = mainLizmap.digitizing._drawInteraction; + if (drawInteraction && typeof drawInteraction.appendCoordinates === 'function') { + drawInteraction.appendCoordinates([coord]); + } + } + + // Update last segment length + const features = mainLizmap.digitizing.featureDrawn; + if (features && features.length > 0) { + const geom = features[0].getGeometry(); + if (geom.getType() === 'LineString') { + const coords = geom.getCoordinates(); + if (coords.length >= 2) { + const lastSegment = new LineString([coords[coords.length - 2], coords[coords.length - 1]]); + this.lastSegmentLength = getLength(lastSegment, { + projection: mainLizmap.map.getView().getProjection() + }).toFixed(3); + } + } + } + } + + /** + * Handle split complete in edition context. + * Determines the smaller/larger split parts and triggers the legacy split workflow. + * @param {object} evt - The split event with features array and geometryType + * @private + */ + _handleSplitComplete() { + if (mainLizmap.digitizing.context !== 'edition') return; + + // Read split results from Digitizing instance (not from event — + // OL Feature objects can't pass through EventDispatcher's JSON.stringify) + const splitFeatures = mainLizmap.digitizing._lastSplitFeatures; + const splitGeometryType = mainLizmap.digitizing._lastSplitGeometryType; + if (!splitFeatures || splitFeatures.length < 2) return; + + const eform = document.querySelector('#edition-form-container form'); + if (!eform) return; + + const gColumn = eform.querySelector('input[name="liz_geometryColumn"]')?.value; + const srid = eform.querySelector('input[name="liz_srid"]')?.value; + if (!gColumn || !srid) return; + + // Determine smaller and larger features by length/area + const f0 = splitFeatures[0]; + const f1 = splitFeatures[1]; + const g0 = f0.getGeometry(); + const g1 = f1.getGeometry(); + let smallerFeature, largerFeature; + + if (splitGeometryType === 'line') { + const len0 = getLength(g0, { projection: mainLizmap.map.getView().getProjection() }); + const len1 = getLength(g1, { projection: mainLizmap.map.getView().getProjection() }); + smallerFeature = len0 < len1 ? f0 : f1; + largerFeature = len0 < len1 ? f1 : f0; + } else { + const area0 = getArea(g0, { projection: mainLizmap.map.getView().getProjection() }); + const area1 = getArea(g1, { projection: mainLizmap.map.getView().getProjection() }); + smallerFeature = area0 < area1 ? f0 : f1; + largerFeature = area0 < area1 ? f1 : f0; + } + + // Serialize smaller feature as a new record. + // Capture the current form data including disabled (read-only) elements so + // that required attributes which are rendered as disabled inputs are still + // sent to saveNewFeature. PK field clearing is handled server-side in the + // PHP saveNewFeature action (which knows the exact PK column names). + const smallerWkt = mainLizmap.digitizing.getFeatureAsWKT(srid, smallerFeature); + if (!smallerWkt) return; + const formData = new FormData(eform); + + // Include values from disabled elements (read-only required fields, PK columns). + for (const el of eform.querySelectorAll('[name]:disabled')) { + if (!formData.has(el.name)) { + formData.set(el.name, el.value ?? ''); + } + } + + formData.set('liz_featureId', ''); + formData.set('__JFORMS_TOKEN__', ''); + if (gColumn) { + formData.set(gColumn, smallerWkt); + } + + // Keep both split features visible in the draw source so the user can + // see the full result of the split operation on the map. + + // Trigger legacy event so edition.js stores the new feature for saving + this._lizmap3.events.triggerEvent('lizmapeditionsplitcomplete', { + newFeatureFormData: formData + }); + + // Set the form geometry explicitly to the larger feature + const largerWkt = mainLizmap.digitizing.getFeatureAsWKT(srid, largerFeature); + const input = eform.querySelector(`input[name="${gColumn}"]`); + if (input) { + input.value = largerWkt; + input.dispatchEvent(new Event('change')); + } + + // Trigger backward compat event + const featureId = eform.querySelector('input[name="liz_featureId"]')?.value; + const layerId = eform.querySelector('input[name="liz_layerId"]')?.value; + lizMap.events.triggerEvent('lizmapeditiongeometryupdated', { + layerId, featureId, geometry: largerWkt, srid + }); + + // Notify the user that editing tools are locked until both features are saved + this._showEditingMessage('edition.split.locked.message'); + } + + /** + * Deactivate the digitizing module for edition + */ + deactivateDigitizing() { + // Remove geometry change listener + if (this._geometryChangedListener) { + mainEventDispatcher.removeListener( + this._geometryChangedListener, + 'digitizing.geometryChanged' + ); + this._geometryChangedListener = null; + } + + // Remove feature drawn listener + if (this._featureDrawnListener) { + mainEventDispatcher.removeListener( + this._featureDrawnListener, + 'digitizing.featureDrawn' + ); + this._featureDrawnListener = null; + } + + // Remove geolocation listener + if (this._geolocationListener) { + mainEventDispatcher.removeListener( + this._geolocationListener, + 'geolocation.position' + ); + this._geolocationListener = null; + } + + // Remove split complete listener + if (this._splitCompleteListener) { + mainEventDispatcher.removeListener( + this._splitCompleteListener, + 'digitizing.splitComplete' + ); + this._splitCompleteListener = null; + } + + // Clear digitizing features for edition context + if (mainLizmap.digitizing && mainLizmap.digitizing.context === 'edition') { + mainLizmap.digitizing.toolSelected = 'deactivate'; + mainLizmap.digitizing.eraseAll(); + mainLizmap.digitizing.context = 'draw'; + mainLizmap.digitizing.toggleVisibility(false); + + // Restore digitizing component visibility + const digEl = this.digitizingElement; + if (digEl) { + digEl.style.display = ''; + } + + // Restore user's draw color + if (this._savedDrawColor) { + mainLizmap.digitizing._drawColor = this._savedDrawColor; + mainEventDispatcher.dispatch({ type: 'digitizing.drawColor', color: this._savedDrawColor }); + this._savedDrawColor = null; + } + } + } + + /** + * Update the edition form geometry field from the digitizing module + */ + updateFormGeometry() { + const eform = document.querySelector('#edition-form-container form'); + if (!eform) return; + const gColumn = eform.querySelector('input[name="liz_geometryColumn"]')?.value; + const srid = eform.querySelector('input[name="liz_srid"]')?.value; + if (!gColumn || !srid) return; + + const wkt = mainLizmap.digitizing.getFeatureAsWKT(srid); + const input = eform.querySelector(`input[name="${gColumn}"]`); + if (input) { + input.value = wkt; + input.dispatchEvent(new Event('change')); + } + + // Backward compat event + const featureId = eform.querySelector('input[name="liz_featureId"]')?.value; + const layerId = eform.querySelector('input[name="liz_layerId"]')?.value; + lizMap.events.triggerEvent('lizmapeditiongeometryupdated', { + layerId, featureId, geometry: wkt, srid + }); + } + /** * Fetch editable features for given array of layer IDs * @param {Array} layerIds - Array of layer IDs @@ -135,6 +533,9 @@ export default class Edition { layerIndex++; } + }).catch(error => { + console.error('fetchEditableFeatures failed:', error); + lizMap.addMessage(error.message || String(error), 'danger', true); }); } } diff --git a/assets/src/modules/FeaturePickerPopup.js b/assets/src/modules/FeaturePickerPopup.js index e694998efd..1f6f851f66 100644 --- a/assets/src/modules/FeaturePickerPopup.js +++ b/assets/src/modules/FeaturePickerPopup.js @@ -5,6 +5,12 @@ * @license MPL-2.0 */ +import { mainLizmap } from './Globals.js'; +import VectorLayer from 'ol/layer/Vector.js'; +import VectorSource from 'ol/source/Vector.js'; +import { Style, Fill, Stroke } from 'ol/style.js'; +import WKT from 'ol/format/WKT.js'; + /** * Popup for selecting features at a clicked position * @class @@ -14,28 +20,29 @@ export default class FeaturePickerPopup { this._map = map; this._popup = null; this._features = []; + this._highlightSource = null; this._highlightLayer = null; + this._wktFormat = new WKT(); this._createHighlightLayer(); } /** - * Create OpenLayers layer for feature highlighting + * Create OL6 vector layer for feature highlighting */ _createHighlightLayer() { - // Create temporary vector layer for highlighting - this._highlightLayer = new OpenLayers.Layer.Vector('FeaturePickerHighlight', { - displayInLayerSwitcher: false, - styleMap: new OpenLayers.StyleMap({ - 'default': new OpenLayers.Style({ - fillColor: '#ffaa00', - fillOpacity: 0.3, - strokeColor: '#ff6600', - strokeWidth: 3, - strokeOpacity: 0.8 - }) - }) + this._highlightSource = new VectorSource(); + this._highlightLayer = new VectorLayer({ + source: this._highlightSource, + style: new Style({ + fill: new Fill({ color: 'rgba(255, 170, 0, 0.3)' }), + stroke: new Stroke({ color: '#ff6600', width: 3, opacity: 0.8 }) + }), + zIndex: 900, + displayInLayerSwitcher: false }); - this._map.addLayer(this._highlightLayer); + if (mainLizmap?.map) { + mainLizmap.map.addLayer(this._highlightLayer); + } } /** @@ -69,7 +76,9 @@ export default class FeaturePickerPopup { minWidth: '250px', maxWidth: '400px', maxHeight: '300px', - overflow: 'auto' + overflow: 'auto', + // #map has pointer-events:none; override so clicks reach popup items + 'pointer-events': 'auto' }); // Add to map container @@ -149,19 +158,26 @@ export default class FeaturePickerPopup { } /** - * Highlight a feature on the map + * Highlight a feature on the OL6 map * @param {number} index - Index of feature to highlight */ _highlightFeature(index) { this._clearHighlight(); const feature = this._features[index]; - if (feature && feature.geometry) { - // Create OpenLayers feature for highlighting - const highlightFeature = new OpenLayers.Feature.Vector( - feature.geometry.clone() - ); - this._highlightLayer.addFeatures([highlightFeature]); + if (!feature) return; + + // Prefer WKT-based OL6 feature (set by GeometryCopyHandler) + if (feature.geometryWKT && this._highlightSource) { + try { + const ol6Feature = this._wktFormat.readFeature(feature.geometryWKT, { + dataProjection: mainLizmap?.map?.getView()?.getProjection() ?? 'EPSG:3857', + featureProjection: mainLizmap?.map?.getView()?.getProjection() ?? 'EPSG:3857' + }); + this._highlightSource.addFeature(ol6Feature); + } catch { + // silently ignore parse errors + } } } @@ -169,7 +185,9 @@ export default class FeaturePickerPopup { * Clear feature highlight */ _clearHighlight() { - this._highlightLayer.removeAllFeatures(); + if (this._highlightSource) { + this._highlightSource.clear(); + } } /** diff --git a/assets/src/modules/Snapping.js b/assets/src/modules/Snapping.js index 4593cb0d68..17e37c6796 100644 --- a/assets/src/modules/Snapping.js +++ b/assets/src/modules/Snapping.js @@ -5,11 +5,16 @@ * @license MPL-2.0 */ -import { mainEventDispatcher } from '../modules/Globals.js'; +import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js'; import Edition from './Edition.js'; import { MapLayerLoadStatus, MapRootState } from './state/MapLayer.js'; import { TreeRootState } from './state/LayerTree.js'; -import WFS from './WFS.js'; + +import { Snap } from 'ol/interaction.js'; +import { Vector as VectorSource } from 'ol/source.js'; +import { Vector as VectorLayer } from 'ol/layer.js'; +import { Circle, Fill, Stroke, Style } from 'ol/style.js'; +import GeoJSON from 'ol/format/GeoJSON.js'; /** * @class @@ -32,7 +37,6 @@ export default class Snapping { this._lizmap3 = lizmap3; this._active = false; - this._snapLayersRefreshable = false; this._maxFeatures = 1000; this._restrictToMapExtent = true; @@ -42,36 +46,38 @@ export default class Snapping { this._snapLayers = []; this._snapOnStart = false; this._pendingMapReadyListener = null; - this._wfsErrorNotified = false; - - // Create layer to store snap features - const snapLayer = new OpenLayers.Layer.Vector('snaplayer', { - visibility: false, - styleMap: new OpenLayers.StyleMap({ - pointRadius: 2, - fill: false, - stroke: false, - strokeWidth: 3, - strokeColor: 'red', - strokeOpacity: 0.8 + + // Create OL6 snap source and layer with a subtle solid style + this._snapSource = new VectorSource(); + this._snapLayer = new VectorLayer({ + source: this._snapSource, + visible: false, + style: new Style({ + stroke: new Stroke({ + color: 'rgba(255, 140, 0, 0.7)', + width: 1.5 + }), + fill: new Fill({ + color: 'rgba(255, 140, 0, 0.05)' + }), + image: new Circle({ + radius: 4, + fill: new Fill({ color: 'rgba(255, 140, 0, 0.4)' }), + stroke: new Stroke({ color: 'rgba(255, 140, 0, 0.8)', width: 1 }) + }) }) }); + this._snapLayer.setProperties({ name: 'snaplayer' }); - this._lizmap3.map.addLayer(snapLayer); - this._snapLayer = snapLayer; + // Will be added to map once mainLizmap.map is ready + this._snapInteraction = null; + this._mapReady = false; - const snapControl = new OpenLayers.Control.Snapping({ - layer: this._edition.editLayer, - targets: [{ - layer: snapLayer - }] - }); - this._lizmap3.map.addControls([snapControl]); - this._lizmap3.controls['snapControl'] = snapControl; - - this._setSnapLayersRefreshable = () => { - if(this._active){ - this.snapLayersRefreshable = true; + // OL6 Snap source feeds off in-memory features; reload them after the + // map view moves so off-extent geometry comes into snap range. + this._refreshSnapDataOnMoveEnd = () => { + if (this._active) { + this.getSnappingData(); } } @@ -87,7 +93,6 @@ export default class Snapping { config.snap_enabled = this._snapEnabled; this.config = config; - this.snapLayersRefreshable = true; // dispatch an event, it might be useful to know when the list of visible layer for snap changed mainEventDispatcher.dispatch('snapping.layer.visibility.changed'); @@ -108,9 +113,19 @@ export default class Snapping { this._snapLayers = visibleLayers.concat(snapLayers); } + // Ensure snap layer is added to map when available + this._ensureMapReady = () => { + if (!this._mapReady && mainLizmap.map) { + mainLizmap.map.addToolLayer(this._snapLayer); + this._mapReady = true; + } + }; + // Activate snap when a layer is edited mainEventDispatcher.addListener( () => { + this._ensureMapReady(); + // Get snapping configuration for edited layer for (const editionLayer in this._lizmap3.config.editionLayers) { if (this._lizmap3.config.editionLayers.hasOwnProperty(editionLayer)) { @@ -150,21 +165,8 @@ export default class Snapping { } if (this._config !== undefined){ - // Configure snapping - const snapControl = this._lizmap3.controls.snapControl; - - // Set edition layer as main layer - snapControl.setLayer(this._edition.editLayer); - - snapControl.targets[0].node = this._config.snap_vertices; - snapControl.targets[0].vertex = this._config.snap_intersections; - snapControl.targets[0].edge = this._config.snap_segments; - snapControl.targets[0].nodeTolerance = this._config.snap_vertices_tolerance; - snapControl.targets[0].vertexTolerance = this._config.snap_intersections_tolerance; - snapControl.targets[0].edgeTolerance = this._config.snap_segments_tolerance; - // Listen to moveend event and to layers visibility changes to able data refreshing - this._lizmap3.map.events.register('moveend', this, this._setSnapLayersRefreshable); + mainLizmap.map.on('moveend', this._refreshSnapDataOnMoveEnd); this._rootMapGroup.addListener( this._setSnapLayersVisibility, ['layer.visibility.changed','group.visibility.changed'] @@ -191,11 +193,13 @@ export default class Snapping { this._pendingMapReadyListener = null; } this.active = false; - this._snapLayer.destroyFeatures(); + this._snapSource.clear(); this.config = undefined; - // Remove listener to moveend event to layers visibility event - this._lizmap3.map.events.unregister('moveend', this, this._setSnapLayersRefreshable); + // Remove listener to moveend event and layers visibility event + if (mainLizmap.map) { + mainLizmap.map.un('moveend', this._refreshSnapDataOnMoveEnd); + } this._rootMapGroup.removeListener( this._setSnapLayersVisibility, ['layer.visibility.changed','group.visibility.changed'] @@ -244,97 +248,43 @@ export default class Snapping { } } - /** - * Log a WFS snap failure and surface a user-visible message, once per refresh batch. - * Called from both the rejection and the "no valid FeatureCollection" path. - * @param {string} layerName - the layer whose WFS request failed - * @param {Error|object|string} detail - the error or payload that triggered the notice - * @private - */ - _notifySnapWfsError(layerName, detail) { - console.warn('Snapping: WFS request failed for layer', layerName, detail); - if (this._wfsErrorNotified) return; - this._wfsErrorNotified = true; - const msg = lizDict['snapping.message.wfs_error'] - || 'Snapping: failed to load data for some layers — snap may be incomplete.'; - this._lizmap3.addMessage(msg, 'error', true, 7000); - } - getSnappingData () { - // Empty snapping layer first - this._snapLayer.destroyFeatures(); - - // Reset the once-per-refresh error notification flag so a new batch of - // requests can re-surface a user-visible message if WFS still fails. - this._wfsErrorNotified = false; + // Empty snapping source first + this._snapSource.clear(); // filter only visible layers and toggled layers on the the snap list const currentSnapLayers = this._snapLayers.filter( (layerId) => this._snapEnabled[layerId] && this._snapToggled[layerId] ); - // Request the map projection so QGIS Server reprojects server-side with full - // PROJ accuracy (including datum grid shifts). This prevents the ~cm coordinate - // drift that occurs when OL2 performs a client-side EPSG:4326 → map-projection - // transform using a simplified Helmert approximation instead of an NTv2 grid. - const mapProjection = this._lizmap3.map.getProjection(); - const mapExtent = this._restrictToMapExtent ? this._lizmap3.map.getExtent() : null; - const wfs = new WFS(); - const gFormat = new OpenLayers.Format.GeoJSON({ ignoreExtraDims: true }); + const mapProjection = mainLizmap.map.getView().getProjection().getCode(); + // TODO : group async calls with Promises for (const snapLayer of currentSnapLayers) { - const layerConfigById = this._lizmap3.getLayerConfigById(snapLayer); - if (!layerConfigById || !layerConfigById[0]) continue; - - const layerName = layerConfigById[0]; - const layerConf = this._lizmap3.config.layers[layerName]; - if (!layerConf) continue; - - // Resolve typename (same logic as getVectorLayerWfsUrl) - let typeName = layerName.split(' ').join('_'); - if (layerConf.hasOwnProperty('shortname') && layerConf['shortname']) typeName = layerConf['shortname']; - else if (layerConf.hasOwnProperty('typename') && layerConf['typename']) typeName = layerConf['typename']; - - const wfsOptions = { - VERSION: '1.1.0', - TYPENAME: typeName, - SRSNAME: mapProjection, - MAXFEATURES: this._maxFeatures, - }; - // Apply existing layer filter if present (e.g. login-based filter) - if (layerConf.hasOwnProperty('request_params') && layerConf['request_params'].hasOwnProperty('filter')) { - const layerFilter = layerConf['request_params']['filter']; - if (layerFilter) { - wfsOptions['EXP_FILTER'] = layerFilter.replace(layerName + ':', ''); - } - } + lizMap.getFeatureData(this._lizmap3.getLayerConfigById(snapLayer)[0], null, null, 'geom', this._restrictToMapExtent, null, this._maxFeatures, + (fName, fFilter, fFeatures) => { - // Append CRS code so the server interprets the extent in the map projection - if (mapExtent) { - wfsOptions['BBOX'] = [mapExtent.left, mapExtent.bottom, mapExtent.right, mapExtent.top].join(',') + ',' + mapProjection; - } + // Transform features + const snapLayerConfig = lizMap.config.layers[fName]; + let snapLayerCrs = snapLayerConfig['featureCrs']; + if (!snapLayerCrs) { + snapLayerCrs = snapLayerConfig['crs']; + } - wfs.getFeature(wfsOptions).then(data => { - if (!data || !Array.isArray(data.features)) { - // The WFS endpoint returned something (no rejection) but not a - // FeatureCollection — most likely an OGC ExceptionReport wrapped as - // JSON. Treat as a failure so the user sees that snap may be incomplete. - this._notifySnapWfsError(layerName, data); - return; - } - // Features are already in map projection — no client-side reprojection needed. - const tfeatures = gFormat.read({ - type: 'FeatureCollection', - features: data.features + const gFormat = new GeoJSON(); + const tfeatures = gFormat.readFeatures( + { type: 'FeatureCollection', features: fFeatures }, + { + dataProjection: snapLayerCrs, + featureProjection: mapProjection + } + ); + + // Add features + this._snapSource.addFeatures(tfeatures); }); - this._snapLayer.addFeatures(tfeatures); - }).catch(err => { - this._notifySnapWfsError(layerName, err); - }); } - - this.snapLayersRefreshable = false; } toggle(){ @@ -382,16 +332,6 @@ export default class Snapping { config.snap_on_layers = this._snapToggled; this.config = config; - this.snapLayersRefreshable = true; - } - - get snapLayersRefreshable(){ - return this._snapLayersRefreshable; - } - - set snapLayersRefreshable(refreshable) { - this._snapLayersRefreshable = refreshable; - mainEventDispatcher.dispatch('snapping.refreshable'); } get active() { @@ -401,22 +341,68 @@ export default class Snapping { set active(active) { this._active = active; - // (de)activate snap control + // (de)activate snap interaction if (this._active) { this.getSnappingData(); - this._lizmap3.controls.snapControl.activate(); + this._createSnapInteraction(); } else { - // Disable refresh button when snapping is inactive - this.snapLayersRefreshable = false; - this._lizmap3.controls.snapControl.deactivate(); + this._removeSnapInteraction(); } - // Set snap layer visibility - this._snapLayer.setVisibility(this._active); + // Show snap layer when active so users can see snappable features + this._snapLayer.setVisible(this._active); mainEventDispatcher.dispatch('snapping.active'); } + /** + * Create and add the OL6 Snap interaction to the map + * @private + */ + _createSnapInteraction() { + this._removeSnapInteraction(); + + if (!this._config || !mainLizmap.map) return; + + this._snapInteraction = new Snap({ + source: this._snapSource, + vertex: this._config.snap_vertices || this._config.snap_intersections, + edge: this._config.snap_segments, + pixelTolerance: Math.max( + parseInt(this._config.snap_vertices_tolerance) || 10, + parseInt(this._config.snap_segments_tolerance) || 10 + ) + }); + + mainLizmap.map.addInteraction(this._snapInteraction); + } + + /** + * Remove the OL6 Snap interaction from the map + * @private + */ + _removeSnapInteraction() { + if (this._snapInteraction && mainLizmap.map) { + mainLizmap.map.removeInteraction(this._snapInteraction); + this._snapInteraction = null; + } + } + + /** + * Re-add the Snap interaction so it sits at the top of the map's + * interactions list, which means OL processes it *before* whatever + * Draw / Modify / Translate was just added by another module. + * + * Callers (e.g. the Digitizing module) invoke this after adding a new + * drawing interaction so snapping continues to work against the latest + * interaction in the stack. No-op when snapping is inactive. + */ + reorderSnapInteraction() { + if (this._active && mainLizmap.map && this._config) { + this._createSnapInteraction(); + } + } + get config() { return this._config; } @@ -424,6 +410,11 @@ export default class Snapping { set config(config) { this._config = config; + // Re-create snap interaction with updated config when active + if (this._active && this._config) { + this._createSnapInteraction(); + } + mainEventDispatcher.dispatch('snapping.config'); } } diff --git a/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties b/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties index 3f7d396878..e3a909481a 100644 --- a/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties +++ b/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties @@ -87,6 +87,7 @@ edition.datepicker.reset=Reset the date edition.confirm.delete=Are you sure you want to delete the selected feature? edition.confirm.cancel=Are you sure you want to cancel the edition? You will lose any modifications made on the currently edited object. edition.confirm.restart-drawing=Are you sure you want to delete the existing geometry and redraw it from scratch? +edition.toolbar.redraw=Redraw the geometry edition.confirm.paste=Are you sure you want to replace the existing geometry with the one in the clipboard? edition.geom.paste=Paste the geometry edition.message.error.fetch.form=Error while fetching the editing form for that layer. Please contact the system administrator. @@ -249,10 +250,14 @@ digitizing.toolbar.newText=New text digitizing.toolbar.textRotation=Rotation digitizing.toolbar.textScale=Size digitizing.toolbar.import=Import in GeoJSON, GPX, KML, zipped Shapefile or FlatGeobuf -digitizing.toolbar.edit=Edit (hold "Shift" to select multiple features, hold "Alt" and click to delete a vertex) -digitizing.toolbar.split=Split the geometry intersecting the drawing line -digitizing.toolbar.rotate=Rotate the selected geometry -digitizing.toolbar.scaling=Scale the selected geometry (hold "Shift" to lock proportions) +digitizing.toolbar.edit=Edit +digitizing.toolbar.edit.help=Edit vertices. Hold "Shift" to select multiple features, hold "Alt" and click to delete a vertex. +digitizing.toolbar.split=Split +digitizing.toolbar.split.help=Draw a line across the geometry to split it into two parts. +digitizing.toolbar.rotate=Rotate +digitizing.toolbar.rotate.help=Drag a corner handle to rotate the geometry around its center. +digitizing.toolbar.scaling=Scale +digitizing.toolbar.scaling.help=Drag a corner handle to scale the geometry. Hold "Shift" to lock proportions. digitizing.toolbar.erase=Delete some features digitizing.toolbar.erase.all=Delete all features digitizing.confirm.erase=Are you sure you want to delete this feature? diff --git a/lizmap/modules/view/templates/map.tpl b/lizmap/modules/view/templates/map.tpl index 76d53085fb..a86c68b23d 100644 --- a/lizmap/modules/view/templates/map.tpl +++ b/lizmap/modules/view/templates/map.tpl @@ -51,7 +51,7 @@
-
+
{zone 'view~map_minidock', array('repository'=>$repository,'project'=>$project,'dockable'=>$minidockable)} diff --git a/lizmap/modules/view/templates/map_edition.tpl b/lizmap/modules/view/templates/map_edition.tpl index ebfb3f9300..932ca92a8d 100644 --- a/lizmap/modules/view/templates/map_edition.tpl +++ b/lizmap/modules/view/templates/map_edition.tpl @@ -35,6 +35,12 @@
+ + {* lizmap-reverse-geom lives outside the legacy + #edition-geomtool-container so it stays visible after the + OL10 migration hides the legacy buttons. *} + +