@mongoosejs/studio 0.2.4 → 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 +1147 -8
- package/frontend/public/tw.css +72 -0
- package/frontend/src/chat/chat.js +11 -3
- 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
|
@@ -1488,7 +1488,7 @@ module.exports = "<div class=\"flex\" style=\"height: calc(100vh - 55px); height
|
|
|
1488
1488
|
const api = __webpack_require__(/*! ../api */ "./frontend/src/api.js");
|
|
1489
1489
|
const template = __webpack_require__(/*! ./chat.html */ "./frontend/src/chat/chat.html");
|
|
1490
1490
|
|
|
1491
|
-
module.exports =
|
|
1491
|
+
module.exports = {
|
|
1492
1492
|
template: template,
|
|
1493
1493
|
props: ['threadId'],
|
|
1494
1494
|
data: () => ({
|
|
@@ -1515,7 +1515,9 @@ module.exports = app => app.component('chat', {
|
|
|
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,10 +1535,15 @@ module.exports = app => app.component('chat', {
|
|
|
1533
1535
|
if (event.chatMessage) {
|
|
1534
1536
|
if (!userChatMessage) {
|
|
1535
1537
|
userChatMessage = event.chatMessage;
|
|
1538
|
+
this.chatMessages.splice(userChatMessageIndex, 1, userChatMessage);
|
|
1536
1539
|
} else {
|
|
1537
1540
|
const assistantChatMessageIndex = this.chatMessages.indexOf(assistantChatMessage);
|
|
1538
1541
|
assistantChatMessage = event.chatMessage;
|
|
1539
|
-
|
|
1542
|
+
if (assistantChatMessageIndex !== -1) {
|
|
1543
|
+
this.chatMessages.splice(assistantChatMessageIndex, 1, assistantChatMessage);
|
|
1544
|
+
} else {
|
|
1545
|
+
this.chatMessages.push(assistantChatMessage);
|
|
1546
|
+
}
|
|
1540
1547
|
}
|
|
1541
1548
|
} else if (event.chatThread) {
|
|
1542
1549
|
for (const thread of this.chatThreads) {
|
|
@@ -1547,6 +1554,7 @@ module.exports = app => app.component('chat', {
|
|
|
1547
1554
|
} else if (event.textPart) {
|
|
1548
1555
|
if (!assistantChatMessage) {
|
|
1549
1556
|
assistantChatMessage = {
|
|
1557
|
+
_id: Math.random().toString(36).substr(2, 9),
|
|
1550
1558
|
content: event.textPart,
|
|
1551
1559
|
role: 'assistant'
|
|
1552
1560
|
};
|
|
@@ -1669,7 +1677,7 @@ module.exports = app => app.component('chat', {
|
|
|
1669
1677
|
|
|
1670
1678
|
this.$refs.messageInput.focus();
|
|
1671
1679
|
}
|
|
1672
|
-
}
|
|
1680
|
+
};
|
|
1673
1681
|
|
|
1674
1682
|
|
|
1675
1683
|
/***/ },
|
|
@@ -2709,7 +2717,7 @@ module.exports = app => app.component('detail-array', {
|
|
|
2709
2717
|
(module) {
|
|
2710
2718
|
|
|
2711
2719
|
"use strict";
|
|
2712
|
-
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";
|
|
2713
2721
|
|
|
2714
2722
|
/***/ },
|
|
2715
2723
|
|
|
@@ -2722,10 +2730,63 @@ module.exports = "<div class=\"w-full\">\n <pre class=\"w-full whitespace-pre-w
|
|
|
2722
2730
|
"use strict";
|
|
2723
2731
|
|
|
2724
2732
|
|
|
2733
|
+
/* global L */
|
|
2725
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
|
+
|
|
2726
2772
|
module.exports = app => app.component('detail-default', {
|
|
2727
2773
|
template: template,
|
|
2728
|
-
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
|
+
},
|
|
2729
2790
|
computed: {
|
|
2730
2791
|
displayValue() {
|
|
2731
2792
|
if (this.value === null) {
|
|
@@ -2745,10 +2806,1022 @@ module.exports = app => app.component('detail-default', {
|
|
|
2745
2806
|
} catch (err) {
|
|
2746
2807
|
return String(this.value);
|
|
2747
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
|
+
}
|
|
2748
3820
|
}
|
|
2749
3821
|
}
|
|
2750
3822
|
});
|
|
2751
3823
|
|
|
3824
|
+
|
|
2752
3825
|
/***/ },
|
|
2753
3826
|
|
|
2754
3827
|
/***/ "./frontend/src/document-details/document-details.css"
|
|
@@ -3285,7 +4358,7 @@ module.exports = ".document-details {\n width: 100%;\n }\n \n .document-de
|
|
|
3285
4358
|
(module) {
|
|
3286
4359
|
|
|
3287
4360
|
"use strict";
|
|
3288
|
-
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";
|
|
3289
4362
|
|
|
3290
4363
|
/***/ },
|
|
3291
4364
|
|
|
@@ -3315,8 +4388,10 @@ module.exports = app => app.component('document-property', {
|
|
|
3315
4388
|
dateType: 'picker', // picker, iso
|
|
3316
4389
|
isCollapsed: false, // Start uncollapsed by default
|
|
3317
4390
|
isValueExpanded: false, // Track if the value is expanded
|
|
4391
|
+
detailViewMode: 'text',
|
|
3318
4392
|
copyButtonLabel: 'Copy',
|
|
3319
|
-
copyResetTimeoutId: null
|
|
4393
|
+
copyResetTimeoutId: null,
|
|
4394
|
+
showTooltip: false
|
|
3320
4395
|
};
|
|
3321
4396
|
},
|
|
3322
4397
|
beforeDestroy() {
|
|
@@ -3390,9 +4465,58 @@ module.exports = app => app.component('document-property', {
|
|
|
3390
4465
|
return this.arrayValue.length - 2;
|
|
3391
4466
|
}
|
|
3392
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
|
+
}
|
|
3393
4504
|
}
|
|
3394
4505
|
},
|
|
3395
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
|
+
},
|
|
3396
4520
|
handleInputChange(newValue) {
|
|
3397
4521
|
const currentValue = this.getValueForPath(this.path.path);
|
|
3398
4522
|
|
|
@@ -3463,6 +4587,11 @@ module.exports = app => app.component('document-property', {
|
|
|
3463
4587
|
if (!this.document) {
|
|
3464
4588
|
return;
|
|
3465
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
|
|
3466
4595
|
const documentValue = mpath.get(path, this.document);
|
|
3467
4596
|
return documentValue;
|
|
3468
4597
|
},
|
|
@@ -3482,6 +4611,16 @@ module.exports = app => app.component('document-property', {
|
|
|
3482
4611
|
this.copyResetTimeoutId = null;
|
|
3483
4612
|
}, 5000);
|
|
3484
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
|
+
},
|
|
3485
4624
|
copyPropertyValue() {
|
|
3486
4625
|
const textToCopy = this.valueAsString;
|
|
3487
4626
|
if (textToCopy == null) {
|
|
@@ -32450,7 +33589,7 @@ const compile = () => {
|
|
|
32450
33589
|
(module) {
|
|
32451
33590
|
|
|
32452
33591
|
"use strict";
|
|
32453
|
-
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"}}');
|
|
32454
33593
|
|
|
32455
33594
|
/***/ }
|
|
32456
33595
|
|