@mongoosejs/studio 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/actions/ChatMessage/executeScript.js +2 -1
- package/backend/integrations/callLLM.js +1 -1
- package/backend/integrations/streamLLM.js +1 -1
- package/docs/user_stories.md +13 -0
- package/frontend/public/app.js +1142 -7
- package/frontend/public/tw.css +72 -0
- package/frontend/src/chat/chat.js +6 -2
- package/frontend/src/detail-default/detail-default.html +15 -2
- package/frontend/src/detail-default/detail-default.js +1066 -2
- package/frontend/src/document-details/document-property/document-property.html +71 -3
- package/frontend/src/document-details/document-property/document-property.js +67 -1
- package/package.json +1 -1
package/frontend/public/app.js
CHANGED
|
@@ -1515,7 +1515,9 @@ module.exports = {
|
|
|
1515
1515
|
this.$toast.success('Chat thread created!');
|
|
1516
1516
|
}
|
|
1517
1517
|
|
|
1518
|
+
const userChatMessageIndex = this.chatMessages.length;
|
|
1518
1519
|
this.chatMessages.push({
|
|
1520
|
+
_id: Math.random().toString(36).substr(2, 9),
|
|
1519
1521
|
content,
|
|
1520
1522
|
role: 'user'
|
|
1521
1523
|
});
|
|
@@ -1533,11 +1535,12 @@ module.exports = {
|
|
|
1533
1535
|
if (event.chatMessage) {
|
|
1534
1536
|
if (!userChatMessage) {
|
|
1535
1537
|
userChatMessage = event.chatMessage;
|
|
1536
|
-
|
|
1538
|
+
this.chatMessages.splice(userChatMessageIndex, 1, userChatMessage);
|
|
1539
|
+
} else {
|
|
1537
1540
|
const assistantChatMessageIndex = this.chatMessages.indexOf(assistantChatMessage);
|
|
1538
1541
|
assistantChatMessage = event.chatMessage;
|
|
1539
1542
|
if (assistantChatMessageIndex !== -1) {
|
|
1540
|
-
this.chatMessages
|
|
1543
|
+
this.chatMessages.splice(assistantChatMessageIndex, 1, assistantChatMessage);
|
|
1541
1544
|
} else {
|
|
1542
1545
|
this.chatMessages.push(assistantChatMessage);
|
|
1543
1546
|
}
|
|
@@ -1551,6 +1554,7 @@ module.exports = {
|
|
|
1551
1554
|
} else if (event.textPart) {
|
|
1552
1555
|
if (!assistantChatMessage) {
|
|
1553
1556
|
assistantChatMessage = {
|
|
1557
|
+
_id: Math.random().toString(36).substr(2, 9),
|
|
1554
1558
|
content: event.textPart,
|
|
1555
1559
|
role: 'assistant'
|
|
1556
1560
|
};
|
|
@@ -2713,7 +2717,7 @@ module.exports = app => app.component('detail-array', {
|
|
|
2713
2717
|
(module) {
|
|
2714
2718
|
|
|
2715
2719
|
"use strict";
|
|
2716
|
-
module.exports = "<div class=\"w-full\">\n <pre class=\"w-full whitespace-pre-wrap break-words font-mono text-sm text-gray-700 m-0\">{{displayValue}}</pre>\n</div
|
|
2720
|
+
module.exports = "<div class=\"w-full\">\n <pre v-if=\"!isGeoJsonGeometry || !mapVisible\" class=\"w-full whitespace-pre-wrap break-words font-mono text-sm text-gray-700 m-0\">{{displayValue}}</pre>\n <div v-show=\"isGeoJsonGeometry && mapVisible\" class=\"mt-2 border border-gray-200 rounded relative\">\n <div ref=\"map\" class=\"h-64 w-full\" style=\"min-height: 256px; height: 256px;\"></div>\n <!-- Undo button below map -->\n <div v-if=\"isEditable && canUndo\" class=\"mt-2 flex justify-end\">\n <button\n @click=\"undoDelete\"\n class=\"text-xs px-3 py-1.5 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors\"\n title=\"Undo all changes\"\n >\n Undo\n </button>\n </div>\n </div>\n</div>\n";
|
|
2717
2721
|
|
|
2718
2722
|
/***/ },
|
|
2719
2723
|
|
|
@@ -2726,10 +2730,63 @@ module.exports = "<div class=\"w-full\">\n <pre class=\"w-full whitespace-pre-w
|
|
|
2726
2730
|
"use strict";
|
|
2727
2731
|
|
|
2728
2732
|
|
|
2733
|
+
/* global L */
|
|
2729
2734
|
const template = __webpack_require__(/*! ./detail-default.html */ "./frontend/src/detail-default/detail-default.html");
|
|
2735
|
+
const appendCSS = __webpack_require__(/*! ../appendCSS */ "./frontend/src/appendCSS.js");
|
|
2736
|
+
|
|
2737
|
+
// Add CSS for polygon vertex markers and context menu
|
|
2738
|
+
appendCSS(`
|
|
2739
|
+
.polygon-vertex-marker {
|
|
2740
|
+
pointer-events: auto !important;
|
|
2741
|
+
}
|
|
2742
|
+
.polygon-vertex-marker > div {
|
|
2743
|
+
pointer-events: auto !important;
|
|
2744
|
+
}
|
|
2745
|
+
.leaflet-context-menu {
|
|
2746
|
+
position: absolute;
|
|
2747
|
+
background: white;
|
|
2748
|
+
border: 1px solid #ccc;
|
|
2749
|
+
border-radius: 4px;
|
|
2750
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
2751
|
+
z-index: 10000;
|
|
2752
|
+
min-width: 120px;
|
|
2753
|
+
padding: 4px 0;
|
|
2754
|
+
}
|
|
2755
|
+
.leaflet-context-menu-item {
|
|
2756
|
+
padding: 8px 16px;
|
|
2757
|
+
cursor: pointer;
|
|
2758
|
+
font-size: 14px;
|
|
2759
|
+
color: #333;
|
|
2760
|
+
}
|
|
2761
|
+
.leaflet-context-menu-item:hover {
|
|
2762
|
+
background-color: #f0f0f0;
|
|
2763
|
+
}
|
|
2764
|
+
.leaflet-context-menu-item.delete {
|
|
2765
|
+
color: #dc3545;
|
|
2766
|
+
}
|
|
2767
|
+
.leaflet-context-menu-item.delete:hover {
|
|
2768
|
+
background-color: #fee;
|
|
2769
|
+
}
|
|
2770
|
+
`);
|
|
2771
|
+
|
|
2730
2772
|
module.exports = app => app.component('detail-default', {
|
|
2731
2773
|
template: template,
|
|
2732
|
-
props: ['value'],
|
|
2774
|
+
props: ['value', 'viewMode', 'onChange'],
|
|
2775
|
+
data() {
|
|
2776
|
+
return {
|
|
2777
|
+
mapVisible: false,
|
|
2778
|
+
mapInstance: null,
|
|
2779
|
+
mapLayer: null,
|
|
2780
|
+
draggableMarker: null,
|
|
2781
|
+
draggableMarkers: [], // For polygon vertices
|
|
2782
|
+
hasUnsavedChanges: false,
|
|
2783
|
+
currentEditedGeometry: null, // Track the current edited geometry state
|
|
2784
|
+
contextMenu: null, // Custom context menu element
|
|
2785
|
+
contextMenuMarker: null, // Marker that triggered context menu
|
|
2786
|
+
originalGeometry: null, // Store the original geometry when editing starts
|
|
2787
|
+
isCreatingMarkers: false // Guard against re-entrant marker creation
|
|
2788
|
+
};
|
|
2789
|
+
},
|
|
2733
2790
|
computed: {
|
|
2734
2791
|
displayValue() {
|
|
2735
2792
|
if (this.value === null) {
|
|
@@ -2749,10 +2806,1022 @@ module.exports = app => app.component('detail-default', {
|
|
|
2749
2806
|
} catch (err) {
|
|
2750
2807
|
return String(this.value);
|
|
2751
2808
|
}
|
|
2809
|
+
},
|
|
2810
|
+
isGeoJsonGeometry() {
|
|
2811
|
+
return this.value != null
|
|
2812
|
+
&& typeof this.value === 'object'
|
|
2813
|
+
&& !Array.isArray(this.value)
|
|
2814
|
+
&& Object.prototype.hasOwnProperty.call(this.value, 'type')
|
|
2815
|
+
&& Object.prototype.hasOwnProperty.call(this.value, 'coordinates');
|
|
2816
|
+
},
|
|
2817
|
+
isGeoJsonPoint() {
|
|
2818
|
+
return this.isGeoJsonGeometry && this.value.type === 'Point';
|
|
2819
|
+
},
|
|
2820
|
+
isGeoJsonPolygon() {
|
|
2821
|
+
return this.isGeoJsonGeometry && (this.value.type === 'Polygon' || this.value.type === 'MultiPolygon');
|
|
2822
|
+
},
|
|
2823
|
+
isMultiPolygon() {
|
|
2824
|
+
return this.isGeoJsonGeometry && this.value.type === 'MultiPolygon';
|
|
2825
|
+
},
|
|
2826
|
+
isEditable() {
|
|
2827
|
+
return (this.isGeoJsonPoint || this.isGeoJsonPolygon) && typeof this.onChange === 'function';
|
|
2828
|
+
},
|
|
2829
|
+
canUndo() {
|
|
2830
|
+
// Can undo if there are any changes from the original geometry
|
|
2831
|
+
return this.hasUnsavedChanges && this.originalGeometry != null;
|
|
2832
|
+
}
|
|
2833
|
+
},
|
|
2834
|
+
watch: {
|
|
2835
|
+
viewMode: {
|
|
2836
|
+
handler(newValue) {
|
|
2837
|
+
this.mapVisible = newValue === 'map';
|
|
2838
|
+
if (this.mapVisible) {
|
|
2839
|
+
this.$nextTick(() => {
|
|
2840
|
+
this.ensureMap();
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
},
|
|
2844
|
+
immediate: true
|
|
2845
|
+
},
|
|
2846
|
+
value: {
|
|
2847
|
+
handler(newValue) {
|
|
2848
|
+
if (this.mapVisible) {
|
|
2849
|
+
this.$nextTick(() => {
|
|
2850
|
+
this.ensureMap();
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
// Reset unsaved changes flag when value changes externally (e.g., after save)
|
|
2854
|
+
if (this.hasUnsavedChanges && (this.isGeoJsonPoint || this.isGeoJsonPolygon)) {
|
|
2855
|
+
this.hasUnsavedChanges = false;
|
|
2856
|
+
this.currentEditedGeometry = null; // Reset edited geometry when value changes externally
|
|
2857
|
+
}
|
|
2858
|
+
// Store the new value as the original geometry for future edits
|
|
2859
|
+
if (newValue && this.isGeoJsonGeometry) {
|
|
2860
|
+
this.originalGeometry = JSON.parse(JSON.stringify(newValue));
|
|
2861
|
+
} else {
|
|
2862
|
+
this.originalGeometry = null;
|
|
2863
|
+
}
|
|
2864
|
+
},
|
|
2865
|
+
deep: true,
|
|
2866
|
+
immediate: true
|
|
2867
|
+
}
|
|
2868
|
+
},
|
|
2869
|
+
beforeDestroy() {
|
|
2870
|
+
this.hideContextMenu();
|
|
2871
|
+
if (this.draggableMarker) {
|
|
2872
|
+
this.draggableMarker.remove();
|
|
2873
|
+
this.draggableMarker = null;
|
|
2874
|
+
}
|
|
2875
|
+
// Clean up polygon vertex markers
|
|
2876
|
+
this.draggableMarkers.forEach(marker => {
|
|
2877
|
+
if (marker) {
|
|
2878
|
+
marker.remove();
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
this.draggableMarkers = [];
|
|
2882
|
+
if (this.mapInstance) {
|
|
2883
|
+
this.mapInstance.remove();
|
|
2884
|
+
this.mapInstance = null;
|
|
2885
|
+
this.mapLayer = null;
|
|
2886
|
+
}
|
|
2887
|
+
},
|
|
2888
|
+
methods: {
|
|
2889
|
+
ensureMap() {
|
|
2890
|
+
if (!this.mapVisible || !this.isGeoJsonGeometry || !this.$refs.map) {
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
if (typeof L === 'undefined') {
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
if (!this.mapInstance) {
|
|
2899
|
+
// Ensure the map container has explicit dimensions
|
|
2900
|
+
const mapElement = this.$refs.map;
|
|
2901
|
+
if (mapElement) {
|
|
2902
|
+
// Set explicit dimensions inline with !important to override any CSS
|
|
2903
|
+
mapElement.style.setProperty('height', '256px', 'important');
|
|
2904
|
+
mapElement.style.setProperty('min-height', '256px', 'important');
|
|
2905
|
+
mapElement.style.setProperty('width', '100%', 'important');
|
|
2906
|
+
mapElement.style.setProperty('display', 'block', 'important');
|
|
2907
|
+
|
|
2908
|
+
// Force a reflow to ensure dimensions are applied
|
|
2909
|
+
void mapElement.offsetHeight;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
try {
|
|
2913
|
+
this.mapInstance = L.map(this.$refs.map, {
|
|
2914
|
+
preferCanvas: false
|
|
2915
|
+
}).setView([0, 0], 1);
|
|
2916
|
+
|
|
2917
|
+
// Ensure map container has relative positioning for context menu
|
|
2918
|
+
const mapContainer = this.mapInstance.getContainer();
|
|
2919
|
+
if (mapContainer) {
|
|
2920
|
+
mapContainer.style.position = 'relative';
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
2924
|
+
attribution: '© OpenStreetMap contributors'
|
|
2925
|
+
}).addTo(this.mapInstance);
|
|
2926
|
+
|
|
2927
|
+
// Explicitly invalidate size after creation to ensure proper rendering
|
|
2928
|
+
this.$nextTick(() => {
|
|
2929
|
+
if (this.mapInstance) {
|
|
2930
|
+
this.mapInstance.invalidateSize();
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
this.updateMapLayer();
|
|
2939
|
+
this.$nextTick(() => {
|
|
2940
|
+
if (this.mapInstance) {
|
|
2941
|
+
this.mapInstance.invalidateSize();
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
},
|
|
2945
|
+
updateMapLayer() {
|
|
2946
|
+
if (!this.mapInstance || !this.isGeoJsonGeometry) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// For Point geometries in edit mode, use a draggable marker
|
|
2951
|
+
if (this.isGeoJsonPoint && this.isEditable) {
|
|
2952
|
+
const [lng, lat] = this.value.coordinates;
|
|
2953
|
+
|
|
2954
|
+
// If marker exists, update its position instead of recreating
|
|
2955
|
+
if (this.draggableMarker) {
|
|
2956
|
+
const currentLatLng = this.draggableMarker.getLatLng();
|
|
2957
|
+
// Only update if coordinates actually changed (avoid interrupting drag)
|
|
2958
|
+
if (Math.abs(currentLatLng.lat - lat) > 0.0001 || Math.abs(currentLatLng.lng - lng) > 0.0001) {
|
|
2959
|
+
this.draggableMarker.setLatLng([lat, lng]);
|
|
2960
|
+
}
|
|
2961
|
+
} else {
|
|
2962
|
+
// Create new draggable marker
|
|
2963
|
+
this.draggableMarker = L.marker([lat, lng], {
|
|
2964
|
+
draggable: true
|
|
2965
|
+
}).addTo(this.mapInstance);
|
|
2966
|
+
|
|
2967
|
+
// Add dragend event handler
|
|
2968
|
+
this.draggableMarker.on('dragend', () => {
|
|
2969
|
+
const newLat = this.draggableMarker.getLatLng().lat;
|
|
2970
|
+
const newLng = this.draggableMarker.getLatLng().lng;
|
|
2971
|
+
const newGeometry = {
|
|
2972
|
+
type: 'Point',
|
|
2973
|
+
coordinates: [newLng, newLat]
|
|
2974
|
+
};
|
|
2975
|
+
this.hasUnsavedChanges = true;
|
|
2976
|
+
if (this.onChange) {
|
|
2977
|
+
this.onChange(newGeometry);
|
|
2978
|
+
}
|
|
2979
|
+
});
|
|
2980
|
+
|
|
2981
|
+
// Center map on marker with appropriate zoom level
|
|
2982
|
+
const currentZoom = this.mapInstance.getZoom();
|
|
2983
|
+
// If zoom is too low (less than 10), set a good default zoom level (13)
|
|
2984
|
+
const targetZoom = currentZoom < 10 ? 13 : currentZoom;
|
|
2985
|
+
this.mapInstance.setView([lat, lng], targetZoom);
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
// Remove any existing non-draggable layer
|
|
2989
|
+
if (this.mapLayer) {
|
|
2990
|
+
this.mapLayer.remove();
|
|
2991
|
+
this.mapLayer = null;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// Clean up polygon markers if they exist
|
|
2995
|
+
this.draggableMarkers.forEach(marker => {
|
|
2996
|
+
if (marker) marker.remove();
|
|
2997
|
+
});
|
|
2998
|
+
this.draggableMarkers = [];
|
|
2999
|
+
} else if (this.isGeoJsonPolygon && this.isEditable) {
|
|
3000
|
+
// Initialize current edited geometry if not set
|
|
3001
|
+
if (!this.currentEditedGeometry) {
|
|
3002
|
+
this.currentEditedGeometry = JSON.parse(JSON.stringify(this.value));
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
// For Polygon geometries in edit mode, create polygon layer with draggable vertex markers
|
|
3006
|
+
// Use current edited geometry if available, otherwise use value
|
|
3007
|
+
const polygonGeometryToUse = this.currentEditedGeometry || this.value;
|
|
3008
|
+
const feature = {
|
|
3009
|
+
type: 'Feature',
|
|
3010
|
+
geometry: polygonGeometryToUse,
|
|
3011
|
+
properties: {}
|
|
3012
|
+
};
|
|
3013
|
+
|
|
3014
|
+
// Update or create polygon layer
|
|
3015
|
+
if (this.mapLayer) {
|
|
3016
|
+
this.mapLayer.remove();
|
|
3017
|
+
}
|
|
3018
|
+
this.mapLayer = L.geoJSON(feature, {
|
|
3019
|
+
style: {
|
|
3020
|
+
color: '#3388ff',
|
|
3021
|
+
weight: 2,
|
|
3022
|
+
opacity: 0.8,
|
|
3023
|
+
fillOpacity: 0.2
|
|
3024
|
+
},
|
|
3025
|
+
interactive: this.isMultiPolygon // Only interactive for MultiPolygon (to allow edge clicks)
|
|
3026
|
+
}).addTo(this.mapInstance);
|
|
3027
|
+
|
|
3028
|
+
// Add contextmenu handler to polygon edges to add vertices (only for MultiPolygon)
|
|
3029
|
+
if (this.isMultiPolygon) {
|
|
3030
|
+
this.mapLayer.eachLayer((layer) => {
|
|
3031
|
+
layer.on('contextmenu', (e) => {
|
|
3032
|
+
e.originalEvent.preventDefault();
|
|
3033
|
+
e.originalEvent.stopPropagation();
|
|
3034
|
+
|
|
3035
|
+
// Check if clicking near an existing marker (using pixel distance)
|
|
3036
|
+
const clickPoint = e.latlng;
|
|
3037
|
+
const clickContainerPoint = this.mapInstance.latLngToContainerPoint(clickPoint);
|
|
3038
|
+
|
|
3039
|
+
const isClickingOnMarker = this.draggableMarkers.some(marker => {
|
|
3040
|
+
if (!marker || !marker._icon) return false;
|
|
3041
|
+
const markerLatLng = marker.getLatLng();
|
|
3042
|
+
const markerContainerPoint = this.mapInstance.latLngToContainerPoint(markerLatLng);
|
|
3043
|
+
|
|
3044
|
+
// Calculate pixel distance
|
|
3045
|
+
const dx = clickContainerPoint.x - markerContainerPoint.x;
|
|
3046
|
+
const dy = clickContainerPoint.y - markerContainerPoint.y;
|
|
3047
|
+
const pixelDistance = Math.sqrt(dx * dx + dy * dy);
|
|
3048
|
+
|
|
3049
|
+
// 15 pixel threshold - marker icon is about 12px, so 15px gives some buffer
|
|
3050
|
+
return pixelDistance < 15;
|
|
3051
|
+
});
|
|
3052
|
+
|
|
3053
|
+
if (!isClickingOnMarker) {
|
|
3054
|
+
this.showAddVertexContextMenu(e.originalEvent, clickPoint);
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
// Make polygon layers non-interactive to avoid interfering with marker dragging
|
|
3061
|
+
this.mapLayer.eachLayer((layer) => {
|
|
3062
|
+
layer.setStyle({
|
|
3063
|
+
interactive: this.isMultiPolygon, // Only interactive for MultiPolygon (for context menu)
|
|
3064
|
+
stroke: true,
|
|
3065
|
+
weight: 2,
|
|
3066
|
+
opacity: 0.8
|
|
3067
|
+
});
|
|
3068
|
+
layer._path.style.pointerEvents = this.isMultiPolygon ? 'stroke' : 'none';
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
// Get the outer ring coordinates
|
|
3072
|
+
// For Polygon: coordinates[0] is the outer ring
|
|
3073
|
+
// For MultiPolygon: coordinates[0][0] is the first polygon's outer ring
|
|
3074
|
+
// Use current edited geometry if available
|
|
3075
|
+
const ringGeometryToUse = this.currentEditedGeometry || this.value;
|
|
3076
|
+
let outerRing = [];
|
|
3077
|
+
|
|
3078
|
+
if (this.isMultiPolygon) {
|
|
3079
|
+
// MultiPolygon structure: [[[[lng, lat], ...]], [[[lng, lat], ...]]]
|
|
3080
|
+
// Get the first polygon's outer ring
|
|
3081
|
+
if (ringGeometryToUse.coordinates[0] && ringGeometryToUse.coordinates[0][0]) {
|
|
3082
|
+
outerRing = ringGeometryToUse.coordinates[0][0];
|
|
3083
|
+
}
|
|
3084
|
+
} else {
|
|
3085
|
+
// Polygon structure: [[[lng, lat], ...]]
|
|
3086
|
+
outerRing = ringGeometryToUse.coordinates[0] || [];
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
if (outerRing.length === 0) {
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
// Determine how many markers we should have (accounting for closed rings)
|
|
3094
|
+
const isClosedRing = outerRing.length > 0 &&
|
|
3095
|
+
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
|
|
3096
|
+
outerRing[0][1] === outerRing[outerRing.length - 1][1];
|
|
3097
|
+
const expectedMarkerCount = isClosedRing ? outerRing.length - 1 : outerRing.length;
|
|
3098
|
+
|
|
3099
|
+
// Remove existing markers if count doesn't match
|
|
3100
|
+
if (this.draggableMarkers.length !== expectedMarkerCount && !this.isCreatingMarkers) {
|
|
3101
|
+
this.isCreatingMarkers = true;
|
|
3102
|
+
this.draggableMarkers.forEach(marker => {
|
|
3103
|
+
if (marker) marker.remove();
|
|
3104
|
+
});
|
|
3105
|
+
this.draggableMarkers = [];
|
|
3106
|
+
|
|
3107
|
+
// Create draggable markers for each vertex
|
|
3108
|
+
// Use setTimeout to ensure markers are added after polygon layer is fully rendered
|
|
3109
|
+
this.$nextTick(() => {
|
|
3110
|
+
// GeoJSON polygons typically have the first and last coordinate the same (closed ring)
|
|
3111
|
+
// We'll create markers for all coordinates except the last one if it's a duplicate
|
|
3112
|
+
// But we need to track the original index for updating the correct coordinate
|
|
3113
|
+
const isClosedRing = outerRing.length > 0 &&
|
|
3114
|
+
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
|
|
3115
|
+
outerRing[0][1] === outerRing[outerRing.length - 1][1];
|
|
3116
|
+
|
|
3117
|
+
const coordsToProcess = isClosedRing
|
|
3118
|
+
? outerRing.slice(0, -1) // Skip last coordinate if it's a duplicate of first
|
|
3119
|
+
: outerRing;
|
|
3120
|
+
|
|
3121
|
+
coordsToProcess.forEach((coord, visibleIndex) => {
|
|
3122
|
+
// visibleIndex is the index in the visible markers array
|
|
3123
|
+
// actualIndex is the index in the coordinates array (same for non-closed rings)
|
|
3124
|
+
const actualIndex = visibleIndex;
|
|
3125
|
+
if (!Array.isArray(coord) || coord.length < 2) {
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
const [lng, lat] = coord;
|
|
3130
|
+
|
|
3131
|
+
// Create a custom icon for the vertex marker
|
|
3132
|
+
const icon = L.divIcon({
|
|
3133
|
+
className: 'polygon-vertex-marker',
|
|
3134
|
+
html: '<div style="width: 12px; height: 12px; background-color: #3388ff; border: 2px solid white; border-radius: 50%; cursor: move;"></div>',
|
|
3135
|
+
iconSize: [12, 12],
|
|
3136
|
+
iconAnchor: [6, 6]
|
|
3137
|
+
});
|
|
3138
|
+
|
|
3139
|
+
const marker = L.marker([lat, lng], {
|
|
3140
|
+
draggable: true,
|
|
3141
|
+
icon: icon,
|
|
3142
|
+
zIndexOffset: 1000, // Ensure markers are above polygon layer
|
|
3143
|
+
riseOnHover: true, // Bring marker to front on hover
|
|
3144
|
+
bubblingMouseEvents: false // Prevent events from bubbling to polygon
|
|
3145
|
+
});
|
|
3146
|
+
|
|
3147
|
+
// Add marker to map
|
|
3148
|
+
marker.addTo(this.mapInstance);
|
|
3149
|
+
|
|
3150
|
+
// Ensure marker is in the marker pane (above overlay pane where polygon is)
|
|
3151
|
+
// Note: markers don't have bringToFront(), we use zIndexOffset and DOM manipulation
|
|
3152
|
+
if (marker._icon) {
|
|
3153
|
+
marker._icon.style.pointerEvents = 'auto';
|
|
3154
|
+
marker._icon.style.zIndex = (1001 + visibleIndex).toString(); // Unique z-index for each marker
|
|
3155
|
+
// Move marker icon to top of marker pane
|
|
3156
|
+
if (marker._icon.parentNode) {
|
|
3157
|
+
marker._icon.parentNode.appendChild(marker._icon);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// Add dragend event handler
|
|
3162
|
+
// Store the actualIndex in closure for this marker
|
|
3163
|
+
const markerActualIndex = actualIndex;
|
|
3164
|
+
const markerIsFirstInClosedRing = isClosedRing && actualIndex === 0;
|
|
3165
|
+
|
|
3166
|
+
// Add right-click handler to show context menu
|
|
3167
|
+
marker.on('contextmenu', (e) => {
|
|
3168
|
+
e.originalEvent.preventDefault();
|
|
3169
|
+
e.originalEvent.stopPropagation();
|
|
3170
|
+
this.showContextMenu(e.originalEvent, markerActualIndex, marker);
|
|
3171
|
+
return false;
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
// Also attach directly to icon element as the event might not bubble to marker
|
|
3175
|
+
if (marker._icon) {
|
|
3176
|
+
marker._icon.addEventListener('contextmenu', (e) => {
|
|
3177
|
+
e.preventDefault();
|
|
3178
|
+
e.stopPropagation();
|
|
3179
|
+
this.showContextMenu(e, markerActualIndex, marker);
|
|
3180
|
+
return false;
|
|
3181
|
+
}, true); // Use capture phase
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
marker.on('dragend', () => {
|
|
3185
|
+
const newLat = marker.getLatLng().lat;
|
|
3186
|
+
const newLng = marker.getLatLng().lng;
|
|
3187
|
+
|
|
3188
|
+
// Use current edited geometry if available, otherwise use original value
|
|
3189
|
+
const baseGeometry = this.currentEditedGeometry || this.value;
|
|
3190
|
+
const newCoordinates = JSON.parse(JSON.stringify(baseGeometry.coordinates));
|
|
3191
|
+
|
|
3192
|
+
// Get the outer ring to check if it's closed
|
|
3193
|
+
let outerRing = [];
|
|
3194
|
+
if (this.isMultiPolygon) {
|
|
3195
|
+
outerRing = newCoordinates[0][0] || [];
|
|
3196
|
+
} else {
|
|
3197
|
+
outerRing = newCoordinates[0] || [];
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
// Check if this is a closed ring
|
|
3201
|
+
const isClosedRingNow = outerRing.length > 0 &&
|
|
3202
|
+
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
|
|
3203
|
+
outerRing[0][1] === outerRing[outerRing.length - 1][1];
|
|
3204
|
+
|
|
3205
|
+
// Update the coordinate
|
|
3206
|
+
if (this.isMultiPolygon) {
|
|
3207
|
+
// MultiPolygon: coordinates[0][0][index] = [lng, lat]
|
|
3208
|
+
newCoordinates[0][0][markerActualIndex] = [newLng, newLat];
|
|
3209
|
+
// If closed ring and this is the first coordinate, also update the closing coordinate
|
|
3210
|
+
if (isClosedRingNow && markerIsFirstInClosedRing) {
|
|
3211
|
+
newCoordinates[0][0][outerRing.length - 1] = [newLng, newLat];
|
|
3212
|
+
}
|
|
3213
|
+
} else {
|
|
3214
|
+
// Polygon: coordinates[0][index] = [lng, lat]
|
|
3215
|
+
newCoordinates[0][markerActualIndex] = [newLng, newLat];
|
|
3216
|
+
// If closed ring and this is the first coordinate, also update the closing coordinate
|
|
3217
|
+
if (isClosedRingNow && markerIsFirstInClosedRing) {
|
|
3218
|
+
newCoordinates[0][outerRing.length - 1] = [newLng, newLat];
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
// Validate coordinate structure before creating geometry
|
|
3223
|
+
try {
|
|
3224
|
+
// Ensure coordinates are valid numbers
|
|
3225
|
+
if (isNaN(newLng) || isNaN(newLat)) {
|
|
3226
|
+
throw new Error(`Invalid coordinates: [${newLng}, ${newLat}]`);
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
const newGeometry = {
|
|
3230
|
+
type: baseGeometry.type, // Preserve Polygon or MultiPolygon type
|
|
3231
|
+
coordinates: newCoordinates
|
|
3232
|
+
};
|
|
3233
|
+
|
|
3234
|
+
// Validate the geometry structure
|
|
3235
|
+
if (!newGeometry.type || !Array.isArray(newGeometry.coordinates)) {
|
|
3236
|
+
throw new Error('Invalid geometry structure');
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
// Store the current edited geometry state
|
|
3240
|
+
this.currentEditedGeometry = newGeometry;
|
|
3241
|
+
this.hasUnsavedChanges = true;
|
|
3242
|
+
|
|
3243
|
+
// Update the polygon layer immediately for visual feedback
|
|
3244
|
+
this.updatePolygonLayer(newGeometry);
|
|
3245
|
+
|
|
3246
|
+
// Notify parent of the change
|
|
3247
|
+
if (this.onChange) {
|
|
3248
|
+
this.onChange(newGeometry);
|
|
3249
|
+
}
|
|
3250
|
+
} catch (error) {
|
|
3251
|
+
// Log errors to aid debugging while still preventing UI crashes
|
|
3252
|
+
console.error('Error updating geometry on marker dragend:', error);
|
|
3253
|
+
}
|
|
3254
|
+
});
|
|
3255
|
+
|
|
3256
|
+
this.draggableMarkers.push(marker);
|
|
3257
|
+
});
|
|
3258
|
+
// Reset the guard after all markers are created
|
|
3259
|
+
this.isCreatingMarkers = false;
|
|
3260
|
+
});
|
|
3261
|
+
} else {
|
|
3262
|
+
// Update existing marker positions if coordinates changed
|
|
3263
|
+
// Get the correct outer ring based on geometry type
|
|
3264
|
+
// Use current edited geometry if available
|
|
3265
|
+
const updateGeometryToUse = this.currentEditedGeometry || this.value;
|
|
3266
|
+
let currentOuterRing = [];
|
|
3267
|
+
if (this.isMultiPolygon) {
|
|
3268
|
+
if (updateGeometryToUse.coordinates[0] && updateGeometryToUse.coordinates[0][0]) {
|
|
3269
|
+
currentOuterRing = updateGeometryToUse.coordinates[0][0];
|
|
3270
|
+
}
|
|
3271
|
+
} else {
|
|
3272
|
+
currentOuterRing = updateGeometryToUse.coordinates[0] || [];
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
currentOuterRing.forEach((coord, index) => {
|
|
3276
|
+
const [lng, lat] = coord;
|
|
3277
|
+
const marker = this.draggableMarkers[index];
|
|
3278
|
+
if (marker) {
|
|
3279
|
+
const currentLatLng = marker.getLatLng();
|
|
3280
|
+
// Only update if coordinates actually changed (avoid interrupting drag)
|
|
3281
|
+
if (Math.abs(currentLatLng.lat - lat) > 0.0001 || Math.abs(currentLatLng.lng - lng) > 0.0001) {
|
|
3282
|
+
marker.setLatLng([lat, lng]);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
|
|
3287
|
+
// Update polygon layer to reflect any coordinate changes
|
|
3288
|
+
// Use current edited geometry if available, otherwise use value
|
|
3289
|
+
const layerGeometryToUse = this.currentEditedGeometry || this.value;
|
|
3290
|
+
this.updatePolygonLayer(layerGeometryToUse);
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// Remove point marker if it exists
|
|
3294
|
+
if (this.draggableMarker) {
|
|
3295
|
+
this.draggableMarkers = this.draggableMarkers.filter(marker => marker !== this.draggableMarker);
|
|
3296
|
+
this.draggableMarker.remove();
|
|
3297
|
+
this.draggableMarker = null;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// Fit bounds to polygon
|
|
3301
|
+
const bounds = this.mapLayer.getBounds();
|
|
3302
|
+
if (bounds.isValid()) {
|
|
3303
|
+
const currentZoom = this.mapInstance.getZoom();
|
|
3304
|
+
if (currentZoom < 10) {
|
|
3305
|
+
this.mapInstance.fitBounds(bounds, { maxZoom: 16 });
|
|
3306
|
+
} else {
|
|
3307
|
+
// Just ensure polygon is visible, don't change zoom if already zoomed in
|
|
3308
|
+
if (!bounds.contains(this.mapInstance.getBounds())) {
|
|
3309
|
+
this.mapInstance.fitBounds(bounds, { maxZoom: 16 });
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
} else {
|
|
3314
|
+
// For other geometries or non-editable mode, use standard GeoJSON layer
|
|
3315
|
+
if (this.draggableMarker) {
|
|
3316
|
+
this.draggableMarker.remove();
|
|
3317
|
+
this.draggableMarker = null;
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
// Clean up polygon markers
|
|
3321
|
+
this.draggableMarkers.forEach(marker => {
|
|
3322
|
+
if (marker) marker.remove();
|
|
3323
|
+
});
|
|
3324
|
+
this.draggableMarkers = [];
|
|
3325
|
+
|
|
3326
|
+
if (this.mapLayer) {
|
|
3327
|
+
this.mapLayer.remove();
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
const feature = {
|
|
3331
|
+
type: 'Feature',
|
|
3332
|
+
geometry: this.value,
|
|
3333
|
+
properties: {}
|
|
3334
|
+
};
|
|
3335
|
+
|
|
3336
|
+
this.mapLayer = L.geoJSON(feature).addTo(this.mapInstance);
|
|
3337
|
+
const bounds = this.mapLayer.getBounds();
|
|
3338
|
+
if (bounds.isValid()) {
|
|
3339
|
+
this.mapInstance.fitBounds(bounds, { maxZoom: 16 });
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
},
|
|
3343
|
+
updatePolygonLayer(geometry) {
|
|
3344
|
+
if (!this.mapInstance || !this.mapLayer) {
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
// Remove old layer
|
|
3349
|
+
this.mapLayer.remove();
|
|
3350
|
+
|
|
3351
|
+
// Create new layer with updated geometry
|
|
3352
|
+
const feature = {
|
|
3353
|
+
type: 'Feature',
|
|
3354
|
+
geometry: geometry,
|
|
3355
|
+
properties: {}
|
|
3356
|
+
};
|
|
3357
|
+
|
|
3358
|
+
this.mapLayer = L.geoJSON(feature, {
|
|
3359
|
+
style: {
|
|
3360
|
+
color: '#3388ff',
|
|
3361
|
+
weight: 2,
|
|
3362
|
+
opacity: 0.8,
|
|
3363
|
+
fillOpacity: 0.2
|
|
3364
|
+
},
|
|
3365
|
+
interactive: this.isMultiPolygon // Only interactive for MultiPolygon (to allow edge clicks)
|
|
3366
|
+
}).addTo(this.mapInstance);
|
|
3367
|
+
|
|
3368
|
+
// Add contextmenu handler to polygon edges to add vertices (only for MultiPolygon)
|
|
3369
|
+
if (this.isMultiPolygon && this.mapLayer.eachLayer) {
|
|
3370
|
+
this.mapLayer.eachLayer((layer) => {
|
|
3371
|
+
// Remove any existing contextmenu handlers first
|
|
3372
|
+
layer.off('contextmenu');
|
|
3373
|
+
|
|
3374
|
+
layer.on('contextmenu', (e) => {
|
|
3375
|
+
e.originalEvent.preventDefault();
|
|
3376
|
+
e.originalEvent.stopPropagation();
|
|
3377
|
+
|
|
3378
|
+
const clickPoint = e.latlng;
|
|
3379
|
+
const clickContainerPoint = this.mapInstance.latLngToContainerPoint(clickPoint);
|
|
3380
|
+
|
|
3381
|
+
// Check if clicking near an existing marker
|
|
3382
|
+
const isClickingOnMarker = this.draggableMarkers.some(marker => {
|
|
3383
|
+
if (!marker || !marker._icon) return false;
|
|
3384
|
+
const markerLatLng = marker.getLatLng();
|
|
3385
|
+
const markerContainerPoint = this.mapInstance.latLngToContainerPoint(markerLatLng);
|
|
3386
|
+
|
|
3387
|
+
const dx = clickContainerPoint.x - markerContainerPoint.x;
|
|
3388
|
+
const dy = clickContainerPoint.y - markerContainerPoint.y;
|
|
3389
|
+
const pixelDistance = Math.sqrt(dx * dx + dy * dy);
|
|
3390
|
+
|
|
3391
|
+
return pixelDistance < 15;
|
|
3392
|
+
});
|
|
3393
|
+
|
|
3394
|
+
if (!isClickingOnMarker) {
|
|
3395
|
+
this.showAddVertexContextMenu(e.originalEvent, clickPoint);
|
|
3396
|
+
}
|
|
3397
|
+
});
|
|
3398
|
+
|
|
3399
|
+
// Style polygon layer
|
|
3400
|
+
if (layer.setStyle) {
|
|
3401
|
+
layer.setStyle({
|
|
3402
|
+
interactive: true,
|
|
3403
|
+
stroke: true,
|
|
3404
|
+
weight: 2,
|
|
3405
|
+
opacity: 0.8
|
|
3406
|
+
});
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
if (layer._path) {
|
|
3410
|
+
layer._path.style.pointerEvents = 'stroke';
|
|
3411
|
+
}
|
|
3412
|
+
});
|
|
3413
|
+
} else if (!this.isMultiPolygon && this.mapLayer.eachLayer) {
|
|
3414
|
+
// For regular Polygon, ensure it's non-interactive
|
|
3415
|
+
this.mapLayer.eachLayer((layer) => {
|
|
3416
|
+
layer.off('contextmenu'); // Remove any contextmenu handlers
|
|
3417
|
+
if (layer.setStyle) {
|
|
3418
|
+
layer.setStyle({ interactive: false });
|
|
3419
|
+
}
|
|
3420
|
+
if (layer._path) {
|
|
3421
|
+
layer._path.style.pointerEvents = 'none';
|
|
3422
|
+
}
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
// Bring all markers to front after updating polygon
|
|
3427
|
+
// Note: markers don't have bringToFront(), we manipulate DOM directly
|
|
3428
|
+
this.draggableMarkers.forEach((marker, index) => {
|
|
3429
|
+
if (marker && marker._icon && marker._icon.parentNode) {
|
|
3430
|
+
marker._icon.style.zIndex = (1001 + index).toString();
|
|
3431
|
+
marker._icon.parentNode.appendChild(marker._icon);
|
|
3432
|
+
}
|
|
3433
|
+
});
|
|
3434
|
+
},
|
|
3435
|
+
showContextMenu(event, index, marker) {
|
|
3436
|
+
// Hide any existing context menu
|
|
3437
|
+
this.hideContextMenu();
|
|
3438
|
+
|
|
3439
|
+
// Store the marker for deletion
|
|
3440
|
+
this.contextMenuMarker = { index, marker, type: 'vertex' };
|
|
3441
|
+
|
|
3442
|
+
// Create context menu if it doesn't exist
|
|
3443
|
+
if (!this.contextMenu) {
|
|
3444
|
+
this.contextMenu = document.createElement('div');
|
|
3445
|
+
this.contextMenu.className = 'leaflet-context-menu';
|
|
3446
|
+
|
|
3447
|
+
// Append to map container so it's positioned relative to the map
|
|
3448
|
+
if (this.mapInstance && this.mapInstance.getContainer()) {
|
|
3449
|
+
this.mapInstance.getContainer().appendChild(this.contextMenu);
|
|
3450
|
+
} else {
|
|
3451
|
+
document.body.appendChild(this.contextMenu);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// Clear existing menu items
|
|
3456
|
+
this.contextMenu.innerHTML = '';
|
|
3457
|
+
|
|
3458
|
+
// Add Delete option for vertices
|
|
3459
|
+
const deleteItem = document.createElement('div');
|
|
3460
|
+
deleteItem.className = 'leaflet-context-menu-item delete';
|
|
3461
|
+
deleteItem.textContent = 'Delete';
|
|
3462
|
+
deleteItem.addEventListener('click', (e) => {
|
|
3463
|
+
e.stopPropagation();
|
|
3464
|
+
if (this.contextMenuMarker && this.contextMenuMarker.type === 'vertex') {
|
|
3465
|
+
this.deleteVertex(this.contextMenuMarker.index, this.contextMenuMarker.marker);
|
|
3466
|
+
}
|
|
3467
|
+
this.hideContextMenu();
|
|
3468
|
+
});
|
|
3469
|
+
this.contextMenu.appendChild(deleteItem);
|
|
3470
|
+
|
|
3471
|
+
// Get map container position for relative positioning
|
|
3472
|
+
const mapContainer = this.mapInstance ? this.mapInstance.getContainer() : null;
|
|
3473
|
+
let left = event.clientX;
|
|
3474
|
+
let top = event.clientY;
|
|
3475
|
+
|
|
3476
|
+
if (mapContainer) {
|
|
3477
|
+
const rect = mapContainer.getBoundingClientRect();
|
|
3478
|
+
left = event.clientX - rect.left;
|
|
3479
|
+
top = event.clientY - rect.top;
|
|
3480
|
+
this.contextMenu.style.position = 'absolute';
|
|
3481
|
+
} else {
|
|
3482
|
+
this.contextMenu.style.position = 'fixed';
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
// Position the context menu at the click location
|
|
3486
|
+
this.contextMenu.style.left = left + 'px';
|
|
3487
|
+
this.contextMenu.style.top = top + 'px';
|
|
3488
|
+
this.contextMenu.style.display = 'block';
|
|
3489
|
+
|
|
3490
|
+
// Hide context menu when clicking elsewhere
|
|
3491
|
+
const hideMenu = (e) => {
|
|
3492
|
+
if (this.contextMenu && !this.contextMenu.contains(e.target)) {
|
|
3493
|
+
this.hideContextMenu();
|
|
3494
|
+
document.removeEventListener('click', hideMenu);
|
|
3495
|
+
document.removeEventListener('contextmenu', hideMenu);
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
|
|
3499
|
+
// Use setTimeout to avoid immediate hide from the current click
|
|
3500
|
+
setTimeout(() => {
|
|
3501
|
+
document.addEventListener('click', hideMenu);
|
|
3502
|
+
document.addEventListener('contextmenu', hideMenu);
|
|
3503
|
+
}, 10);
|
|
3504
|
+
},
|
|
3505
|
+
hideContextMenu() {
|
|
3506
|
+
if (this.contextMenu) {
|
|
3507
|
+
this.contextMenu.style.display = 'none';
|
|
3508
|
+
}
|
|
3509
|
+
this.contextMenuMarker = null;
|
|
3510
|
+
},
|
|
3511
|
+
showAddVertexContextMenu(event, latlng) {
|
|
3512
|
+
// Hide any existing context menu
|
|
3513
|
+
this.hideContextMenu();
|
|
3514
|
+
|
|
3515
|
+
// Store the location for adding vertex
|
|
3516
|
+
this.contextMenuMarker = { latlng, type: 'edge' };
|
|
3517
|
+
|
|
3518
|
+
// Create context menu if it doesn't exist
|
|
3519
|
+
if (!this.contextMenu) {
|
|
3520
|
+
this.contextMenu = document.createElement('div');
|
|
3521
|
+
this.contextMenu.className = 'leaflet-context-menu';
|
|
3522
|
+
|
|
3523
|
+
// Append to map container so it's positioned relative to the map
|
|
3524
|
+
if (this.mapInstance && this.mapInstance.getContainer()) {
|
|
3525
|
+
this.mapInstance.getContainer().appendChild(this.contextMenu);
|
|
3526
|
+
} else {
|
|
3527
|
+
document.body.appendChild(this.contextMenu);
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
// Clear existing menu items
|
|
3532
|
+
this.contextMenu.innerHTML = '';
|
|
3533
|
+
|
|
3534
|
+
// Add "Add Vertex" option
|
|
3535
|
+
const addVertexItem = document.createElement('div');
|
|
3536
|
+
addVertexItem.className = 'leaflet-context-menu-item';
|
|
3537
|
+
addVertexItem.textContent = 'Add Vertex';
|
|
3538
|
+
addVertexItem.addEventListener('click', (e) => {
|
|
3539
|
+
e.stopPropagation();
|
|
3540
|
+
if (this.contextMenuMarker && this.contextMenuMarker.type === 'edge') {
|
|
3541
|
+
this.addVertexAtLocation(this.contextMenuMarker.latlng);
|
|
3542
|
+
}
|
|
3543
|
+
this.hideContextMenu();
|
|
3544
|
+
});
|
|
3545
|
+
this.contextMenu.appendChild(addVertexItem);
|
|
3546
|
+
|
|
3547
|
+
// Position the context menu
|
|
3548
|
+
const mapContainer = this.mapInstance ? this.mapInstance.getContainer() : null;
|
|
3549
|
+
let left = event.clientX;
|
|
3550
|
+
let top = event.clientY;
|
|
3551
|
+
|
|
3552
|
+
if (mapContainer) {
|
|
3553
|
+
const rect = mapContainer.getBoundingClientRect();
|
|
3554
|
+
left = event.clientX - rect.left;
|
|
3555
|
+
top = event.clientY - rect.top;
|
|
3556
|
+
this.contextMenu.style.position = 'absolute';
|
|
3557
|
+
} else {
|
|
3558
|
+
this.contextMenu.style.position = 'fixed';
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
this.contextMenu.style.left = left + 'px';
|
|
3562
|
+
this.contextMenu.style.top = top + 'px';
|
|
3563
|
+
this.contextMenu.style.display = 'block';
|
|
3564
|
+
|
|
3565
|
+
// Hide menu when clicking elsewhere
|
|
3566
|
+
const hideMenu = (e) => {
|
|
3567
|
+
if (!this.contextMenu || !this.contextMenu.contains(e.target)) {
|
|
3568
|
+
this.hideContextMenu();
|
|
3569
|
+
document.removeEventListener('click', hideMenu);
|
|
3570
|
+
}
|
|
3571
|
+
};
|
|
3572
|
+
setTimeout(() => {
|
|
3573
|
+
document.addEventListener('click', hideMenu);
|
|
3574
|
+
}, 0);
|
|
3575
|
+
},
|
|
3576
|
+
addVertexAtLocation(latlng) {
|
|
3577
|
+
// Get current geometry
|
|
3578
|
+
const baseGeometry = this.currentEditedGeometry || this.value;
|
|
3579
|
+
const newCoordinates = JSON.parse(JSON.stringify(baseGeometry.coordinates));
|
|
3580
|
+
|
|
3581
|
+
// Get the outer ring
|
|
3582
|
+
let outerRing = [];
|
|
3583
|
+
if (this.isMultiPolygon) {
|
|
3584
|
+
outerRing = newCoordinates[0][0] || [];
|
|
3585
|
+
} else {
|
|
3586
|
+
outerRing = newCoordinates[0] || [];
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
if (outerRing.length === 0) {
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
// Check if this is a closed ring
|
|
3594
|
+
const isClosedRing = outerRing.length > 0 &&
|
|
3595
|
+
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
|
|
3596
|
+
outerRing[0][1] === outerRing[outerRing.length - 1][1];
|
|
3597
|
+
|
|
3598
|
+
// Convert latlng to [lng, lat] format
|
|
3599
|
+
const newCoord = [latlng.lng, latlng.lat];
|
|
3600
|
+
|
|
3601
|
+
// Find the closest edge segment and insert the vertex
|
|
3602
|
+
let closestEdgeIndex = 0;
|
|
3603
|
+
let minDistance = Infinity;
|
|
3604
|
+
|
|
3605
|
+
// Check each edge segment
|
|
3606
|
+
for (let i = 0; i < outerRing.length - 1; i++) {
|
|
3607
|
+
const p1 = outerRing[i];
|
|
3608
|
+
const p2 = outerRing[i + 1];
|
|
3609
|
+
|
|
3610
|
+
// Calculate distance from click point to edge segment
|
|
3611
|
+
const distance = this.distanceToLineSegment(
|
|
3612
|
+
[latlng.lng, latlng.lat],
|
|
3613
|
+
[p1[0], p1[1]],
|
|
3614
|
+
[p2[0], p2[1]]
|
|
3615
|
+
);
|
|
3616
|
+
|
|
3617
|
+
if (distance < minDistance) {
|
|
3618
|
+
minDistance = distance;
|
|
3619
|
+
closestEdgeIndex = i + 1; // Insert after point i
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
// For closed rings, also check the edge from last to first
|
|
3624
|
+
if (isClosedRing) {
|
|
3625
|
+
const p1 = outerRing[outerRing.length - 1];
|
|
3626
|
+
const p2 = outerRing[0];
|
|
3627
|
+
const distance = this.distanceToLineSegment(
|
|
3628
|
+
[latlng.lng, latlng.lat],
|
|
3629
|
+
[p1[0], p1[1]],
|
|
3630
|
+
[p2[0], p2[1]]
|
|
3631
|
+
);
|
|
3632
|
+
|
|
3633
|
+
if (distance < minDistance) {
|
|
3634
|
+
minDistance = distance;
|
|
3635
|
+
closestEdgeIndex = outerRing.length; // Insert at end (before closing coordinate)
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
// Insert the new coordinate
|
|
3640
|
+
if (this.isMultiPolygon) {
|
|
3641
|
+
newCoordinates[0][0].splice(closestEdgeIndex, 0, newCoord);
|
|
3642
|
+
// If it was a closed ring, update the closing coordinate
|
|
3643
|
+
if (isClosedRing) {
|
|
3644
|
+
newCoordinates[0][0][newCoordinates[0][0].length - 1] = newCoordinates[0][0][0];
|
|
3645
|
+
}
|
|
3646
|
+
} else {
|
|
3647
|
+
newCoordinates[0].splice(closestEdgeIndex, 0, newCoord);
|
|
3648
|
+
// If it was a closed ring, update the closing coordinate
|
|
3649
|
+
if (isClosedRing) {
|
|
3650
|
+
newCoordinates[0][newCoordinates[0].length - 1] = newCoordinates[0][0];
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
const newGeometry = {
|
|
3655
|
+
type: baseGeometry.type,
|
|
3656
|
+
coordinates: newCoordinates
|
|
3657
|
+
};
|
|
3658
|
+
|
|
3659
|
+
// Store the current edited geometry state
|
|
3660
|
+
this.currentEditedGeometry = newGeometry;
|
|
3661
|
+
this.hasUnsavedChanges = true;
|
|
3662
|
+
|
|
3663
|
+
// Update the polygon layer and recreate markers
|
|
3664
|
+
this.updatePolygonLayer(newGeometry);
|
|
3665
|
+
this.$nextTick(() => {
|
|
3666
|
+
this.updateMapLayer();
|
|
3667
|
+
});
|
|
3668
|
+
|
|
3669
|
+
// Notify parent of the change
|
|
3670
|
+
if (this.onChange) {
|
|
3671
|
+
this.onChange(newGeometry);
|
|
3672
|
+
}
|
|
3673
|
+
},
|
|
3674
|
+
distanceToLineSegment(point, lineStart, lineEnd) {
|
|
3675
|
+
// Calculate distance from point to line segment
|
|
3676
|
+
// point, lineStart, lineEnd are [lng, lat] arrays
|
|
3677
|
+
const A = point[0] - lineStart[0];
|
|
3678
|
+
const B = point[1] - lineStart[1];
|
|
3679
|
+
const C = lineEnd[0] - lineStart[0];
|
|
3680
|
+
const D = lineEnd[1] - lineStart[1];
|
|
3681
|
+
|
|
3682
|
+
const dot = A * C + B * D;
|
|
3683
|
+
const lenSq = C * C + D * D;
|
|
3684
|
+
let param = -1;
|
|
3685
|
+
|
|
3686
|
+
if (lenSq !== 0) {
|
|
3687
|
+
param = dot / lenSq;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
let xx, yy;
|
|
3691
|
+
|
|
3692
|
+
if (param < 0) {
|
|
3693
|
+
xx = lineStart[0];
|
|
3694
|
+
yy = lineStart[1];
|
|
3695
|
+
} else if (param > 1) {
|
|
3696
|
+
xx = lineEnd[0];
|
|
3697
|
+
yy = lineEnd[1];
|
|
3698
|
+
} else {
|
|
3699
|
+
xx = lineStart[0] + param * C;
|
|
3700
|
+
yy = lineStart[1] + param * D;
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
const dx = point[0] - xx;
|
|
3704
|
+
const dy = point[1] - yy;
|
|
3705
|
+
|
|
3706
|
+
// Use simple Euclidean distance (approximation for small areas)
|
|
3707
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
3708
|
+
},
|
|
3709
|
+
deleteVertex(index, marker) {
|
|
3710
|
+
// Get current geometry
|
|
3711
|
+
const baseGeometry = this.currentEditedGeometry || this.value;
|
|
3712
|
+
const newCoordinates = JSON.parse(JSON.stringify(baseGeometry.coordinates));
|
|
3713
|
+
|
|
3714
|
+
// Get the outer ring
|
|
3715
|
+
let outerRing = [];
|
|
3716
|
+
if (this.isMultiPolygon) {
|
|
3717
|
+
outerRing = newCoordinates[0][0] || [];
|
|
3718
|
+
} else {
|
|
3719
|
+
outerRing = newCoordinates[0] || [];
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
// Check if this is a closed ring
|
|
3723
|
+
const isClosedRing = outerRing.length > 0 &&
|
|
3724
|
+
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
|
|
3725
|
+
outerRing[0][1] === outerRing[outerRing.length - 1][1];
|
|
3726
|
+
|
|
3727
|
+
// Minimum vertices for a valid polygon (3 for triangle, but GeoJSON requires at least 4 for closed ring)
|
|
3728
|
+
const minVertices = isClosedRing ? 4 : 3;
|
|
3729
|
+
|
|
3730
|
+
// Don't allow deletion if we'd have too few vertices
|
|
3731
|
+
const currentVertexCount = isClosedRing ? outerRing.length - 1 : outerRing.length;
|
|
3732
|
+
if (currentVertexCount < minVertices) {
|
|
3733
|
+
const message = isClosedRing
|
|
3734
|
+
? `Cannot delete vertex. A polygon requires at least ${minVertices} vertices (including the closing vertex).`
|
|
3735
|
+
: `Cannot delete vertex. A polygon requires at least ${minVertices} vertices.`;
|
|
3736
|
+
this.$toast.error(message, {
|
|
3737
|
+
timeout: 5000
|
|
3738
|
+
});
|
|
3739
|
+
this.hideContextMenu();
|
|
3740
|
+
return; // Can't delete - would make invalid polygon
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
// Remove the coordinate
|
|
3744
|
+
if (this.isMultiPolygon) {
|
|
3745
|
+
newCoordinates[0][0].splice(index, 1);
|
|
3746
|
+
// If it was a closed ring and we removed a coordinate, update the closing coordinate
|
|
3747
|
+
if (isClosedRing && index === 0) {
|
|
3748
|
+
// If we deleted the first coordinate, the new first becomes the closing coordinate
|
|
3749
|
+
newCoordinates[0][0][newCoordinates[0][0].length - 1] = newCoordinates[0][0][0];
|
|
3750
|
+
} else if (isClosedRing && index === outerRing.length - 1) {
|
|
3751
|
+
// If we deleted the closing coordinate, update it to match the first
|
|
3752
|
+
newCoordinates[0][0][newCoordinates[0][0].length - 1] = newCoordinates[0][0][0];
|
|
3753
|
+
}
|
|
3754
|
+
} else {
|
|
3755
|
+
newCoordinates[0].splice(index, 1);
|
|
3756
|
+
// If it was a closed ring and we removed a coordinate, update the closing coordinate
|
|
3757
|
+
if (isClosedRing && index === 0) {
|
|
3758
|
+
// If we deleted the first coordinate, the new first becomes the closing coordinate
|
|
3759
|
+
newCoordinates[0][newCoordinates[0].length - 1] = newCoordinates[0][0];
|
|
3760
|
+
} else if (isClosedRing && index === outerRing.length - 1) {
|
|
3761
|
+
// If we deleted the closing coordinate, update it to match the first
|
|
3762
|
+
newCoordinates[0][newCoordinates[0].length - 1] = newCoordinates[0][0];
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
const newGeometry = {
|
|
3767
|
+
type: baseGeometry.type,
|
|
3768
|
+
coordinates: newCoordinates
|
|
3769
|
+
};
|
|
3770
|
+
|
|
3771
|
+
// Store the current edited geometry state
|
|
3772
|
+
this.currentEditedGeometry = newGeometry;
|
|
3773
|
+
this.hasUnsavedChanges = true;
|
|
3774
|
+
|
|
3775
|
+
// Remove all markers and force recreation (needed because marker closures capture indices)
|
|
3776
|
+
this.draggableMarkers.forEach(m => {
|
|
3777
|
+
if (m) m.remove();
|
|
3778
|
+
});
|
|
3779
|
+
this.draggableMarkers = [];
|
|
3780
|
+
|
|
3781
|
+
// Update the polygon layer and recreate markers
|
|
3782
|
+
this.updatePolygonLayer(newGeometry);
|
|
3783
|
+
this.$nextTick(() => {
|
|
3784
|
+
this.updateMapLayer();
|
|
3785
|
+
});
|
|
3786
|
+
|
|
3787
|
+
// Notify parent of the change
|
|
3788
|
+
if (this.onChange) {
|
|
3789
|
+
this.onChange(newGeometry);
|
|
3790
|
+
}
|
|
3791
|
+
},
|
|
3792
|
+
undoDelete() {
|
|
3793
|
+
// Restore the original geometry (undo all changes)
|
|
3794
|
+
if (!this.originalGeometry) {
|
|
3795
|
+
return;
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
const restoredGeometry = JSON.parse(JSON.stringify(this.originalGeometry));
|
|
3799
|
+
|
|
3800
|
+
// Clear all edited state
|
|
3801
|
+
this.currentEditedGeometry = null;
|
|
3802
|
+
this.hasUnsavedChanges = false;
|
|
3803
|
+
|
|
3804
|
+
// Update the map to show the original geometry
|
|
3805
|
+
if (this.isGeoJsonPoint) {
|
|
3806
|
+
this.$nextTick(() => {
|
|
3807
|
+
this.updateMapLayer();
|
|
3808
|
+
});
|
|
3809
|
+
} else if (this.isGeoJsonPolygon) {
|
|
3810
|
+
this.updatePolygonLayer(restoredGeometry);
|
|
3811
|
+
this.$nextTick(() => {
|
|
3812
|
+
this.updateMapLayer();
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
// Notify parent of the change (restore to original)
|
|
3817
|
+
if (this.onChange) {
|
|
3818
|
+
this.onChange(restoredGeometry);
|
|
3819
|
+
}
|
|
2752
3820
|
}
|
|
2753
3821
|
}
|
|
2754
3822
|
});
|
|
2755
3823
|
|
|
3824
|
+
|
|
2756
3825
|
/***/ },
|
|
2757
3826
|
|
|
2758
3827
|
/***/ "./frontend/src/document-details/document-details.css"
|
|
@@ -3289,7 +4358,7 @@ module.exports = ".document-details {\n width: 100%;\n }\n \n .document-de
|
|
|
3289
4358
|
(module) {
|
|
3290
4359
|
|
|
3291
4360
|
"use strict";
|
|
3292
|
-
module.exports = "<div class=\"border border-gray-200 bg-white rounded-lg mb-2\">\n <!-- Collapsible Header -->\n <div\n @click=\"toggleCollapse\"\n class=\"p-1 cursor-pointer flex items-center justify-between border-b border-gray-200 transition-colors duration-200 ease-in-out\"\n :class=\"{ 'bg-amber-100 hover:bg-amber-200': highlight, 'bg-slate-100 hover:bg-gray-100': !highlight }\"\n >\n <div class=\"flex items-center\" >\n <svg\n :class=\"isCollapsed ? 'rotate-0' : 'rotate-90'\"\n class=\"w-4 h-4 text-gray-500 mr-2 transition-transform duration-200\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>\n </svg>\n <span class=\"font-medium text-gray-900\">{{path.path}}</span>\n <span class=\"ml-2 text-sm text-gray-500\">({{(path.instance || 'unknown').toLowerCase()}})</span>\n </div>\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n class=\"flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded-md border border-transparent hover:border-gray-300 bg-white\"\n @click.stop.prevent=\"copyPropertyValue\"\n title=\"Copy value\"\n aria-label=\"Copy property value\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7h8m-8 4h8m-8 4h5m-7-9a2 2 0 012-2h7a2 2 0 012 2v10a2 2 0 01-2 2H8l-4-4V7a2 2 0 012-2z\" />\n </svg>\n {{copyButtonLabel}}\n </button>\n <router-link\n v-if=\"path.ref && getValueForPath(path.path)\"\n :to=\"`/model/${path.ref}/document/${getValueForPath(path.path)}`\"\n class=\"bg-ultramarine-600 hover:bg-ultramarine-500 text-white px-2 py-1 text-sm rounded-md\"\n @click.stop\n >View Document\n </router-link>\n </div>\n </div>\n\n <!-- Collapsible Content -->\n <div v-if=\"!isCollapsed\" class=\"p-2\">\n <!-- Date Type Selector (when editing dates) -->\n <div v-if=\"editting && path.instance === 'Date'\" class=\"mb-3 flex gap-1.5\">\n <div\n @click=\"dateType = 'picker'\"\n :class=\"dateType === 'picker' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'picker' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n Date Picker\n </div>\n </div>\n <div\n @click=\"dateType = 'iso'\"\n :class=\"dateType === 'iso' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'iso' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n ISO String\n </div>\n </div>\n </div>\n\n <!-- Field Content -->\n <div v-if=\"editting && path.path !== '_id'\">\n <component\n :is=\"getEditComponentForPath(path)\"\n :value=\"getEditValueForPath(path)\"\n :format=\"dateType\"\n v-bind=\"getEditComponentProps(path)\"\n @input=\"handleInputChange($event)\"\n @error=\"invalid[path.path] = $event;\"\n >\n </component>\n </div>\n <div v-else>\n <!-- Show truncated or full value based on needsTruncation and isValueExpanded -->\n <!-- Special handling for truncated arrays -->\n <div v-if=\"isArray && shouldShowTruncated\" class=\"w-full\">\n <div class=\"mt-2\">\n <div\n v-for=\"(item, index) in truncatedArrayItems\"\n :key=\"index\"\n class=\"mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative hover:bg-slate-50 hover:border-l-blue-600\">\n <div class=\"absolute -left-2 top-1/2 -translate-y-1/2 w-5 h-5 bg-blue-500 text-white rounded-full flex items-center justify-center text-[10px] font-semibold font-mono z-10 hover:bg-blue-600\">{{ index }}</div>\n <div v-if=\"arrayUtils.isObjectItem(item)\" class=\"flex flex-col gap-1 mt-1 px-2\">\n <div\n v-for=\"key in arrayUtils.getItemKeys(item)\"\n :key=\"key\"\n class=\"flex items-start gap-2 text-xs font-mono\">\n <span class=\"font-semibold text-gray-600 flex-shrink-0 min-w-[80px]\">{{ key }}:</span>\n <span class=\"text-gray-800 break-words whitespace-pre-wrap flex-1\">{{ arrayUtils.formatItemValue(item, key) }}</span>\n </div>\n </div>\n <div v-else class=\"text-xs py-1.5 px-2 font-mono text-gray-800 break-words whitespace-pre-wrap mt-1\">{{ arrayUtils.formatValue(item) }}</div>\n </div>\n <div class=\"mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-none border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative opacity-70 hover:opacity-100\">\n <div class=\"text-xs py-1.5 px-2 font-mono text-gray-500 italic break-words whitespace-pre-wrap mt-1\">\n ... and {{ remainingArrayCount }} more item{{ remainingArrayCount !== 1 ? 's' : '' }}\n </div>\n </div>\n </div>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show all {{ arrayValue.length }} items\n </button>\n </div>\n <!-- Non-array truncated view -->\n <div v-else-if=\"shouldShowTruncated && !isArray\" class=\"relative\">\n <div class=\"text-gray-700 whitespace-pre-wrap break-words font-mono text-sm\">{{truncatedString}}</div>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show more ({{valueAsString.length}} characters)\n </button>\n </div>\n <!-- Expanded view -->\n <div v-else-if=\"needsTruncation && isValueExpanded\" class=\"relative\">\n <component :is=\"getComponentForPath(path)\" :value=\"getValueForPath(path.path)\"></component>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4 rotate-180\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show less\n </button>\n </div>\n <!-- Full view (no truncation needed) -->\n <div v-else>\n <component :is=\"getComponentForPath(path)\" :value=\"getValueForPath(path.path)\"></component>\n </div>\n </div>\n </div>\n</div>\n";
|
|
4361
|
+
module.exports = "<div class=\"border border-gray-200 bg-white rounded-lg mb-2\" style=\"overflow: visible;\">\n <!-- Collapsible Header -->\n <div\n @click=\"toggleCollapse\"\n class=\"p-1 cursor-pointer flex items-center justify-between border-b border-gray-200 transition-colors duration-200 ease-in-out\"\n :class=\"{ 'bg-amber-100 hover:bg-amber-200': highlight, 'bg-slate-100 hover:bg-gray-100': !highlight }\"\n style=\"overflow: visible; position: relative;\"\n >\n <div class=\"flex items-center\" >\n <svg\n :class=\"isCollapsed ? 'rotate-0' : 'rotate-90'\"\n class=\"w-4 h-4 text-gray-500 mr-2 transition-transform duration-200\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>\n </svg>\n <span class=\"font-medium text-gray-900\">{{path.path}}</span>\n <span class=\"ml-2 text-sm text-gray-500\">({{(path.instance || 'unknown').toLowerCase()}})</span>\n <div v-if=\"isGeoJsonGeometry\" class=\"ml-3 inline-flex items-center gap-2\">\n <div class=\"inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold\">\n <button\n type=\"button\"\n class=\"rounded-full px-2.5 py-0.5 transition\"\n :class=\"detailViewMode === 'text' ? 'bg-blue-600 text-white shadow' : 'text-gray-700 hover:text-gray-900'\"\n :style=\"detailViewMode === 'text' ? 'color: white !important; background-color: #2563eb !important;' : ''\"\n @click.stop=\"setDetailViewMode('text')\">\n Text\n </button>\n <button\n type=\"button\"\n class=\"rounded-full px-2.5 py-0.5 transition\"\n :class=\"detailViewMode === 'map' ? 'bg-blue-600 text-white shadow' : 'text-gray-700 hover:text-gray-900'\"\n :style=\"detailViewMode === 'map' ? 'color: white !important; background-color: #2563eb !important;' : ''\"\n @click.stop=\"setDetailViewMode('map')\">\n Map\n </button>\n </div>\n <!-- Info icon with tooltip -->\n <div v-if=\"editting\" class=\"relative inline-block\" style=\"z-index: 10002;\" @mouseenter=\"showTooltip = true\" @mouseleave=\"showTooltip = false\" @click.stop>\n <svg\n ref=\"infoIcon\"\n class=\"w-6 h-6 text-gray-400 hover:text-gray-600 cursor-help\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n </svg>\n <div\n v-show=\"showTooltip\"\n ref=\"tooltip\"\n class=\"absolute left-full top-0 ml-2 w-64 p-3 text-white text-xs rounded-lg shadow-xl\"\n style=\"z-index: 99999; pointer-events: none; white-space: normal; position: fixed; background-color: #111827;\"\n :style=\"getTooltipStyle()\"\n >\n <div class=\"font-semibold mb-2\">Map Controls:</div>\n <div v-if=\"isGeoJsonPoint\" class=\"space-y-1\">\n <div>• Drag pin to move location</div>\n </div>\n <div v-else-if=\"isGeoJsonPolygon\" class=\"space-y-1\">\n <div>• Drag vertices to reshape polygon</div>\n <div v-if=\"isMultiPolygon\">• Right-click edge to add new vertex</div>\n <div>• Right-click vertex to delete</div>\n </div>\n <div class=\"absolute top-2 -left-1 w-0 h-0 border-t-4 border-b-4 border-r-4 border-transparent border-r-gray-900\"></div>\n </div>\n </div>\n </div>\n </div>\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n class=\"flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded-md border border-transparent hover:border-gray-300 bg-white\"\n @click.stop.prevent=\"copyPropertyValue\"\n title=\"Copy value\"\n aria-label=\"Copy property value\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7h8m-8 4h8m-8 4h5m-7-9a2 2 0 012-2h7a2 2 0 012 2v10a2 2 0 01-2 2H8l-4-4V7a2 2 0 012-2z\" />\n </svg>\n {{copyButtonLabel}}\n </button>\n <router-link\n v-if=\"path.ref && getValueForPath(path.path)\"\n :to=\"`/model/${path.ref}/document/${getValueForPath(path.path)}`\"\n class=\"bg-ultramarine-600 hover:bg-ultramarine-500 text-white px-2 py-1 text-sm rounded-md\"\n @click.stop\n >View Document\n </router-link>\n </div>\n </div>\n\n <!-- Collapsible Content -->\n <div v-if=\"!isCollapsed\" class=\"p-2\">\n <!-- Date Type Selector (when editing dates) -->\n <div v-if=\"editting && path.instance === 'Date'\" class=\"mb-3 flex gap-1.5\">\n <div\n @click=\"dateType = 'picker'\"\n :class=\"dateType === 'picker' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'picker' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n Date Picker\n </div>\n </div>\n <div\n @click=\"dateType = 'iso'\"\n :class=\"dateType === 'iso' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'iso' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n ISO String\n </div>\n </div>\n </div>\n\n <!-- Field Content -->\n <div v-if=\"editting && path.path !== '_id'\">\n <!-- Use detail-default with map editing for GeoJSON geometries -->\n <component\n v-if=\"isGeoJsonGeometry\"\n :is=\"getComponentForPath(path)\"\n :value=\"getEditValueForPath(path)\"\n :view-mode=\"detailViewMode\"\n :on-change=\"handleInputChange\"\n >\n </component>\n <!-- Use standard edit components for other types -->\n <component\n v-else\n :is=\"getEditComponentForPath(path)\"\n :value=\"getEditValueForPath(path)\"\n :format=\"dateType\"\n v-bind=\"getEditComponentProps(path)\"\n @input=\"handleInputChange($event)\"\n @error=\"invalid[path.path] = $event;\"\n >\n </component>\n </div>\n <div v-else>\n <!-- Show truncated or full value based on needsTruncation and isValueExpanded -->\n <!-- Special handling for truncated arrays -->\n <div v-if=\"isArray && shouldShowTruncated\" class=\"w-full\">\n <div class=\"mt-2\">\n <div\n v-for=\"(item, index) in truncatedArrayItems\"\n :key=\"index\"\n class=\"mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative hover:bg-slate-50 hover:border-l-blue-600\">\n <div class=\"absolute -left-2 top-1/2 -translate-y-1/2 w-5 h-5 bg-blue-500 text-white rounded-full flex items-center justify-center text-[10px] font-semibold font-mono z-10 hover:bg-blue-600\">{{ index }}</div>\n <div v-if=\"arrayUtils.isObjectItem(item)\" class=\"flex flex-col gap-1 mt-1 px-2\">\n <div\n v-for=\"key in arrayUtils.getItemKeys(item)\"\n :key=\"key\"\n class=\"flex items-start gap-2 text-xs font-mono\">\n <span class=\"font-semibold text-gray-600 flex-shrink-0 min-w-[80px]\">{{ key }}:</span>\n <span class=\"text-gray-800 break-words whitespace-pre-wrap flex-1\">{{ arrayUtils.formatItemValue(item, key) }}</span>\n </div>\n </div>\n <div v-else class=\"text-xs py-1.5 px-2 font-mono text-gray-800 break-words whitespace-pre-wrap mt-1\">{{ arrayUtils.formatValue(item) }}</div>\n </div>\n <div class=\"mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-none border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative opacity-70 hover:opacity-100\">\n <div class=\"text-xs py-1.5 px-2 font-mono text-gray-500 italic break-words whitespace-pre-wrap mt-1\">\n ... and {{ remainingArrayCount }} more item{{ remainingArrayCount !== 1 ? 's' : '' }}\n </div>\n </div>\n </div>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show all {{ arrayValue.length }} items\n </button>\n </div>\n <!-- Non-array truncated view -->\n <div v-else-if=\"shouldShowTruncated && !isArray\" class=\"relative\">\n <div class=\"text-gray-700 whitespace-pre-wrap break-words font-mono text-sm\">{{truncatedString}}</div>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show more ({{valueAsString.length}} characters)\n </button>\n </div>\n <!-- Expanded view -->\n <div v-else-if=\"needsTruncation && isValueExpanded\" class=\"relative\">\n <component\n :is=\"getComponentForPath(path)\"\n :value=\"getValueForPath(path.path)\"\n :view-mode=\"detailViewMode\"></component>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4 rotate-180\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show less\n </button>\n </div>\n <!-- Full view (no truncation needed) -->\n <div v-else>\n <component\n :is=\"getComponentForPath(path)\"\n :value=\"getValueForPath(path.path)\"\n :view-mode=\"detailViewMode\"></component>\n </div>\n </div>\n </div>\n</div>\n";
|
|
3293
4362
|
|
|
3294
4363
|
/***/ },
|
|
3295
4364
|
|
|
@@ -3319,8 +4388,10 @@ module.exports = app => app.component('document-property', {
|
|
|
3319
4388
|
dateType: 'picker', // picker, iso
|
|
3320
4389
|
isCollapsed: false, // Start uncollapsed by default
|
|
3321
4390
|
isValueExpanded: false, // Track if the value is expanded
|
|
4391
|
+
detailViewMode: 'text',
|
|
3322
4392
|
copyButtonLabel: 'Copy',
|
|
3323
|
-
copyResetTimeoutId: null
|
|
4393
|
+
copyResetTimeoutId: null,
|
|
4394
|
+
showTooltip: false
|
|
3324
4395
|
};
|
|
3325
4396
|
},
|
|
3326
4397
|
beforeDestroy() {
|
|
@@ -3394,9 +4465,58 @@ module.exports = app => app.component('document-property', {
|
|
|
3394
4465
|
return this.arrayValue.length - 2;
|
|
3395
4466
|
}
|
|
3396
4467
|
return 0;
|
|
4468
|
+
},
|
|
4469
|
+
isGeoJsonGeometry() {
|
|
4470
|
+
const value = this.getValueForPath(this.path.path);
|
|
4471
|
+
return value != null
|
|
4472
|
+
&& typeof value === 'object'
|
|
4473
|
+
&& !Array.isArray(value)
|
|
4474
|
+
&& Object.prototype.hasOwnProperty.call(value, 'type')
|
|
4475
|
+
&& Object.prototype.hasOwnProperty.call(value, 'coordinates');
|
|
4476
|
+
},
|
|
4477
|
+
isGeoJsonPoint() {
|
|
4478
|
+
const value = this.getValueForPath(this.path.path);
|
|
4479
|
+
return this.isGeoJsonGeometry && value.type === 'Point';
|
|
4480
|
+
},
|
|
4481
|
+
isGeoJsonPolygon() {
|
|
4482
|
+
const value = this.getValueForPath(this.path.path);
|
|
4483
|
+
return this.isGeoJsonGeometry && (value.type === 'Polygon' || value.type === 'MultiPolygon');
|
|
4484
|
+
},
|
|
4485
|
+
isMultiPolygon() {
|
|
4486
|
+
const value = this.getValueForPath(this.path.path);
|
|
4487
|
+
return this.isGeoJsonGeometry && value.type === 'MultiPolygon';
|
|
4488
|
+
}
|
|
4489
|
+
},
|
|
4490
|
+
watch: {
|
|
4491
|
+
isGeoJsonGeometry(newValue) {
|
|
4492
|
+
if (!newValue) {
|
|
4493
|
+
this.detailViewMode = 'text';
|
|
4494
|
+
} else if (this.editting) {
|
|
4495
|
+
// Default to map view when editing GeoJSON
|
|
4496
|
+
this.detailViewMode = 'map';
|
|
4497
|
+
}
|
|
4498
|
+
},
|
|
4499
|
+
editting(newValue) {
|
|
4500
|
+
// When entering edit mode for GeoJSON, default to map view
|
|
4501
|
+
if (newValue && this.isGeoJsonGeometry) {
|
|
4502
|
+
this.detailViewMode = 'map';
|
|
4503
|
+
}
|
|
3397
4504
|
}
|
|
3398
4505
|
},
|
|
3399
4506
|
methods: {
|
|
4507
|
+
setDetailViewMode(mode) {
|
|
4508
|
+
this.detailViewMode = mode;
|
|
4509
|
+
|
|
4510
|
+
// When switching to map view, expand the container and value so the map is visible
|
|
4511
|
+
if (mode === 'map' && this.isGeoJsonGeometry) {
|
|
4512
|
+
if (this.isCollapsed) {
|
|
4513
|
+
this.isCollapsed = false;
|
|
4514
|
+
}
|
|
4515
|
+
if (this.needsTruncation && !this.isValueExpanded) {
|
|
4516
|
+
this.isValueExpanded = true;
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
4519
|
+
},
|
|
3400
4520
|
handleInputChange(newValue) {
|
|
3401
4521
|
const currentValue = this.getValueForPath(this.path.path);
|
|
3402
4522
|
|
|
@@ -3467,6 +4587,11 @@ module.exports = app => app.component('document-property', {
|
|
|
3467
4587
|
if (!this.document) {
|
|
3468
4588
|
return;
|
|
3469
4589
|
}
|
|
4590
|
+
// If there are unsaved changes for this path, use the changed value
|
|
4591
|
+
if (Object.prototype.hasOwnProperty.call(this.changes, path)) {
|
|
4592
|
+
return this.changes[path];
|
|
4593
|
+
}
|
|
4594
|
+
// Otherwise, use the document value
|
|
3470
4595
|
const documentValue = mpath.get(path, this.document);
|
|
3471
4596
|
return documentValue;
|
|
3472
4597
|
},
|
|
@@ -3486,6 +4611,16 @@ module.exports = app => app.component('document-property', {
|
|
|
3486
4611
|
this.copyResetTimeoutId = null;
|
|
3487
4612
|
}, 5000);
|
|
3488
4613
|
},
|
|
4614
|
+
getTooltipStyle() {
|
|
4615
|
+
if (!this.$refs.infoIcon || !this.showTooltip) {
|
|
4616
|
+
return {};
|
|
4617
|
+
}
|
|
4618
|
+
const rect = this.$refs.infoIcon.getBoundingClientRect();
|
|
4619
|
+
return {
|
|
4620
|
+
left: (rect.right + 8) + 'px',
|
|
4621
|
+
top: rect.top + 'px'
|
|
4622
|
+
};
|
|
4623
|
+
},
|
|
3489
4624
|
copyPropertyValue() {
|
|
3490
4625
|
const textToCopy = this.valueAsString;
|
|
3491
4626
|
if (textToCopy == null) {
|
|
@@ -32454,7 +33589,7 @@ const compile = () => {
|
|
|
32454
33589
|
(module) {
|
|
32455
33590
|
|
|
32456
33591
|
"use strict";
|
|
32457
|
-
module.exports = /*#__PURE__*/JSON.parse('{"name":"@mongoosejs/studio","version":"0.2.
|
|
33592
|
+
module.exports = /*#__PURE__*/JSON.parse('{"name":"@mongoosejs/studio","version":"0.2.6","description":"A Mongoose-native MongoDB UI with schema-aware autocomplete, AI-assisted queries, and dashboards that understand your models - not just your data.","homepage":"https://mongoosestudio.app/","repository":{"type":"git","url":"https://github.com/mongoosejs/studio"},"license":"Apache-2.0","dependencies":{"@ai-sdk/anthropic":"2.x","@ai-sdk/google":"2.x","@ai-sdk/openai":"2.x","ai":"5.x","archetype":"0.13.1","csv-stringify":"6.3.0","ejson":"^2.2.3","extrovert":"^0.2.0","marked":"15.0.12","node-inspect-extracted":"3.x","tailwindcss":"3.4.0","vue":"3.x","vue-toastification":"^2.0.0-rc.5","webpack":"5.x"},"peerDependencies":{"mongoose":"7.x || 8.x || ^9.0.0"},"devDependencies":{"@masteringjs/eslint-config":"0.1.1","axios":"1.2.2","dedent":"^1.6.0","eslint":"9.30.0","express":"4.x","mocha":"10.2.0","mongoose":"9.x","sinon":"^21.0.1"},"scripts":{"lint":"eslint .","tailwind":"tailwindcss -o ./frontend/public/tw.css","tailwind:watch":"tailwindcss -o ./frontend/public/tw.css --watch","test":"mocha test/*.test.js","test:frontend":"mocha test/frontend/*.test.js"}}');
|
|
32458
33593
|
|
|
32459
33594
|
/***/ }
|
|
32460
33595
|
|