@softwarity/geojson-editor 1.0.13 → 1.0.15
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/README.md +98 -2
- package/dist/geojson-editor.js +2 -2
- package/package.json +1 -1
- package/src/geojson-editor.css +1 -69
- package/src/geojson-editor.js +718 -391
package/src/geojson-editor.js
CHANGED
|
@@ -8,6 +8,29 @@ const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'dev';
|
|
|
8
8
|
const GEOJSON_KEYS = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
|
|
9
9
|
const GEOMETRY_TYPES = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
|
|
10
10
|
|
|
11
|
+
// Pre-compiled regex patterns for performance (avoid re-creation on each call)
|
|
12
|
+
const RE_CONTEXT_GEOMETRY = /"geometry"\s*:/;
|
|
13
|
+
const RE_CONTEXT_PROPERTIES = /"properties"\s*:/;
|
|
14
|
+
const RE_CONTEXT_FEATURES = /"features"\s*:/;
|
|
15
|
+
const RE_COLLAPSED_BRACKET = /^(\s*"[^"]+"\s*:\s*)([{\[])/;
|
|
16
|
+
const RE_COLLAPSED_ROOT = /^(\s*)([{\[]),?\s*$/;
|
|
17
|
+
const RE_ESCAPE_AMP = /&/g;
|
|
18
|
+
const RE_ESCAPE_LT = /</g;
|
|
19
|
+
const RE_ESCAPE_GT = />/g;
|
|
20
|
+
const RE_PUNCTUATION = /([{}[\],:])/g;
|
|
21
|
+
const RE_JSON_KEYS = /"([^"]+)"(<span class="json-punctuation">:<\/span>)/g;
|
|
22
|
+
const RE_TYPE_VALUES = /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>(\s*)"([^"]*)"/g;
|
|
23
|
+
const RE_STRING_VALUES = /(<span class="json-punctuation">:<\/span>)(\s*)"([^"]*)"/g;
|
|
24
|
+
const RE_COLOR_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
25
|
+
const RE_NUMBERS_COLON = /(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
|
|
26
|
+
const RE_NUMBERS_ARRAY = /(<span class="json-punctuation">[\[,]<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
|
|
27
|
+
const RE_NUMBERS_START = /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim;
|
|
28
|
+
const RE_BOOLEANS = /(<span class="json-punctuation">:<\/span>)(\s*)(true|false)/g;
|
|
29
|
+
const RE_NULL = /(<span class="json-punctuation">:<\/span>)(\s*)(null)/g;
|
|
30
|
+
const RE_UNRECOGNIZED = /(<\/span>|^)([^<]+)(<span|$)/g;
|
|
31
|
+
const RE_WHITESPACE_ONLY = /^\s*$/;
|
|
32
|
+
const RE_WHITESPACE_SPLIT = /(\s+)/;
|
|
33
|
+
|
|
11
34
|
/**
|
|
12
35
|
* GeoJSON Editor Web Component
|
|
13
36
|
* Monaco-like architecture with virtualized line rendering
|
|
@@ -33,7 +56,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
33
56
|
this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
|
|
34
57
|
|
|
35
58
|
// ========== View State ==========
|
|
36
|
-
this.scrollTop = 0;
|
|
37
59
|
this.viewportHeight = 0;
|
|
38
60
|
this.lineHeight = 19.5; // CSS: line-height * font-size = 1.5 * 13px
|
|
39
61
|
this.bufferLines = 5; // Extra lines to render above/below viewport
|
|
@@ -56,6 +78,148 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
56
78
|
|
|
57
79
|
// ========== Theme ==========
|
|
58
80
|
this.themes = { dark: {}, light: {} };
|
|
81
|
+
|
|
82
|
+
// ========== Undo/Redo History ==========
|
|
83
|
+
this._undoStack = []; // Stack of previous states
|
|
84
|
+
this._redoStack = []; // Stack of undone states
|
|
85
|
+
this._maxHistorySize = 100; // Maximum history entries
|
|
86
|
+
this._lastActionTime = 0; // Timestamp of last action (for grouping)
|
|
87
|
+
this._lastActionType = null; // Type of last action (for grouping)
|
|
88
|
+
this._groupingDelay = 500; // ms - actions within this delay are grouped
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ========== Render Cache ==========
|
|
92
|
+
_invalidateRenderCache() {
|
|
93
|
+
this._lastStartIndex = -1;
|
|
94
|
+
this._lastEndIndex = -1;
|
|
95
|
+
this._lastTotalLines = -1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ========== Undo/Redo System ==========
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a snapshot of current editor state
|
|
102
|
+
* @returns {Object} State snapshot
|
|
103
|
+
*/
|
|
104
|
+
_createSnapshot() {
|
|
105
|
+
return {
|
|
106
|
+
lines: [...this.lines],
|
|
107
|
+
cursorLine: this.cursorLine,
|
|
108
|
+
cursorColumn: this.cursorColumn,
|
|
109
|
+
timestamp: Date.now()
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Restore editor state from snapshot
|
|
115
|
+
* @param {Object} snapshot - State to restore
|
|
116
|
+
*/
|
|
117
|
+
_restoreSnapshot(snapshot) {
|
|
118
|
+
this.lines = [...snapshot.lines];
|
|
119
|
+
this.cursorLine = snapshot.cursorLine;
|
|
120
|
+
this.cursorColumn = snapshot.cursorColumn;
|
|
121
|
+
this.updateModel();
|
|
122
|
+
this._invalidateRenderCache();
|
|
123
|
+
this.scheduleRender();
|
|
124
|
+
this.updatePlaceholderVisibility();
|
|
125
|
+
this.emitChange();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Save current state to undo stack before making changes
|
|
130
|
+
* @param {string} actionType - Type of action (insert, delete, paste, etc.)
|
|
131
|
+
*/
|
|
132
|
+
_saveToHistory(actionType = 'edit') {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const shouldGroup = (
|
|
135
|
+
actionType === this._lastActionType &&
|
|
136
|
+
(now - this._lastActionTime) < this._groupingDelay
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// If same action type within grouping delay, don't create new entry
|
|
140
|
+
if (!shouldGroup) {
|
|
141
|
+
const snapshot = this._createSnapshot();
|
|
142
|
+
this._undoStack.push(snapshot);
|
|
143
|
+
|
|
144
|
+
// Limit stack size
|
|
145
|
+
if (this._undoStack.length > this._maxHistorySize) {
|
|
146
|
+
this._undoStack.shift();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clear redo stack on new action
|
|
150
|
+
this._redoStack = [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._lastActionTime = now;
|
|
154
|
+
this._lastActionType = actionType;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Undo last action
|
|
159
|
+
* @returns {boolean} True if undo was performed
|
|
160
|
+
*/
|
|
161
|
+
undo() {
|
|
162
|
+
if (this._undoStack.length === 0) return false;
|
|
163
|
+
|
|
164
|
+
// Save current state to redo stack
|
|
165
|
+
this._redoStack.push(this._createSnapshot());
|
|
166
|
+
|
|
167
|
+
// Restore previous state
|
|
168
|
+
const previousState = this._undoStack.pop();
|
|
169
|
+
this._restoreSnapshot(previousState);
|
|
170
|
+
|
|
171
|
+
// Reset action tracking
|
|
172
|
+
this._lastActionType = null;
|
|
173
|
+
this._lastActionTime = 0;
|
|
174
|
+
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Redo previously undone action
|
|
180
|
+
* @returns {boolean} True if redo was performed
|
|
181
|
+
*/
|
|
182
|
+
redo() {
|
|
183
|
+
if (this._redoStack.length === 0) return false;
|
|
184
|
+
|
|
185
|
+
// Save current state to undo stack
|
|
186
|
+
this._undoStack.push(this._createSnapshot());
|
|
187
|
+
|
|
188
|
+
// Restore next state
|
|
189
|
+
const nextState = this._redoStack.pop();
|
|
190
|
+
this._restoreSnapshot(nextState);
|
|
191
|
+
|
|
192
|
+
// Reset action tracking
|
|
193
|
+
this._lastActionType = null;
|
|
194
|
+
this._lastActionTime = 0;
|
|
195
|
+
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Clear undo/redo history
|
|
201
|
+
*/
|
|
202
|
+
clearHistory() {
|
|
203
|
+
this._undoStack = [];
|
|
204
|
+
this._redoStack = [];
|
|
205
|
+
this._lastActionType = null;
|
|
206
|
+
this._lastActionTime = 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if undo is available
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
canUndo() {
|
|
214
|
+
return this._undoStack.length > 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check if redo is available
|
|
219
|
+
* @returns {boolean}
|
|
220
|
+
*/
|
|
221
|
+
canRedo() {
|
|
222
|
+
return this._redoStack.length > 0;
|
|
59
223
|
}
|
|
60
224
|
|
|
61
225
|
// ========== Unique ID Generation ==========
|
|
@@ -162,14 +326,16 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
162
326
|
* @param {Object} range - The range info {startLine, endLine}
|
|
163
327
|
*/
|
|
164
328
|
_deleteCollapsedNode(range) {
|
|
329
|
+
this._saveToHistory('delete');
|
|
330
|
+
|
|
165
331
|
// Remove all lines from startLine to endLine
|
|
166
332
|
const count = range.endLine - range.startLine + 1;
|
|
167
333
|
this.lines.splice(range.startLine, count);
|
|
168
|
-
|
|
334
|
+
|
|
169
335
|
// Position cursor at the line where the node was
|
|
170
336
|
this.cursorLine = Math.min(range.startLine, this.lines.length - 1);
|
|
171
337
|
this.cursorColumn = 0;
|
|
172
|
-
|
|
338
|
+
|
|
173
339
|
this.formatAndUpdate();
|
|
174
340
|
}
|
|
175
341
|
|
|
@@ -350,17 +516,21 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
350
516
|
const rect = lineEl.getBoundingClientRect();
|
|
351
517
|
const clickX = e.clientX - rect.left;
|
|
352
518
|
if (clickX < 14) {
|
|
519
|
+
// Block render until click is processed to prevent DOM destruction
|
|
520
|
+
this._blockRender = true;
|
|
353
521
|
return;
|
|
354
522
|
}
|
|
355
523
|
}
|
|
356
|
-
|
|
524
|
+
|
|
357
525
|
// Skip if clicking on an inline control pseudo-element (positioned with negative left)
|
|
358
|
-
if (e.target.classList.contains('json-color') ||
|
|
526
|
+
if (e.target.classList.contains('json-color') ||
|
|
359
527
|
e.target.classList.contains('json-boolean')) {
|
|
360
528
|
const rect = e.target.getBoundingClientRect();
|
|
361
529
|
const clickX = e.clientX - rect.left;
|
|
362
530
|
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
363
531
|
if (clickX < 0 && clickX >= -8) {
|
|
532
|
+
// Block render until click is processed to prevent DOM destruction
|
|
533
|
+
this._blockRender = true;
|
|
364
534
|
return;
|
|
365
535
|
}
|
|
366
536
|
}
|
|
@@ -387,7 +557,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
387
557
|
|
|
388
558
|
// Focus textarea
|
|
389
559
|
hiddenTextarea.focus();
|
|
390
|
-
this.
|
|
560
|
+
this._invalidateRenderCache();
|
|
391
561
|
this.scheduleRender();
|
|
392
562
|
});
|
|
393
563
|
|
|
@@ -413,7 +583,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
413
583
|
viewport.scrollTop += scrollSpeed;
|
|
414
584
|
}
|
|
415
585
|
|
|
416
|
-
this.
|
|
586
|
+
this._invalidateRenderCache();
|
|
417
587
|
this.scheduleRender();
|
|
418
588
|
});
|
|
419
589
|
|
|
@@ -425,13 +595,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
425
595
|
// Focus/blur handling to show/hide cursor
|
|
426
596
|
hiddenTextarea.addEventListener('focus', () => {
|
|
427
597
|
editorWrapper.classList.add('focused');
|
|
428
|
-
this.
|
|
598
|
+
this._invalidateRenderCache(); // Force re-render to show cursor
|
|
429
599
|
this.scheduleRender();
|
|
430
600
|
});
|
|
431
601
|
|
|
432
602
|
hiddenTextarea.addEventListener('blur', () => {
|
|
433
603
|
editorWrapper.classList.remove('focused');
|
|
434
|
-
this.
|
|
604
|
+
this._invalidateRenderCache(); // Force re-render to hide cursor
|
|
435
605
|
this.scheduleRender();
|
|
436
606
|
});
|
|
437
607
|
|
|
@@ -439,7 +609,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
439
609
|
let isRendering = false;
|
|
440
610
|
viewport.addEventListener('scroll', () => {
|
|
441
611
|
if (isRendering) return;
|
|
442
|
-
this.scrollTop = viewport.scrollTop;
|
|
443
612
|
this.syncGutterScroll();
|
|
444
613
|
|
|
445
614
|
// Use requestAnimationFrame to batch scroll updates
|
|
@@ -453,8 +622,21 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
453
622
|
}
|
|
454
623
|
});
|
|
455
624
|
|
|
625
|
+
// Composition handling for international keyboards (dead keys)
|
|
626
|
+
hiddenTextarea.addEventListener('compositionstart', () => {
|
|
627
|
+
this._isComposing = true;
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
hiddenTextarea.addEventListener('compositionend', () => {
|
|
631
|
+
this._isComposing = false;
|
|
632
|
+
// Process the final composed text
|
|
633
|
+
this.handleInput();
|
|
634
|
+
});
|
|
635
|
+
|
|
456
636
|
// Input handling (hidden textarea)
|
|
457
637
|
hiddenTextarea.addEventListener('input', () => {
|
|
638
|
+
// Skip input during composition (dead keys on international keyboards)
|
|
639
|
+
if (this._isComposing) return;
|
|
458
640
|
this.handleInput();
|
|
459
641
|
});
|
|
460
642
|
|
|
@@ -508,6 +690,11 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
508
690
|
* Set the editor content from a string value
|
|
509
691
|
*/
|
|
510
692
|
setValue(value) {
|
|
693
|
+
// Save to history only if there's existing content
|
|
694
|
+
if (this.lines.length > 0) {
|
|
695
|
+
this._saveToHistory('setValue');
|
|
696
|
+
}
|
|
697
|
+
|
|
511
698
|
if (!value || !value.trim()) {
|
|
512
699
|
this.lines = [];
|
|
513
700
|
} else {
|
|
@@ -524,7 +711,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
524
711
|
this.lines = value.split('\n');
|
|
525
712
|
}
|
|
526
713
|
}
|
|
527
|
-
|
|
714
|
+
|
|
528
715
|
// Clear state for new content
|
|
529
716
|
this.collapsedNodes.clear();
|
|
530
717
|
this.hiddenFeatures.clear();
|
|
@@ -559,9 +746,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
559
746
|
* Rebuilds line-to-nodeId mapping while preserving collapsed state
|
|
560
747
|
*/
|
|
561
748
|
updateModel() {
|
|
749
|
+
// Invalidate context map cache since content changed
|
|
750
|
+
this._contextMapCache = null;
|
|
751
|
+
|
|
562
752
|
// Rebuild lineToNodeId mapping (may shift due to edits)
|
|
563
753
|
this._rebuildNodeIdMappings();
|
|
564
|
-
|
|
754
|
+
|
|
565
755
|
this.computeFeatureRanges();
|
|
566
756
|
this.computeLineMetadata();
|
|
567
757
|
this.computeVisibleLines();
|
|
@@ -740,7 +930,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
740
930
|
}
|
|
741
931
|
|
|
742
932
|
// Reset render cache to force re-render
|
|
743
|
-
this.
|
|
933
|
+
this._invalidateRenderCache();
|
|
744
934
|
this._lastEndIndex = -1;
|
|
745
935
|
this._lastTotalLines = -1;
|
|
746
936
|
}
|
|
@@ -756,11 +946,15 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
756
946
|
}
|
|
757
947
|
|
|
758
948
|
renderViewport() {
|
|
949
|
+
// Skip render if blocked (during inline control click to prevent DOM destruction)
|
|
950
|
+
if (this._blockRender) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
759
953
|
const viewport = this.shadowRoot.getElementById('viewport');
|
|
760
954
|
const linesContainer = this.shadowRoot.getElementById('linesContainer');
|
|
761
955
|
const scrollContent = this.shadowRoot.getElementById('scrollContent');
|
|
762
956
|
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
763
|
-
|
|
957
|
+
|
|
764
958
|
if (!viewport || !linesContainer) return;
|
|
765
959
|
|
|
766
960
|
this.viewportHeight = viewport.clientHeight;
|
|
@@ -923,7 +1117,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
923
1117
|
if (!this._charWidth) {
|
|
924
1118
|
const canvas = document.createElement('canvas');
|
|
925
1119
|
const ctx = canvas.getContext('2d');
|
|
926
|
-
|
|
1120
|
+
// Use exact same font as CSS: 'Courier New', Courier, monospace at 13px
|
|
1121
|
+
ctx.font = "13px 'Courier New', Courier, monospace";
|
|
927
1122
|
this._charWidth = ctx.measureText('M').width;
|
|
928
1123
|
}
|
|
929
1124
|
return this._charWidth;
|
|
@@ -1068,106 +1263,25 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1068
1263
|
}
|
|
1069
1264
|
|
|
1070
1265
|
handleKeydown(e) {
|
|
1071
|
-
//
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1266
|
+
// Build context for collapsed zone detection
|
|
1267
|
+
const ctx = {
|
|
1268
|
+
inCollapsedZone: this._getCollapsedRangeForLine(this.cursorLine),
|
|
1269
|
+
onCollapsedNode: this._getCollapsedNodeAtLine(this.cursorLine),
|
|
1270
|
+
onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1076
1273
|
switch (e.key) {
|
|
1077
1274
|
case 'Enter':
|
|
1078
1275
|
e.preventDefault();
|
|
1079
|
-
|
|
1080
|
-
if (onCollapsedNode || inCollapsedZone) return;
|
|
1081
|
-
// On closing line, before bracket -> block
|
|
1082
|
-
if (onClosingLine) {
|
|
1083
|
-
const line = this.lines[this.cursorLine];
|
|
1084
|
-
const bracketPos = this._getClosingBracketPos(line);
|
|
1085
|
-
if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
// After bracket, allow normal enter (add new line)
|
|
1089
|
-
}
|
|
1090
|
-
this.insertNewline();
|
|
1276
|
+
this._handleEnter(ctx);
|
|
1091
1277
|
break;
|
|
1092
1278
|
case 'Backspace':
|
|
1093
1279
|
e.preventDefault();
|
|
1094
|
-
|
|
1095
|
-
if (this._hasSelection()) {
|
|
1096
|
-
this._deleteSelection();
|
|
1097
|
-
this.formatAndUpdate();
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
// On closing line
|
|
1101
|
-
if (onClosingLine) {
|
|
1102
|
-
const line = this.lines[this.cursorLine];
|
|
1103
|
-
const bracketPos = this._getClosingBracketPos(line);
|
|
1104
|
-
if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
|
|
1105
|
-
// After bracket, allow delete
|
|
1106
|
-
this.deleteBackward();
|
|
1107
|
-
return;
|
|
1108
|
-
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1109
|
-
// Just after bracket, delete whole node
|
|
1110
|
-
this._deleteCollapsedNode(onClosingLine);
|
|
1111
|
-
return;
|
|
1112
|
-
}
|
|
1113
|
-
// On or before bracket, delete whole node
|
|
1114
|
-
this._deleteCollapsedNode(onClosingLine);
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
// If on collapsed node opening line at position 0, delete whole node
|
|
1118
|
-
if (onCollapsedNode && this.cursorColumn === 0) {
|
|
1119
|
-
this._deleteCollapsedNode(onCollapsedNode);
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
// Block inside collapsed zones
|
|
1123
|
-
if (inCollapsedZone) return;
|
|
1124
|
-
// On opening line, allow editing before and at bracket
|
|
1125
|
-
if (onCollapsedNode) {
|
|
1126
|
-
const line = this.lines[this.cursorLine];
|
|
1127
|
-
const bracketPos = line.search(/[{\[]/);
|
|
1128
|
-
if (this.cursorColumn > bracketPos + 1) {
|
|
1129
|
-
// After bracket, delete whole node
|
|
1130
|
-
this._deleteCollapsedNode(onCollapsedNode);
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
this.deleteBackward();
|
|
1280
|
+
this._handleBackspace(ctx);
|
|
1135
1281
|
break;
|
|
1136
1282
|
case 'Delete':
|
|
1137
1283
|
e.preventDefault();
|
|
1138
|
-
|
|
1139
|
-
if (this._hasSelection()) {
|
|
1140
|
-
this._deleteSelection();
|
|
1141
|
-
this.formatAndUpdate();
|
|
1142
|
-
return;
|
|
1143
|
-
}
|
|
1144
|
-
// On closing line
|
|
1145
|
-
if (onClosingLine) {
|
|
1146
|
-
const line = this.lines[this.cursorLine];
|
|
1147
|
-
const bracketPos = this._getClosingBracketPos(line);
|
|
1148
|
-
if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
|
|
1149
|
-
// After bracket, allow delete
|
|
1150
|
-
this.deleteForward();
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
// On or before bracket, delete whole node
|
|
1154
|
-
this._deleteCollapsedNode(onClosingLine);
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
// If on collapsed node opening line
|
|
1158
|
-
if (onCollapsedNode) {
|
|
1159
|
-
const line = this.lines[this.cursorLine];
|
|
1160
|
-
const bracketPos = line.search(/[{\[]/);
|
|
1161
|
-
if (this.cursorColumn > bracketPos) {
|
|
1162
|
-
// After bracket, delete whole node
|
|
1163
|
-
this._deleteCollapsedNode(onCollapsedNode);
|
|
1164
|
-
return;
|
|
1165
|
-
}
|
|
1166
|
-
// Before bracket, allow editing key name
|
|
1167
|
-
}
|
|
1168
|
-
// Block inside collapsed zones
|
|
1169
|
-
if (inCollapsedZone) return;
|
|
1170
|
-
this.deleteForward();
|
|
1284
|
+
this._handleDelete(ctx);
|
|
1171
1285
|
break;
|
|
1172
1286
|
case 'ArrowUp':
|
|
1173
1287
|
e.preventDefault();
|
|
@@ -1187,64 +1301,169 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1187
1301
|
break;
|
|
1188
1302
|
case 'Home':
|
|
1189
1303
|
e.preventDefault();
|
|
1190
|
-
this._handleHomeEnd('home', e.shiftKey, onClosingLine);
|
|
1304
|
+
this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine);
|
|
1191
1305
|
break;
|
|
1192
1306
|
case 'End':
|
|
1193
1307
|
e.preventDefault();
|
|
1194
|
-
this._handleHomeEnd('end', e.shiftKey, onClosingLine);
|
|
1308
|
+
this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine);
|
|
1195
1309
|
break;
|
|
1196
1310
|
case 'a':
|
|
1197
|
-
// Ctrl+A or Cmd+A: select all
|
|
1198
1311
|
if (e.ctrlKey || e.metaKey) {
|
|
1199
1312
|
e.preventDefault();
|
|
1200
1313
|
this._selectAll();
|
|
1201
|
-
return;
|
|
1202
1314
|
}
|
|
1203
1315
|
break;
|
|
1204
|
-
case '
|
|
1205
|
-
e.
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
// Find the position just after the opening bracket
|
|
1212
|
-
const startLine = this.lines[containingNode.startLine];
|
|
1213
|
-
const bracketPos = startLine.search(/[{\[]/);
|
|
1214
|
-
|
|
1215
|
-
this.toggleCollapse(containingNode.nodeId);
|
|
1216
|
-
|
|
1217
|
-
// Move cursor to just after the opening bracket
|
|
1218
|
-
this.cursorLine = containingNode.startLine;
|
|
1219
|
-
this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
|
|
1220
|
-
this._clearSelection();
|
|
1221
|
-
this._scrollToCursor();
|
|
1316
|
+
case 'z':
|
|
1317
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1318
|
+
e.preventDefault();
|
|
1319
|
+
if (e.shiftKey) {
|
|
1320
|
+
this.redo();
|
|
1321
|
+
} else {
|
|
1322
|
+
this.undo();
|
|
1222
1323
|
}
|
|
1223
|
-
return;
|
|
1224
1324
|
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1229
|
-
|
|
1325
|
+
break;
|
|
1326
|
+
case 'y':
|
|
1327
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1328
|
+
e.preventDefault();
|
|
1329
|
+
this.redo();
|
|
1230
1330
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1331
|
+
break;
|
|
1332
|
+
case 's':
|
|
1333
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1334
|
+
e.preventDefault();
|
|
1335
|
+
this.save();
|
|
1336
|
+
}
|
|
1337
|
+
break;
|
|
1338
|
+
case 'o':
|
|
1339
|
+
if ((e.ctrlKey || e.metaKey) && !this.hasAttribute('readonly')) {
|
|
1340
|
+
e.preventDefault();
|
|
1341
|
+
this.open();
|
|
1234
1342
|
}
|
|
1235
|
-
|
|
1236
|
-
// Block in hidden collapsed zones
|
|
1237
|
-
if (inCollapsedZone) return;
|
|
1238
1343
|
break;
|
|
1344
|
+
case 'Tab':
|
|
1345
|
+
e.preventDefault();
|
|
1346
|
+
this._handleTab(e.shiftKey, ctx);
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
_handleEnter(ctx) {
|
|
1352
|
+
// Block in collapsed zones
|
|
1353
|
+
if (ctx.onCollapsedNode || ctx.inCollapsedZone) return;
|
|
1354
|
+
// On closing line, before bracket -> block
|
|
1355
|
+
if (ctx.onClosingLine) {
|
|
1356
|
+
const line = this.lines[this.cursorLine];
|
|
1357
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1358
|
+
if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
this.insertNewline();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
_handleBackspace(ctx) {
|
|
1366
|
+
// Delete selection if any
|
|
1367
|
+
if (this._hasSelection()) {
|
|
1368
|
+
this._deleteSelection();
|
|
1369
|
+
this.formatAndUpdate();
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
// On closing line
|
|
1373
|
+
if (ctx.onClosingLine) {
|
|
1374
|
+
const line = this.lines[this.cursorLine];
|
|
1375
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1376
|
+
if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
|
|
1377
|
+
this.deleteBackward();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
this._deleteCollapsedNode(ctx.onClosingLine);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
// If on collapsed node opening line at position 0, delete whole node
|
|
1384
|
+
if (ctx.onCollapsedNode && this.cursorColumn === 0) {
|
|
1385
|
+
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
// Block inside collapsed zones
|
|
1389
|
+
if (ctx.inCollapsedZone) return;
|
|
1390
|
+
// On opening line, allow editing before bracket
|
|
1391
|
+
if (ctx.onCollapsedNode) {
|
|
1392
|
+
const line = this.lines[this.cursorLine];
|
|
1393
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1394
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1395
|
+
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
this.deleteBackward();
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
_handleDelete(ctx) {
|
|
1403
|
+
// Delete selection if any
|
|
1404
|
+
if (this._hasSelection()) {
|
|
1405
|
+
this._deleteSelection();
|
|
1406
|
+
this.formatAndUpdate();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
// On closing line
|
|
1410
|
+
if (ctx.onClosingLine) {
|
|
1411
|
+
const line = this.lines[this.cursorLine];
|
|
1412
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1413
|
+
if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
|
|
1414
|
+
this.deleteForward();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
this._deleteCollapsedNode(ctx.onClosingLine);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
// If on collapsed node opening line
|
|
1421
|
+
if (ctx.onCollapsedNode) {
|
|
1422
|
+
const line = this.lines[this.cursorLine];
|
|
1423
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1424
|
+
if (this.cursorColumn > bracketPos) {
|
|
1425
|
+
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// Block inside collapsed zones
|
|
1430
|
+
if (ctx.inCollapsedZone) return;
|
|
1431
|
+
this.deleteForward();
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
_handleTab(isShiftKey, ctx) {
|
|
1435
|
+
// Shift+Tab: collapse the containing expanded node
|
|
1436
|
+
if (isShiftKey) {
|
|
1437
|
+
const containingNode = this._getContainingExpandedNode(this.cursorLine);
|
|
1438
|
+
if (containingNode) {
|
|
1439
|
+
const startLine = this.lines[containingNode.startLine];
|
|
1440
|
+
const bracketPos = startLine.search(/[{\[]/);
|
|
1441
|
+
this.toggleCollapse(containingNode.nodeId);
|
|
1442
|
+
this.cursorLine = containingNode.startLine;
|
|
1443
|
+
this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
|
|
1444
|
+
this._clearSelection();
|
|
1445
|
+
this._scrollToCursor();
|
|
1446
|
+
}
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
// Tab: expand collapsed node if on one
|
|
1450
|
+
if (ctx.onCollapsedNode) {
|
|
1451
|
+
this.toggleCollapse(ctx.onCollapsedNode.nodeId);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (ctx.onClosingLine) {
|
|
1455
|
+
this.toggleCollapse(ctx.onClosingLine.nodeId);
|
|
1239
1456
|
}
|
|
1240
1457
|
}
|
|
1241
1458
|
|
|
1242
1459
|
insertNewline() {
|
|
1460
|
+
this._saveToHistory('newline');
|
|
1461
|
+
|
|
1243
1462
|
if (this.cursorLine < this.lines.length) {
|
|
1244
1463
|
const line = this.lines[this.cursorLine];
|
|
1245
1464
|
const before = line.substring(0, this.cursorColumn);
|
|
1246
1465
|
const after = line.substring(this.cursorColumn);
|
|
1247
|
-
|
|
1466
|
+
|
|
1248
1467
|
this.lines[this.cursorLine] = before;
|
|
1249
1468
|
this.lines.splice(this.cursorLine + 1, 0, after);
|
|
1250
1469
|
this.cursorLine++;
|
|
@@ -1254,11 +1473,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1254
1473
|
this.cursorLine = this.lines.length - 1;
|
|
1255
1474
|
this.cursorColumn = 0;
|
|
1256
1475
|
}
|
|
1257
|
-
|
|
1476
|
+
|
|
1258
1477
|
this.formatAndUpdate();
|
|
1259
1478
|
}
|
|
1260
1479
|
|
|
1261
1480
|
deleteBackward() {
|
|
1481
|
+
this._saveToHistory('delete');
|
|
1482
|
+
|
|
1262
1483
|
if (this.cursorColumn > 0) {
|
|
1263
1484
|
const line = this.lines[this.cursorLine];
|
|
1264
1485
|
this.lines[this.cursorLine] = line.substring(0, this.cursorColumn - 1) + line.substring(this.cursorColumn);
|
|
@@ -1272,11 +1493,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1272
1493
|
this.lines.splice(this.cursorLine, 1);
|
|
1273
1494
|
this.cursorLine--;
|
|
1274
1495
|
}
|
|
1275
|
-
|
|
1496
|
+
|
|
1276
1497
|
this.formatAndUpdate();
|
|
1277
1498
|
}
|
|
1278
1499
|
|
|
1279
1500
|
deleteForward() {
|
|
1501
|
+
this._saveToHistory('delete');
|
|
1502
|
+
|
|
1280
1503
|
if (this.cursorLine < this.lines.length) {
|
|
1281
1504
|
const line = this.lines[this.cursorLine];
|
|
1282
1505
|
if (this.cursorColumn < line.length) {
|
|
@@ -1287,7 +1510,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1287
1510
|
this.lines.splice(this.cursorLine + 1, 1);
|
|
1288
1511
|
}
|
|
1289
1512
|
}
|
|
1290
|
-
|
|
1513
|
+
|
|
1291
1514
|
this.formatAndUpdate();
|
|
1292
1515
|
}
|
|
1293
1516
|
|
|
@@ -1317,7 +1540,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1317
1540
|
const maxCol = this.lines[this.cursorLine]?.length || 0;
|
|
1318
1541
|
this.cursorColumn = Math.min(this.cursorColumn, maxCol);
|
|
1319
1542
|
|
|
1320
|
-
this.
|
|
1543
|
+
this._invalidateRenderCache();
|
|
1321
1544
|
this._scrollToCursor();
|
|
1322
1545
|
this.scheduleRender();
|
|
1323
1546
|
}
|
|
@@ -1326,124 +1549,103 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1326
1549
|
* Move cursor horizontally with smart navigation around collapsed nodes
|
|
1327
1550
|
*/
|
|
1328
1551
|
moveCursorHorizontal(delta) {
|
|
1552
|
+
if (delta > 0) {
|
|
1553
|
+
this._moveCursorRight();
|
|
1554
|
+
} else {
|
|
1555
|
+
this._moveCursorLeft();
|
|
1556
|
+
}
|
|
1557
|
+
this._invalidateRenderCache();
|
|
1558
|
+
this._scrollToCursor();
|
|
1559
|
+
this.scheduleRender();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
_moveCursorRight() {
|
|
1329
1563
|
const line = this.lines[this.cursorLine];
|
|
1330
1564
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1331
1565
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1332
|
-
|
|
1333
|
-
if (
|
|
1334
|
-
|
|
1335
|
-
if (
|
|
1336
|
-
|
|
1337
|
-
if (this.cursorColumn < bracketPos) {
|
|
1338
|
-
// Before bracket, jump to bracket
|
|
1339
|
-
this.cursorColumn = bracketPos;
|
|
1340
|
-
} else if (this.cursorColumn >= line.length) {
|
|
1341
|
-
// At end, go to next line
|
|
1342
|
-
if (this.cursorLine < this.lines.length - 1) {
|
|
1343
|
-
this.cursorLine++;
|
|
1344
|
-
this.cursorColumn = 0;
|
|
1345
|
-
}
|
|
1346
|
-
} else {
|
|
1347
|
-
// On or after bracket, move normally
|
|
1348
|
-
this.cursorColumn++;
|
|
1349
|
-
}
|
|
1350
|
-
} else if (onCollapsed) {
|
|
1351
|
-
const bracketPos = line.search(/[{\[]/);
|
|
1352
|
-
if (this.cursorColumn < bracketPos) {
|
|
1353
|
-
// Before bracket, move normally
|
|
1354
|
-
this.cursorColumn++;
|
|
1355
|
-
} else if (this.cursorColumn === bracketPos) {
|
|
1356
|
-
// On bracket, go to after bracket
|
|
1357
|
-
this.cursorColumn = bracketPos + 1;
|
|
1358
|
-
} else {
|
|
1359
|
-
// After bracket, jump to closing line at bracket
|
|
1360
|
-
this.cursorLine = onCollapsed.endLine;
|
|
1361
|
-
const closingLine = this.lines[this.cursorLine];
|
|
1362
|
-
this.cursorColumn = this._getClosingBracketPos(closingLine);
|
|
1363
|
-
}
|
|
1566
|
+
|
|
1567
|
+
if (onClosingLine) {
|
|
1568
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1569
|
+
if (this.cursorColumn < bracketPos) {
|
|
1570
|
+
this.cursorColumn = bracketPos;
|
|
1364
1571
|
} else if (this.cursorColumn >= line.length) {
|
|
1365
|
-
// Move to next line
|
|
1366
1572
|
if (this.cursorLine < this.lines.length - 1) {
|
|
1367
1573
|
this.cursorLine++;
|
|
1368
1574
|
this.cursorColumn = 0;
|
|
1369
|
-
// Skip hidden collapsed zones
|
|
1370
|
-
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1371
|
-
if (collapsed) {
|
|
1372
|
-
this.cursorLine = collapsed.endLine;
|
|
1373
|
-
this.cursorColumn = 0;
|
|
1374
|
-
}
|
|
1375
1575
|
}
|
|
1376
1576
|
} else {
|
|
1377
1577
|
this.cursorColumn++;
|
|
1378
1578
|
}
|
|
1379
|
-
} else {
|
|
1380
|
-
|
|
1381
|
-
if (
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
this.cursorColumn = openBracketPos + 1;
|
|
1398
|
-
}
|
|
1399
|
-
} else if (onCollapsed) {
|
|
1400
|
-
const bracketPos = line.search(/[{\[]/);
|
|
1401
|
-
if (this.cursorColumn > bracketPos + 1) {
|
|
1402
|
-
// After bracket, go to just after bracket
|
|
1403
|
-
this.cursorColumn = bracketPos + 1;
|
|
1404
|
-
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1405
|
-
// Just after bracket, go to bracket
|
|
1406
|
-
this.cursorColumn = bracketPos;
|
|
1407
|
-
} else if (this.cursorColumn > 0) {
|
|
1408
|
-
// Before bracket, move normally
|
|
1409
|
-
this.cursorColumn--;
|
|
1410
|
-
} else {
|
|
1411
|
-
// At start, go to previous line
|
|
1412
|
-
if (this.cursorLine > 0) {
|
|
1413
|
-
this.cursorLine--;
|
|
1414
|
-
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1415
|
-
}
|
|
1579
|
+
} else if (onCollapsed) {
|
|
1580
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1581
|
+
if (this.cursorColumn < bracketPos) {
|
|
1582
|
+
this.cursorColumn++;
|
|
1583
|
+
} else if (this.cursorColumn === bracketPos) {
|
|
1584
|
+
this.cursorColumn = bracketPos + 1;
|
|
1585
|
+
} else {
|
|
1586
|
+
this.cursorLine = onCollapsed.endLine;
|
|
1587
|
+
this.cursorColumn = this._getClosingBracketPos(this.lines[this.cursorLine]);
|
|
1588
|
+
}
|
|
1589
|
+
} else if (this.cursorColumn >= line.length) {
|
|
1590
|
+
if (this.cursorLine < this.lines.length - 1) {
|
|
1591
|
+
this.cursorLine++;
|
|
1592
|
+
this.cursorColumn = 0;
|
|
1593
|
+
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1594
|
+
if (collapsed) {
|
|
1595
|
+
this.cursorLine = collapsed.endLine;
|
|
1596
|
+
this.cursorColumn = 0;
|
|
1416
1597
|
}
|
|
1598
|
+
}
|
|
1599
|
+
} else {
|
|
1600
|
+
this.cursorColumn++;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
_moveCursorLeft() {
|
|
1605
|
+
const line = this.lines[this.cursorLine];
|
|
1606
|
+
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1607
|
+
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1608
|
+
|
|
1609
|
+
if (onClosingLine) {
|
|
1610
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1611
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1612
|
+
this.cursorColumn--;
|
|
1613
|
+
} else {
|
|
1614
|
+
// Jump to opening line after bracket
|
|
1615
|
+
this.cursorLine = onClosingLine.startLine;
|
|
1616
|
+
const openLine = this.lines[this.cursorLine];
|
|
1617
|
+
this.cursorColumn = openLine.search(/[{\[]/) + 1;
|
|
1618
|
+
}
|
|
1619
|
+
} else if (onCollapsed) {
|
|
1620
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1621
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1622
|
+
this.cursorColumn = bracketPos + 1;
|
|
1623
|
+
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1624
|
+
this.cursorColumn = bracketPos;
|
|
1417
1625
|
} else if (this.cursorColumn > 0) {
|
|
1418
1626
|
this.cursorColumn--;
|
|
1419
1627
|
} else if (this.cursorLine > 0) {
|
|
1420
|
-
// Move to previous line
|
|
1421
1628
|
this.cursorLine--;
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1629
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1630
|
+
}
|
|
1631
|
+
} else if (this.cursorColumn > 0) {
|
|
1632
|
+
this.cursorColumn--;
|
|
1633
|
+
} else if (this.cursorLine > 0) {
|
|
1634
|
+
this.cursorLine--;
|
|
1635
|
+
const closing = this._getCollapsedClosingLine(this.cursorLine);
|
|
1636
|
+
if (closing) {
|
|
1637
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1638
|
+
} else {
|
|
1639
|
+
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1640
|
+
if (collapsed) {
|
|
1641
|
+
this.cursorLine = collapsed.startLine;
|
|
1642
|
+
const openLine = this.lines[this.cursorLine];
|
|
1643
|
+
this.cursorColumn = openLine.search(/[{\[]/) + 1;
|
|
1428
1644
|
} else {
|
|
1429
|
-
|
|
1430
|
-
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1431
|
-
if (collapsed) {
|
|
1432
|
-
// Jump to opening line after bracket
|
|
1433
|
-
this.cursorLine = collapsed.startLine;
|
|
1434
|
-
const openLine = this.lines[this.cursorLine];
|
|
1435
|
-
const bracketPos = openLine.search(/[{\[]/);
|
|
1436
|
-
this.cursorColumn = bracketPos + 1;
|
|
1437
|
-
} else {
|
|
1438
|
-
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1439
|
-
}
|
|
1645
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1440
1646
|
}
|
|
1441
1647
|
}
|
|
1442
1648
|
}
|
|
1443
|
-
|
|
1444
|
-
this._lastStartIndex = -1;
|
|
1445
|
-
this._scrollToCursor();
|
|
1446
|
-
this.scheduleRender();
|
|
1447
1649
|
}
|
|
1448
1650
|
|
|
1449
1651
|
/**
|
|
@@ -1471,17 +1673,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1471
1673
|
}
|
|
1472
1674
|
}
|
|
1473
1675
|
|
|
1474
|
-
/**
|
|
1475
|
-
* Legacy moveCursor for compatibility
|
|
1476
|
-
*/
|
|
1477
|
-
moveCursor(deltaLine, deltaCol) {
|
|
1478
|
-
if (deltaLine !== 0) {
|
|
1479
|
-
this.moveCursorSkipCollapsed(deltaLine);
|
|
1480
|
-
} else if (deltaCol !== 0) {
|
|
1481
|
-
this.moveCursorHorizontal(deltaCol);
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
1676
|
/**
|
|
1486
1677
|
* Handle arrow key with optional selection
|
|
1487
1678
|
*/
|
|
@@ -1536,7 +1727,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1536
1727
|
this.selectionEnd = null;
|
|
1537
1728
|
}
|
|
1538
1729
|
|
|
1539
|
-
this.
|
|
1730
|
+
this._invalidateRenderCache();
|
|
1540
1731
|
this._scrollToCursor();
|
|
1541
1732
|
this.scheduleRender();
|
|
1542
1733
|
}
|
|
@@ -1551,7 +1742,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1551
1742
|
this.cursorLine = lastLine;
|
|
1552
1743
|
this.cursorColumn = this.lines[lastLine]?.length || 0;
|
|
1553
1744
|
|
|
1554
|
-
this.
|
|
1745
|
+
this._invalidateRenderCache();
|
|
1555
1746
|
this._scrollToCursor();
|
|
1556
1747
|
this.scheduleRender();
|
|
1557
1748
|
}
|
|
@@ -1618,9 +1809,11 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1618
1809
|
*/
|
|
1619
1810
|
_deleteSelection() {
|
|
1620
1811
|
if (!this._hasSelection()) return false;
|
|
1621
|
-
|
|
1812
|
+
|
|
1813
|
+
this._saveToHistory('delete');
|
|
1814
|
+
|
|
1622
1815
|
const { start, end } = this._normalizeSelection();
|
|
1623
|
-
|
|
1816
|
+
|
|
1624
1817
|
if (start.line === end.line) {
|
|
1625
1818
|
// Single line selection
|
|
1626
1819
|
const line = this.lines[start.line];
|
|
@@ -1632,12 +1825,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1632
1825
|
this.lines[start.line] = startLine + endLine;
|
|
1633
1826
|
this.lines.splice(start.line + 1, end.line - start.line);
|
|
1634
1827
|
}
|
|
1635
|
-
|
|
1828
|
+
|
|
1636
1829
|
this.cursorLine = start.line;
|
|
1637
1830
|
this.cursorColumn = start.column;
|
|
1638
1831
|
this.selectionStart = null;
|
|
1639
1832
|
this.selectionEnd = null;
|
|
1640
|
-
|
|
1833
|
+
|
|
1641
1834
|
return true;
|
|
1642
1835
|
}
|
|
1643
1836
|
|
|
@@ -1646,10 +1839,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1646
1839
|
if (this._hasSelection()) {
|
|
1647
1840
|
this._deleteSelection();
|
|
1648
1841
|
}
|
|
1649
|
-
|
|
1842
|
+
|
|
1650
1843
|
// Block insertion in hidden collapsed zones
|
|
1651
1844
|
if (this._getCollapsedRangeForLine(this.cursorLine)) return;
|
|
1652
|
-
|
|
1845
|
+
|
|
1653
1846
|
// On closing line, only allow after bracket
|
|
1654
1847
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1655
1848
|
if (onClosingLine) {
|
|
@@ -1657,7 +1850,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1657
1850
|
const bracketPos = this._getClosingBracketPos(line);
|
|
1658
1851
|
if (this.cursorColumn <= bracketPos) return;
|
|
1659
1852
|
}
|
|
1660
|
-
|
|
1853
|
+
|
|
1661
1854
|
// On collapsed opening line, only allow before bracket
|
|
1662
1855
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1663
1856
|
if (onCollapsed) {
|
|
@@ -1665,11 +1858,17 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1665
1858
|
const bracketPos = line.search(/[{\[]/);
|
|
1666
1859
|
if (this.cursorColumn > bracketPos) return;
|
|
1667
1860
|
}
|
|
1668
|
-
|
|
1861
|
+
|
|
1862
|
+
// Save to history before making changes
|
|
1863
|
+
this._saveToHistory('insert');
|
|
1864
|
+
|
|
1669
1865
|
// Handle empty editor case
|
|
1670
1866
|
if (this.lines.length === 0) {
|
|
1671
|
-
|
|
1672
|
-
|
|
1867
|
+
// Split text by newlines to properly handle multi-line paste
|
|
1868
|
+
const textLines = text.split('\n');
|
|
1869
|
+
this.lines = textLines;
|
|
1870
|
+
this.cursorLine = textLines.length - 1;
|
|
1871
|
+
this.cursorColumn = textLines[textLines.length - 1].length;
|
|
1673
1872
|
} else if (this.cursorLine < this.lines.length) {
|
|
1674
1873
|
const line = this.lines[this.cursorLine];
|
|
1675
1874
|
this.lines[this.cursorLine] = line.substring(0, this.cursorColumn) + text + line.substring(this.cursorColumn);
|
|
@@ -1682,11 +1881,17 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1682
1881
|
e.preventDefault();
|
|
1683
1882
|
const text = e.clipboardData.getData('text/plain');
|
|
1684
1883
|
if (text) {
|
|
1884
|
+
const wasEmpty = this.lines.length === 0;
|
|
1685
1885
|
this.insertText(text);
|
|
1686
|
-
// Auto-collapse coordinates after pasting
|
|
1687
|
-
|
|
1886
|
+
// Auto-collapse coordinates after pasting into empty editor
|
|
1887
|
+
if (wasEmpty && this.lines.length > 0) {
|
|
1888
|
+
// Cancel pending render, collapse first, then render once
|
|
1889
|
+
if (this.renderTimer) {
|
|
1890
|
+
cancelAnimationFrame(this.renderTimer);
|
|
1891
|
+
this.renderTimer = null;
|
|
1892
|
+
}
|
|
1688
1893
|
this.autoCollapseCoordinates();
|
|
1689
|
-
}
|
|
1894
|
+
}
|
|
1690
1895
|
}
|
|
1691
1896
|
}
|
|
1692
1897
|
|
|
@@ -1704,11 +1909,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1704
1909
|
e.preventDefault();
|
|
1705
1910
|
if (this._hasSelection()) {
|
|
1706
1911
|
e.clipboardData.setData('text/plain', this._getSelectedText());
|
|
1912
|
+
this._saveToHistory('cut');
|
|
1707
1913
|
this._deleteSelection();
|
|
1708
1914
|
this.formatAndUpdate();
|
|
1709
1915
|
} else {
|
|
1710
1916
|
// Cut all content
|
|
1711
1917
|
e.clipboardData.setData('text/plain', this.getContent());
|
|
1918
|
+
this._saveToHistory('cut');
|
|
1712
1919
|
this.lines = [];
|
|
1713
1920
|
this.cursorLine = 0;
|
|
1714
1921
|
this.cursorColumn = 0;
|
|
@@ -1721,29 +1928,42 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1721
1928
|
*/
|
|
1722
1929
|
_getPositionFromClick(e) {
|
|
1723
1930
|
const viewport = this.shadowRoot.getElementById('viewport');
|
|
1931
|
+
const linesContainer = this.shadowRoot.getElementById('linesContainer');
|
|
1724
1932
|
const rect = viewport.getBoundingClientRect();
|
|
1725
|
-
|
|
1933
|
+
|
|
1726
1934
|
const paddingTop = 8;
|
|
1727
|
-
|
|
1728
|
-
|
|
1935
|
+
|
|
1729
1936
|
const y = e.clientY - rect.top + viewport.scrollTop - paddingTop;
|
|
1730
|
-
const x = e.clientX - rect.left - paddingLeft;
|
|
1731
|
-
|
|
1732
1937
|
const visibleLineIndex = Math.floor(y / this.lineHeight);
|
|
1733
|
-
|
|
1938
|
+
|
|
1734
1939
|
let line = 0;
|
|
1735
1940
|
let column = 0;
|
|
1736
|
-
|
|
1941
|
+
|
|
1737
1942
|
if (visibleLineIndex >= 0 && visibleLineIndex < this.visibleLines.length) {
|
|
1738
1943
|
const lineData = this.visibleLines[visibleLineIndex];
|
|
1739
1944
|
line = lineData.index;
|
|
1740
|
-
|
|
1945
|
+
|
|
1946
|
+
// Get actual line element to calculate column position accurately
|
|
1947
|
+
const lineEl = linesContainer?.querySelector(`.line[data-line-index="${lineData.index}"]`);
|
|
1741
1948
|
const charWidth = this._getCharWidth();
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1949
|
+
|
|
1950
|
+
if (lineEl) {
|
|
1951
|
+
// Use line element's actual position for accurate column calculation
|
|
1952
|
+
const lineRect = lineEl.getBoundingClientRect();
|
|
1953
|
+
const clickRelativeToLine = e.clientX - lineRect.left;
|
|
1954
|
+
const rawColumn = Math.round(clickRelativeToLine / charWidth);
|
|
1955
|
+
const lineLength = lineData.content?.length || 0;
|
|
1956
|
+
column = Math.max(0, Math.min(rawColumn, lineLength));
|
|
1957
|
+
} else {
|
|
1958
|
+
// Fallback to padding-based calculation if line element not found
|
|
1959
|
+
const paddingLeft = 12;
|
|
1960
|
+
const x = e.clientX - rect.left + viewport.scrollLeft - paddingLeft;
|
|
1961
|
+
const rawColumn = Math.round(x / charWidth);
|
|
1962
|
+
const lineLength = lineData.content?.length || 0;
|
|
1963
|
+
column = Math.max(0, Math.min(rawColumn, lineLength));
|
|
1964
|
+
}
|
|
1745
1965
|
}
|
|
1746
|
-
|
|
1966
|
+
|
|
1747
1967
|
return { line, column };
|
|
1748
1968
|
}
|
|
1749
1969
|
|
|
@@ -1766,16 +1986,21 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1766
1986
|
}
|
|
1767
1987
|
|
|
1768
1988
|
handleEditorClick(e) {
|
|
1989
|
+
// Unblock render now that click is being processed
|
|
1990
|
+
this._blockRender = false;
|
|
1991
|
+
|
|
1769
1992
|
// Line-level visibility button (pseudo-element ::before on .line.has-visibility)
|
|
1770
1993
|
const lineEl = e.target.closest('.line.has-visibility');
|
|
1771
1994
|
if (lineEl) {
|
|
1772
1995
|
const rect = lineEl.getBoundingClientRect();
|
|
1773
1996
|
const clickX = e.clientX - rect.left;
|
|
1774
|
-
// Pseudo-element is at the start of the line, check first ~14px
|
|
1775
1997
|
if (clickX < 14) {
|
|
1776
1998
|
e.preventDefault();
|
|
1777
1999
|
e.stopPropagation();
|
|
1778
|
-
|
|
2000
|
+
const featureKey = lineEl.dataset.featureKey;
|
|
2001
|
+
if (featureKey) {
|
|
2002
|
+
this.toggleFeatureVisibility(featureKey);
|
|
2003
|
+
}
|
|
1779
2004
|
return;
|
|
1780
2005
|
}
|
|
1781
2006
|
}
|
|
@@ -1836,33 +2061,34 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1836
2061
|
|
|
1837
2062
|
// Use updateView - don't rebuild nodeId mappings since content didn't change
|
|
1838
2063
|
this.updateView();
|
|
1839
|
-
this.
|
|
2064
|
+
this._invalidateRenderCache(); // Force re-render
|
|
1840
2065
|
this.scheduleRender();
|
|
1841
2066
|
}
|
|
1842
2067
|
|
|
1843
2068
|
autoCollapseCoordinates() {
|
|
1844
2069
|
const ranges = this._findCollapsibleRanges();
|
|
1845
|
-
|
|
2070
|
+
|
|
1846
2071
|
for (const range of ranges) {
|
|
1847
2072
|
if (range.nodeKey === 'coordinates') {
|
|
1848
2073
|
this.collapsedNodes.add(range.nodeId);
|
|
1849
2074
|
}
|
|
1850
2075
|
}
|
|
1851
|
-
|
|
1852
|
-
//
|
|
1853
|
-
|
|
2076
|
+
|
|
2077
|
+
// Rebuild everything to ensure consistent state after collapse changes
|
|
2078
|
+
// This is especially important after paste into empty editor
|
|
2079
|
+
this.updateModel();
|
|
1854
2080
|
this.scheduleRender();
|
|
1855
2081
|
}
|
|
1856
2082
|
|
|
1857
2083
|
// ========== Feature Visibility ==========
|
|
1858
|
-
|
|
2084
|
+
|
|
1859
2085
|
toggleFeatureVisibility(featureKey) {
|
|
1860
2086
|
if (this.hiddenFeatures.has(featureKey)) {
|
|
1861
2087
|
this.hiddenFeatures.delete(featureKey);
|
|
1862
2088
|
} else {
|
|
1863
2089
|
this.hiddenFeatures.add(featureKey);
|
|
1864
2090
|
}
|
|
1865
|
-
|
|
2091
|
+
|
|
1866
2092
|
// Use updateView - content didn't change, just visibility
|
|
1867
2093
|
this.updateView();
|
|
1868
2094
|
this.scheduleRender();
|
|
@@ -2216,173 +2442,164 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2216
2442
|
}
|
|
2217
2443
|
|
|
2218
2444
|
_buildContextMap() {
|
|
2445
|
+
// Memoization: return cached result if content hasn't changed
|
|
2446
|
+
const linesLength = this.lines.length;
|
|
2447
|
+
if (this._contextMapCache &&
|
|
2448
|
+
this._contextMapLinesLength === linesLength &&
|
|
2449
|
+
this._contextMapFirstLine === this.lines[0] &&
|
|
2450
|
+
this._contextMapLastLine === this.lines[linesLength - 1]) {
|
|
2451
|
+
return this._contextMapCache;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2219
2454
|
const contextMap = new Map();
|
|
2220
2455
|
const contextStack = [];
|
|
2221
2456
|
let pendingContext = null;
|
|
2222
|
-
|
|
2223
|
-
for (let i = 0; i <
|
|
2457
|
+
|
|
2458
|
+
for (let i = 0; i < linesLength; i++) {
|
|
2224
2459
|
const line = this.lines[i];
|
|
2225
2460
|
const currentContext = contextStack[contextStack.length - 1]?.context || 'Feature';
|
|
2226
2461
|
contextMap.set(i, currentContext);
|
|
2227
|
-
|
|
2462
|
+
|
|
2228
2463
|
// Check for context-changing keys
|
|
2229
|
-
if (
|
|
2230
|
-
else if (
|
|
2231
|
-
else if (
|
|
2232
|
-
|
|
2464
|
+
if (RE_CONTEXT_GEOMETRY.test(line)) pendingContext = 'geometry';
|
|
2465
|
+
else if (RE_CONTEXT_PROPERTIES.test(line)) pendingContext = 'properties';
|
|
2466
|
+
else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
|
|
2467
|
+
|
|
2233
2468
|
// Track brackets
|
|
2234
2469
|
const openBraces = (line.match(/\{/g) || []).length;
|
|
2235
2470
|
const closeBraces = (line.match(/\}/g) || []).length;
|
|
2236
2471
|
const openBrackets = (line.match(/\[/g) || []).length;
|
|
2237
2472
|
const closeBrackets = (line.match(/\]/g) || []).length;
|
|
2238
|
-
|
|
2473
|
+
|
|
2239
2474
|
for (let j = 0; j < openBraces + openBrackets; j++) {
|
|
2240
2475
|
contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
|
|
2241
2476
|
pendingContext = null;
|
|
2242
2477
|
}
|
|
2243
|
-
|
|
2478
|
+
|
|
2244
2479
|
for (let j = 0; j < closeBraces + closeBrackets && contextStack.length > 0; j++) {
|
|
2245
2480
|
contextStack.pop();
|
|
2246
2481
|
}
|
|
2247
2482
|
}
|
|
2248
|
-
|
|
2483
|
+
|
|
2484
|
+
// Cache the result
|
|
2485
|
+
this._contextMapCache = contextMap;
|
|
2486
|
+
this._contextMapLinesLength = linesLength;
|
|
2487
|
+
this._contextMapFirstLine = this.lines[0];
|
|
2488
|
+
this._contextMapLastLine = this.lines[linesLength - 1];
|
|
2489
|
+
|
|
2249
2490
|
return contextMap;
|
|
2250
2491
|
}
|
|
2251
2492
|
|
|
2252
2493
|
_highlightSyntax(text, context, meta) {
|
|
2253
2494
|
if (!text) return '';
|
|
2254
|
-
|
|
2495
|
+
|
|
2255
2496
|
// For collapsed nodes, truncate the text at the opening bracket
|
|
2256
2497
|
let displayText = text;
|
|
2257
2498
|
let collapsedBracket = null;
|
|
2258
|
-
|
|
2499
|
+
|
|
2259
2500
|
if (meta?.collapseButton?.isCollapsed) {
|
|
2260
2501
|
// Match "key": { or "key": [
|
|
2261
|
-
const bracketMatch = text.match(
|
|
2502
|
+
const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
|
|
2262
2503
|
// Also match standalone { or [ (root Feature objects)
|
|
2263
|
-
const rootMatch = !bracketMatch && text.match(
|
|
2264
|
-
|
|
2504
|
+
const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
|
|
2505
|
+
|
|
2265
2506
|
if (bracketMatch) {
|
|
2266
|
-
// Keep only the part up to and including the opening bracket
|
|
2267
2507
|
displayText = bracketMatch[1] + bracketMatch[2];
|
|
2268
2508
|
collapsedBracket = bracketMatch[2];
|
|
2269
2509
|
} else if (rootMatch) {
|
|
2270
|
-
// Root object - just keep the bracket
|
|
2271
2510
|
displayText = rootMatch[1] + rootMatch[2];
|
|
2272
2511
|
collapsedBracket = rootMatch[2];
|
|
2273
2512
|
}
|
|
2274
2513
|
}
|
|
2275
|
-
|
|
2514
|
+
|
|
2276
2515
|
// Escape HTML first
|
|
2277
2516
|
let result = displayText
|
|
2278
|
-
.replace(
|
|
2279
|
-
.replace(
|
|
2280
|
-
.replace(
|
|
2281
|
-
|
|
2517
|
+
.replace(RE_ESCAPE_AMP, '&')
|
|
2518
|
+
.replace(RE_ESCAPE_LT, '<')
|
|
2519
|
+
.replace(RE_ESCAPE_GT, '>');
|
|
2520
|
+
|
|
2282
2521
|
// Punctuation FIRST (before other replacements can interfere)
|
|
2283
|
-
result = result.replace(
|
|
2284
|
-
|
|
2522
|
+
result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
|
|
2523
|
+
|
|
2285
2524
|
// JSON keys - match "key" followed by :
|
|
2286
2525
|
// In properties context, all keys are treated as regular JSON keys
|
|
2287
|
-
|
|
2526
|
+
RE_JSON_KEYS.lastIndex = 0;
|
|
2527
|
+
result = result.replace(RE_JSON_KEYS, (match, key, colon) => {
|
|
2288
2528
|
if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
|
|
2289
2529
|
return `<span class="geojson-key">"${key}"</span>${colon}`;
|
|
2290
2530
|
}
|
|
2291
2531
|
return `<span class="json-key">"${key}"</span>${colon}`;
|
|
2292
2532
|
});
|
|
2293
|
-
|
|
2533
|
+
|
|
2294
2534
|
// Type values - "type": "Value" - but NOT inside properties context
|
|
2295
2535
|
if (context !== 'properties') {
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
}
|
|
2303
|
-
);
|
|
2536
|
+
RE_TYPE_VALUES.lastIndex = 0;
|
|
2537
|
+
result = result.replace(RE_TYPE_VALUES, (match, space, type) => {
|
|
2538
|
+
const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
|
|
2539
|
+
const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
|
|
2540
|
+
return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
|
|
2541
|
+
});
|
|
2304
2542
|
}
|
|
2305
|
-
|
|
2543
|
+
|
|
2306
2544
|
// String values (not already wrapped in spans)
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
(match
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
// Check if it's a color value (hex) - use ::before for swatch via CSS class
|
|
2314
|
-
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(val)) {
|
|
2315
|
-
return `${colon} <span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
|
|
2316
|
-
}
|
|
2317
|
-
|
|
2318
|
-
return `${colon} <span class="json-string">"${val}"</span>`;
|
|
2545
|
+
RE_STRING_VALUES.lastIndex = 0;
|
|
2546
|
+
result = result.replace(RE_STRING_VALUES, (match, colon, space, val) => {
|
|
2547
|
+
if (match.includes('geojson-type') || match.includes('json-string')) return match;
|
|
2548
|
+
if (RE_COLOR_HEX.test(val)) {
|
|
2549
|
+
return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
|
|
2319
2550
|
}
|
|
2320
|
-
|
|
2321
|
-
|
|
2551
|
+
return `${colon}${space}<span class="json-string">"${val}"</span>`;
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2322
2554
|
// Numbers after colon
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
);
|
|
2327
|
-
|
|
2555
|
+
RE_NUMBERS_COLON.lastIndex = 0;
|
|
2556
|
+
result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
|
|
2557
|
+
|
|
2328
2558
|
// Numbers in arrays (after [ or ,)
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
);
|
|
2333
|
-
|
|
2559
|
+
RE_NUMBERS_ARRAY.lastIndex = 0;
|
|
2560
|
+
result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
|
|
2561
|
+
|
|
2334
2562
|
// Standalone numbers at start of line (coordinates arrays)
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
);
|
|
2339
|
-
|
|
2563
|
+
RE_NUMBERS_START.lastIndex = 0;
|
|
2564
|
+
result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
|
|
2565
|
+
|
|
2340
2566
|
// Booleans - use ::before for checkbox via CSS class
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
);
|
|
2348
|
-
|
|
2567
|
+
RE_BOOLEANS.lastIndex = 0;
|
|
2568
|
+
result = result.replace(RE_BOOLEANS, (match, colon, space, val) => {
|
|
2569
|
+
const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
|
|
2570
|
+
return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2349
2573
|
// Null
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
// Collapsed bracket indicator - just add the class, CSS ::after adds the "...]" or "...}"
|
|
2574
|
+
RE_NULL.lastIndex = 0;
|
|
2575
|
+
result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
|
|
2576
|
+
|
|
2577
|
+
// Collapsed bracket indicator
|
|
2356
2578
|
if (collapsedBracket) {
|
|
2357
2579
|
const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
|
|
2358
|
-
// Replace the last punctuation span (the opening bracket) with collapsed style class
|
|
2359
2580
|
result = result.replace(
|
|
2360
2581
|
new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
|
|
2361
2582
|
`<span class="${bracketClass}">${collapsedBracket}</span>`
|
|
2362
2583
|
);
|
|
2363
2584
|
}
|
|
2364
|
-
|
|
2365
|
-
// Mark unrecognized text as error
|
|
2366
|
-
|
|
2367
|
-
result = result.replace(
|
|
2368
|
-
|
|
2369
|
-
(
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
}).join('');
|
|
2383
|
-
return hasError ? before + processed + after : match;
|
|
2384
|
-
}
|
|
2385
|
-
);
|
|
2585
|
+
|
|
2586
|
+
// Mark unrecognized text as error
|
|
2587
|
+
RE_UNRECOGNIZED.lastIndex = 0;
|
|
2588
|
+
result = result.replace(RE_UNRECOGNIZED, (match, before, text, after) => {
|
|
2589
|
+
if (!text || RE_WHITESPACE_ONLY.test(text)) return match;
|
|
2590
|
+
// Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
|
|
2591
|
+
// Keep whitespace as-is, wrap any non-whitespace unrecognized token
|
|
2592
|
+
const parts = text.split(RE_WHITESPACE_SPLIT);
|
|
2593
|
+
let hasError = false;
|
|
2594
|
+
const processed = parts.map(part => {
|
|
2595
|
+
// If it's whitespace, keep it
|
|
2596
|
+
if (RE_WHITESPACE_ONLY.test(part)) return part;
|
|
2597
|
+
// Mark as error
|
|
2598
|
+
hasError = true;
|
|
2599
|
+
return `<span class="json-error">${part}</span>`;
|
|
2600
|
+
}).join('');
|
|
2601
|
+
return hasError ? before + processed + after : match;
|
|
2602
|
+
});
|
|
2386
2603
|
|
|
2387
2604
|
// Note: visibility is now handled at line level (has-visibility class on .line element)
|
|
2388
2605
|
|
|
@@ -2441,6 +2658,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2441
2658
|
}
|
|
2442
2659
|
|
|
2443
2660
|
removeAll() {
|
|
2661
|
+
if (this.lines.length > 0) {
|
|
2662
|
+
this._saveToHistory('removeAll');
|
|
2663
|
+
}
|
|
2444
2664
|
const removed = this._parseFeatures();
|
|
2445
2665
|
this.lines = [];
|
|
2446
2666
|
this.collapsedNodes.clear();
|
|
@@ -2466,6 +2686,113 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2466
2686
|
this.emitChange();
|
|
2467
2687
|
}
|
|
2468
2688
|
|
|
2689
|
+
/**
|
|
2690
|
+
* Save GeoJSON to a file (triggers download)
|
|
2691
|
+
* @param {string} filename - Optional filename (default: 'features.geojson')
|
|
2692
|
+
* @returns {boolean} True if save was successful
|
|
2693
|
+
*/
|
|
2694
|
+
save(filename = 'features.geojson') {
|
|
2695
|
+
try {
|
|
2696
|
+
const features = this._parseFeatures();
|
|
2697
|
+
const geojson = {
|
|
2698
|
+
type: 'FeatureCollection',
|
|
2699
|
+
features: features
|
|
2700
|
+
};
|
|
2701
|
+
const json = JSON.stringify(geojson, null, 2);
|
|
2702
|
+
const blob = new Blob([json], { type: 'application/geo+json' });
|
|
2703
|
+
const url = URL.createObjectURL(blob);
|
|
2704
|
+
|
|
2705
|
+
const a = document.createElement('a');
|
|
2706
|
+
a.href = url;
|
|
2707
|
+
a.download = filename;
|
|
2708
|
+
document.body.appendChild(a);
|
|
2709
|
+
a.click();
|
|
2710
|
+
document.body.removeChild(a);
|
|
2711
|
+
URL.revokeObjectURL(url);
|
|
2712
|
+
|
|
2713
|
+
return true;
|
|
2714
|
+
} catch (e) {
|
|
2715
|
+
return false;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/**
|
|
2720
|
+
* Open a GeoJSON file from the client filesystem
|
|
2721
|
+
* Note: Available even in readonly mode via API (only Ctrl+O shortcut is blocked)
|
|
2722
|
+
* @returns {Promise<boolean>} Promise that resolves to true if file was loaded successfully
|
|
2723
|
+
*/
|
|
2724
|
+
open() {
|
|
2725
|
+
return new Promise((resolve) => {
|
|
2726
|
+
const input = document.createElement('input');
|
|
2727
|
+
input.type = 'file';
|
|
2728
|
+
input.accept = '.geojson,.json,application/geo+json,application/json';
|
|
2729
|
+
input.style.display = 'none';
|
|
2730
|
+
|
|
2731
|
+
input.addEventListener('change', (e) => {
|
|
2732
|
+
const file = e.target.files?.[0];
|
|
2733
|
+
if (!file) {
|
|
2734
|
+
document.body.removeChild(input);
|
|
2735
|
+
resolve(false);
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
const reader = new FileReader();
|
|
2740
|
+
reader.onload = (event) => {
|
|
2741
|
+
try {
|
|
2742
|
+
const content = event.target.result;
|
|
2743
|
+
const parsed = JSON.parse(content);
|
|
2744
|
+
|
|
2745
|
+
// Extract features from various GeoJSON formats
|
|
2746
|
+
let features = [];
|
|
2747
|
+
if (parsed.type === 'FeatureCollection' && Array.isArray(parsed.features)) {
|
|
2748
|
+
features = parsed.features;
|
|
2749
|
+
} else if (parsed.type === 'Feature') {
|
|
2750
|
+
features = [parsed];
|
|
2751
|
+
} else if (Array.isArray(parsed)) {
|
|
2752
|
+
features = parsed;
|
|
2753
|
+
} else {
|
|
2754
|
+
// Invalid GeoJSON structure
|
|
2755
|
+
document.body.removeChild(input);
|
|
2756
|
+
resolve(false);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// Validate features
|
|
2761
|
+
for (const feature of features) {
|
|
2762
|
+
this._validateFeature(feature);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
// Load features into editor
|
|
2766
|
+
this._saveToHistory('open');
|
|
2767
|
+
this.set(features);
|
|
2768
|
+
this.clearHistory(); // Clear history after opening new file
|
|
2769
|
+
document.body.removeChild(input);
|
|
2770
|
+
resolve(true);
|
|
2771
|
+
} catch (err) {
|
|
2772
|
+
document.body.removeChild(input);
|
|
2773
|
+
resolve(false);
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
|
|
2777
|
+
reader.onerror = () => {
|
|
2778
|
+
document.body.removeChild(input);
|
|
2779
|
+
resolve(false);
|
|
2780
|
+
};
|
|
2781
|
+
|
|
2782
|
+
reader.readAsText(file);
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
// Handle cancel (no file selected)
|
|
2786
|
+
input.addEventListener('cancel', () => {
|
|
2787
|
+
document.body.removeChild(input);
|
|
2788
|
+
resolve(false);
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
document.body.appendChild(input);
|
|
2792
|
+
input.click();
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2469
2796
|
_parseFeatures() {
|
|
2470
2797
|
try {
|
|
2471
2798
|
const content = this.lines.join('\n');
|