@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.
@@ -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._lastStartIndex = -1;
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._lastStartIndex = -1;
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._lastStartIndex = -1; // Force re-render to show cursor
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._lastStartIndex = -1; // Force re-render to hide cursor
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._lastStartIndex = -1;
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
- ctx.font = '13px monospace';
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
- // Check if cursor is in a collapsed zone
1072
- const inCollapsedZone = this._getCollapsedRangeForLine(this.cursorLine);
1073
- const onCollapsedNode = this._getCollapsedNodeAtLine(this.cursorLine);
1074
- const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
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
- // Block in collapsed zones
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
- // Delete selection if any
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
- // Delete selection if any
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 'Tab':
1205
- e.preventDefault();
1206
-
1207
- // Shift+Tab: collapse the containing expanded node
1208
- if (e.shiftKey) {
1209
- const containingNode = this._getContainingExpandedNode(this.cursorLine);
1210
- if (containingNode) {
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
- // Tab: expand collapsed node if on one
1227
- if (onCollapsedNode) {
1228
- this.toggleCollapse(onCollapsedNode.nodeId);
1229
- return;
1325
+ break;
1326
+ case 'y':
1327
+ if (e.ctrlKey || e.metaKey) {
1328
+ e.preventDefault();
1329
+ this.redo();
1230
1330
  }
1231
- if (onClosingLine) {
1232
- this.toggleCollapse(onClosingLine.nodeId);
1233
- return;
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._lastStartIndex = -1;
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 (delta > 0) {
1334
- // Moving right
1335
- if (onClosingLine) {
1336
- const bracketPos = this._getClosingBracketPos(line);
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
- // Moving left
1381
- if (onClosingLine) {
1382
- const bracketPos = this._getClosingBracketPos(line);
1383
- if (this.cursorColumn > bracketPos + 1) {
1384
- // After bracket, move normally
1385
- this.cursorColumn--;
1386
- } else if (this.cursorColumn === bracketPos + 1) {
1387
- // Just after bracket, jump to opening line after bracket
1388
- this.cursorLine = onClosingLine.startLine;
1389
- const openLine = this.lines[this.cursorLine];
1390
- const openBracketPos = openLine.search(/[{\[]/);
1391
- this.cursorColumn = openBracketPos + 1;
1392
- } else {
1393
- // On bracket, jump to opening line after bracket
1394
- this.cursorLine = onClosingLine.startLine;
1395
- const openLine = this.lines[this.cursorLine];
1396
- const openBracketPos = openLine.search(/[{\[]/);
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
- // Check if previous line is closing line of collapsed
1424
- const closing = this._getCollapsedClosingLine(this.cursorLine);
1425
- if (closing) {
1426
- // Go to end of closing line
1427
- this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
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
- // Check if previous line is inside collapsed zone
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._lastStartIndex = -1;
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._lastStartIndex = -1;
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
- this.lines = [text];
1672
- this.cursorColumn = text.length;
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 new content
1687
- requestAnimationFrame(() => {
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
- const paddingLeft = 12;
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
- const rawColumn = Math.round(x / charWidth);
1743
- const lineLength = lineData.content?.length || 0;
1744
- column = Math.max(0, Math.min(rawColumn, lineLength));
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
- this.toggleFeatureVisibility(lineEl.dataset.featureKey);
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._lastStartIndex = -1; // Force re-render
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
- // Use updateView since nodeIds were just assigned by updateModel/setValue
1853
- this.updateView();
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 < this.lines.length; 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 (/"geometry"\s*:/.test(line)) pendingContext = 'geometry';
2230
- else if (/"properties"\s*:/.test(line)) pendingContext = 'properties';
2231
- else if (/"features"\s*:/.test(line)) pendingContext = 'Feature';
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(/^(\s*"[^"]+"\s*:\s*)([{\[])/);
2502
+ const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
2262
2503
  // Also match standalone { or [ (root Feature objects)
2263
- const rootMatch = !bracketMatch && text.match(/^(\s*)([{\[]),?\s*$/);
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(/&/g, '&amp;')
2279
- .replace(/</g, '&lt;')
2280
- .replace(/>/g, '&gt;');
2281
-
2517
+ .replace(RE_ESCAPE_AMP, '&amp;')
2518
+ .replace(RE_ESCAPE_LT, '&lt;')
2519
+ .replace(RE_ESCAPE_GT, '&gt;');
2520
+
2282
2521
  // Punctuation FIRST (before other replacements can interfere)
2283
- result = result.replace(/([{}[\],:])/g, '<span class="json-punctuation">$1</span>');
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
- result = result.replace(/"([^"]+)"(<span class="json-punctuation">:<\/span>)/g, (match, key, colon) => {
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
- result = result.replace(
2297
- /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>\s*"([^"]*)"/g,
2298
- (match, type) => {
2299
- const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
2300
- const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
2301
- return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span> <span class="${cls}">"${type}"</span>`;
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
- result = result.replace(
2308
- /(<span class="json-punctuation">:<\/span>)\s*"([^"]*)"/g,
2309
- (match, colon, val) => {
2310
- // Don't double-wrap if already has a span after colon
2311
- if (match.includes('geojson-type') || match.includes('json-string')) return match;
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
- result = result.replace(
2324
- /(<span class="json-punctuation">:<\/span>)\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2325
- '$1 <span class="json-number">$2</span>'
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
- result = result.replace(
2330
- /(<span class="json-punctuation">[\[,]<\/span>)\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2331
- '$1<span class="json-number">$2</span>'
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
- result = result.replace(
2336
- /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim,
2337
- '$1<span class="json-number">$2</span>'
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
- result = result.replace(
2342
- /(<span class="json-punctuation">:<\/span>)\s*(true|false)/g,
2343
- (match, colon, val) => {
2344
- const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
2345
- return `${colon} <span class="json-boolean${checkedClass}">${val}</span>`;
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
- result = result.replace(
2351
- /(<span class="json-punctuation">:<\/span>)\s*(null)/g,
2352
- '$1 <span class="json-null">$2</span>'
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 - text that's not inside a span and not just whitespace
2366
- // This catches invalid JSON like unquoted strings, malformed values, etc.
2367
- result = result.replace(
2368
- /(<\/span>|^)([^<]+)(<span|$)/g,
2369
- (match, before, text, after) => {
2370
- // Skip if text is only whitespace or empty
2371
- if (!text || /^\s*$/.test(text)) return match;
2372
- // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
2373
- // Keep whitespace as-is, wrap any non-whitespace unrecognized token
2374
- const parts = text.split(/(\s+)/);
2375
- let hasError = false;
2376
- const processed = parts.map(part => {
2377
- // If it's whitespace, keep it
2378
- if (/^\s*$/.test(part)) return part;
2379
- // Mark as error
2380
- hasError = true;
2381
- return `<span class="json-error">${part}</span>`;
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');