@softwarity/geojson-editor 1.0.14 → 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
 
@@ -391,7 +557,7 @@ class GeoJsonEditor extends HTMLElement {
391
557
 
392
558
  // Focus textarea
393
559
  hiddenTextarea.focus();
394
- this._lastStartIndex = -1;
560
+ this._invalidateRenderCache();
395
561
  this.scheduleRender();
396
562
  });
397
563
 
@@ -417,7 +583,7 @@ class GeoJsonEditor extends HTMLElement {
417
583
  viewport.scrollTop += scrollSpeed;
418
584
  }
419
585
 
420
- this._lastStartIndex = -1;
586
+ this._invalidateRenderCache();
421
587
  this.scheduleRender();
422
588
  });
423
589
 
@@ -429,13 +595,13 @@ class GeoJsonEditor extends HTMLElement {
429
595
  // Focus/blur handling to show/hide cursor
430
596
  hiddenTextarea.addEventListener('focus', () => {
431
597
  editorWrapper.classList.add('focused');
432
- this._lastStartIndex = -1; // Force re-render to show cursor
598
+ this._invalidateRenderCache(); // Force re-render to show cursor
433
599
  this.scheduleRender();
434
600
  });
435
601
 
436
602
  hiddenTextarea.addEventListener('blur', () => {
437
603
  editorWrapper.classList.remove('focused');
438
- this._lastStartIndex = -1; // Force re-render to hide cursor
604
+ this._invalidateRenderCache(); // Force re-render to hide cursor
439
605
  this.scheduleRender();
440
606
  });
441
607
 
@@ -443,7 +609,6 @@ class GeoJsonEditor extends HTMLElement {
443
609
  let isRendering = false;
444
610
  viewport.addEventListener('scroll', () => {
445
611
  if (isRendering) return;
446
- this.scrollTop = viewport.scrollTop;
447
612
  this.syncGutterScroll();
448
613
 
449
614
  // Use requestAnimationFrame to batch scroll updates
@@ -525,6 +690,11 @@ class GeoJsonEditor extends HTMLElement {
525
690
  * Set the editor content from a string value
526
691
  */
527
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
+
528
698
  if (!value || !value.trim()) {
529
699
  this.lines = [];
530
700
  } else {
@@ -541,7 +711,7 @@ class GeoJsonEditor extends HTMLElement {
541
711
  this.lines = value.split('\n');
542
712
  }
543
713
  }
544
-
714
+
545
715
  // Clear state for new content
546
716
  this.collapsedNodes.clear();
547
717
  this.hiddenFeatures.clear();
@@ -576,9 +746,12 @@ class GeoJsonEditor extends HTMLElement {
576
746
  * Rebuilds line-to-nodeId mapping while preserving collapsed state
577
747
  */
578
748
  updateModel() {
749
+ // Invalidate context map cache since content changed
750
+ this._contextMapCache = null;
751
+
579
752
  // Rebuild lineToNodeId mapping (may shift due to edits)
580
753
  this._rebuildNodeIdMappings();
581
-
754
+
582
755
  this.computeFeatureRanges();
583
756
  this.computeLineMetadata();
584
757
  this.computeVisibleLines();
@@ -757,7 +930,7 @@ class GeoJsonEditor extends HTMLElement {
757
930
  }
758
931
 
759
932
  // Reset render cache to force re-render
760
- this._lastStartIndex = -1;
933
+ this._invalidateRenderCache();
761
934
  this._lastEndIndex = -1;
762
935
  this._lastTotalLines = -1;
763
936
  }
@@ -1090,106 +1263,25 @@ class GeoJsonEditor extends HTMLElement {
1090
1263
  }
1091
1264
 
1092
1265
  handleKeydown(e) {
1093
- // Check if cursor is in a collapsed zone
1094
- const inCollapsedZone = this._getCollapsedRangeForLine(this.cursorLine);
1095
- const onCollapsedNode = this._getCollapsedNodeAtLine(this.cursorLine);
1096
- const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1097
-
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
+
1098
1273
  switch (e.key) {
1099
1274
  case 'Enter':
1100
1275
  e.preventDefault();
1101
- // Block in collapsed zones
1102
- if (onCollapsedNode || inCollapsedZone) return;
1103
- // On closing line, before bracket -> block
1104
- if (onClosingLine) {
1105
- const line = this.lines[this.cursorLine];
1106
- const bracketPos = this._getClosingBracketPos(line);
1107
- if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
1108
- return;
1109
- }
1110
- // After bracket, allow normal enter (add new line)
1111
- }
1112
- this.insertNewline();
1276
+ this._handleEnter(ctx);
1113
1277
  break;
1114
1278
  case 'Backspace':
1115
1279
  e.preventDefault();
1116
- // Delete selection if any
1117
- if (this._hasSelection()) {
1118
- this._deleteSelection();
1119
- this.formatAndUpdate();
1120
- return;
1121
- }
1122
- // On closing line
1123
- if (onClosingLine) {
1124
- const line = this.lines[this.cursorLine];
1125
- const bracketPos = this._getClosingBracketPos(line);
1126
- if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
1127
- // After bracket, allow delete
1128
- this.deleteBackward();
1129
- return;
1130
- } else if (this.cursorColumn === bracketPos + 1) {
1131
- // Just after bracket, delete whole node
1132
- this._deleteCollapsedNode(onClosingLine);
1133
- return;
1134
- }
1135
- // On or before bracket, delete whole node
1136
- this._deleteCollapsedNode(onClosingLine);
1137
- return;
1138
- }
1139
- // If on collapsed node opening line at position 0, delete whole node
1140
- if (onCollapsedNode && this.cursorColumn === 0) {
1141
- this._deleteCollapsedNode(onCollapsedNode);
1142
- return;
1143
- }
1144
- // Block inside collapsed zones
1145
- if (inCollapsedZone) return;
1146
- // On opening line, allow editing before and at bracket
1147
- if (onCollapsedNode) {
1148
- const line = this.lines[this.cursorLine];
1149
- const bracketPos = line.search(/[{\[]/);
1150
- if (this.cursorColumn > bracketPos + 1) {
1151
- // After bracket, delete whole node
1152
- this._deleteCollapsedNode(onCollapsedNode);
1153
- return;
1154
- }
1155
- }
1156
- this.deleteBackward();
1280
+ this._handleBackspace(ctx);
1157
1281
  break;
1158
1282
  case 'Delete':
1159
1283
  e.preventDefault();
1160
- // Delete selection if any
1161
- if (this._hasSelection()) {
1162
- this._deleteSelection();
1163
- this.formatAndUpdate();
1164
- return;
1165
- }
1166
- // On closing line
1167
- if (onClosingLine) {
1168
- const line = this.lines[this.cursorLine];
1169
- const bracketPos = this._getClosingBracketPos(line);
1170
- if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
1171
- // After bracket, allow delete
1172
- this.deleteForward();
1173
- return;
1174
- }
1175
- // On or before bracket, delete whole node
1176
- this._deleteCollapsedNode(onClosingLine);
1177
- return;
1178
- }
1179
- // If on collapsed node opening line
1180
- if (onCollapsedNode) {
1181
- const line = this.lines[this.cursorLine];
1182
- const bracketPos = line.search(/[{\[]/);
1183
- if (this.cursorColumn > bracketPos) {
1184
- // After bracket, delete whole node
1185
- this._deleteCollapsedNode(onCollapsedNode);
1186
- return;
1187
- }
1188
- // Before bracket, allow editing key name
1189
- }
1190
- // Block inside collapsed zones
1191
- if (inCollapsedZone) return;
1192
- this.deleteForward();
1284
+ this._handleDelete(ctx);
1193
1285
  break;
1194
1286
  case 'ArrowUp':
1195
1287
  e.preventDefault();
@@ -1209,64 +1301,169 @@ class GeoJsonEditor extends HTMLElement {
1209
1301
  break;
1210
1302
  case 'Home':
1211
1303
  e.preventDefault();
1212
- this._handleHomeEnd('home', e.shiftKey, onClosingLine);
1304
+ this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine);
1213
1305
  break;
1214
1306
  case 'End':
1215
1307
  e.preventDefault();
1216
- this._handleHomeEnd('end', e.shiftKey, onClosingLine);
1308
+ this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine);
1217
1309
  break;
1218
1310
  case 'a':
1219
- // Ctrl+A or Cmd+A: select all
1220
1311
  if (e.ctrlKey || e.metaKey) {
1221
1312
  e.preventDefault();
1222
1313
  this._selectAll();
1223
- return;
1224
1314
  }
1225
1315
  break;
1226
- case 'Tab':
1227
- e.preventDefault();
1228
-
1229
- // Shift+Tab: collapse the containing expanded node
1230
- if (e.shiftKey) {
1231
- const containingNode = this._getContainingExpandedNode(this.cursorLine);
1232
- if (containingNode) {
1233
- // Find the position just after the opening bracket
1234
- const startLine = this.lines[containingNode.startLine];
1235
- const bracketPos = startLine.search(/[{\[]/);
1236
-
1237
- this.toggleCollapse(containingNode.nodeId);
1238
-
1239
- // Move cursor to just after the opening bracket
1240
- this.cursorLine = containingNode.startLine;
1241
- this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
1242
- this._clearSelection();
1243
- 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();
1244
1323
  }
1245
- return;
1246
1324
  }
1247
-
1248
- // Tab: expand collapsed node if on one
1249
- if (onCollapsedNode) {
1250
- this.toggleCollapse(onCollapsedNode.nodeId);
1251
- return;
1325
+ break;
1326
+ case 'y':
1327
+ if (e.ctrlKey || e.metaKey) {
1328
+ e.preventDefault();
1329
+ this.redo();
1252
1330
  }
1253
- if (onClosingLine) {
1254
- this.toggleCollapse(onClosingLine.nodeId);
1255
- 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();
1256
1342
  }
1257
-
1258
- // Block in hidden collapsed zones
1259
- if (inCollapsedZone) return;
1343
+ break;
1344
+ case 'Tab':
1345
+ e.preventDefault();
1346
+ this._handleTab(e.shiftKey, ctx);
1260
1347
  break;
1261
1348
  }
1262
1349
  }
1263
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);
1456
+ }
1457
+ }
1458
+
1264
1459
  insertNewline() {
1460
+ this._saveToHistory('newline');
1461
+
1265
1462
  if (this.cursorLine < this.lines.length) {
1266
1463
  const line = this.lines[this.cursorLine];
1267
1464
  const before = line.substring(0, this.cursorColumn);
1268
1465
  const after = line.substring(this.cursorColumn);
1269
-
1466
+
1270
1467
  this.lines[this.cursorLine] = before;
1271
1468
  this.lines.splice(this.cursorLine + 1, 0, after);
1272
1469
  this.cursorLine++;
@@ -1276,11 +1473,13 @@ class GeoJsonEditor extends HTMLElement {
1276
1473
  this.cursorLine = this.lines.length - 1;
1277
1474
  this.cursorColumn = 0;
1278
1475
  }
1279
-
1476
+
1280
1477
  this.formatAndUpdate();
1281
1478
  }
1282
1479
 
1283
1480
  deleteBackward() {
1481
+ this._saveToHistory('delete');
1482
+
1284
1483
  if (this.cursorColumn > 0) {
1285
1484
  const line = this.lines[this.cursorLine];
1286
1485
  this.lines[this.cursorLine] = line.substring(0, this.cursorColumn - 1) + line.substring(this.cursorColumn);
@@ -1294,11 +1493,13 @@ class GeoJsonEditor extends HTMLElement {
1294
1493
  this.lines.splice(this.cursorLine, 1);
1295
1494
  this.cursorLine--;
1296
1495
  }
1297
-
1496
+
1298
1497
  this.formatAndUpdate();
1299
1498
  }
1300
1499
 
1301
1500
  deleteForward() {
1501
+ this._saveToHistory('delete');
1502
+
1302
1503
  if (this.cursorLine < this.lines.length) {
1303
1504
  const line = this.lines[this.cursorLine];
1304
1505
  if (this.cursorColumn < line.length) {
@@ -1309,7 +1510,7 @@ class GeoJsonEditor extends HTMLElement {
1309
1510
  this.lines.splice(this.cursorLine + 1, 1);
1310
1511
  }
1311
1512
  }
1312
-
1513
+
1313
1514
  this.formatAndUpdate();
1314
1515
  }
1315
1516
 
@@ -1339,7 +1540,7 @@ class GeoJsonEditor extends HTMLElement {
1339
1540
  const maxCol = this.lines[this.cursorLine]?.length || 0;
1340
1541
  this.cursorColumn = Math.min(this.cursorColumn, maxCol);
1341
1542
 
1342
- this._lastStartIndex = -1;
1543
+ this._invalidateRenderCache();
1343
1544
  this._scrollToCursor();
1344
1545
  this.scheduleRender();
1345
1546
  }
@@ -1348,124 +1549,103 @@ class GeoJsonEditor extends HTMLElement {
1348
1549
  * Move cursor horizontally with smart navigation around collapsed nodes
1349
1550
  */
1350
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() {
1351
1563
  const line = this.lines[this.cursorLine];
1352
1564
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1353
1565
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1354
-
1355
- if (delta > 0) {
1356
- // Moving right
1357
- if (onClosingLine) {
1358
- const bracketPos = this._getClosingBracketPos(line);
1359
- if (this.cursorColumn < bracketPos) {
1360
- // Before bracket, jump to bracket
1361
- this.cursorColumn = bracketPos;
1362
- } else if (this.cursorColumn >= line.length) {
1363
- // At end, go to next line
1364
- if (this.cursorLine < this.lines.length - 1) {
1365
- this.cursorLine++;
1366
- this.cursorColumn = 0;
1367
- }
1368
- } else {
1369
- // On or after bracket, move normally
1370
- this.cursorColumn++;
1371
- }
1372
- } else if (onCollapsed) {
1373
- const bracketPos = line.search(/[{\[]/);
1374
- if (this.cursorColumn < bracketPos) {
1375
- // Before bracket, move normally
1376
- this.cursorColumn++;
1377
- } else if (this.cursorColumn === bracketPos) {
1378
- // On bracket, go to after bracket
1379
- this.cursorColumn = bracketPos + 1;
1380
- } else {
1381
- // After bracket, jump to closing line at bracket
1382
- this.cursorLine = onCollapsed.endLine;
1383
- const closingLine = this.lines[this.cursorLine];
1384
- this.cursorColumn = this._getClosingBracketPos(closingLine);
1385
- }
1566
+
1567
+ if (onClosingLine) {
1568
+ const bracketPos = this._getClosingBracketPos(line);
1569
+ if (this.cursorColumn < bracketPos) {
1570
+ this.cursorColumn = bracketPos;
1386
1571
  } else if (this.cursorColumn >= line.length) {
1387
- // Move to next line
1388
1572
  if (this.cursorLine < this.lines.length - 1) {
1389
1573
  this.cursorLine++;
1390
1574
  this.cursorColumn = 0;
1391
- // Skip hidden collapsed zones
1392
- const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
1393
- if (collapsed) {
1394
- this.cursorLine = collapsed.endLine;
1395
- this.cursorColumn = 0;
1396
- }
1397
1575
  }
1398
1576
  } else {
1399
1577
  this.cursorColumn++;
1400
1578
  }
1401
- } else {
1402
- // Moving left
1403
- if (onClosingLine) {
1404
- const bracketPos = this._getClosingBracketPos(line);
1405
- if (this.cursorColumn > bracketPos + 1) {
1406
- // After bracket, move normally
1407
- this.cursorColumn--;
1408
- } else if (this.cursorColumn === bracketPos + 1) {
1409
- // Just after bracket, jump to opening line after bracket
1410
- this.cursorLine = onClosingLine.startLine;
1411
- const openLine = this.lines[this.cursorLine];
1412
- const openBracketPos = openLine.search(/[{\[]/);
1413
- this.cursorColumn = openBracketPos + 1;
1414
- } else {
1415
- // On bracket, jump to opening line after bracket
1416
- this.cursorLine = onClosingLine.startLine;
1417
- const openLine = this.lines[this.cursorLine];
1418
- const openBracketPos = openLine.search(/[{\[]/);
1419
- this.cursorColumn = openBracketPos + 1;
1420
- }
1421
- } else if (onCollapsed) {
1422
- const bracketPos = line.search(/[{\[]/);
1423
- if (this.cursorColumn > bracketPos + 1) {
1424
- // After bracket, go to just after bracket
1425
- this.cursorColumn = bracketPos + 1;
1426
- } else if (this.cursorColumn === bracketPos + 1) {
1427
- // Just after bracket, go to bracket
1428
- this.cursorColumn = bracketPos;
1429
- } else if (this.cursorColumn > 0) {
1430
- // Before bracket, move normally
1431
- this.cursorColumn--;
1432
- } else {
1433
- // At start, go to previous line
1434
- if (this.cursorLine > 0) {
1435
- this.cursorLine--;
1436
- this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1437
- }
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;
1438
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;
1439
1625
  } else if (this.cursorColumn > 0) {
1440
1626
  this.cursorColumn--;
1441
1627
  } else if (this.cursorLine > 0) {
1442
- // Move to previous line
1443
1628
  this.cursorLine--;
1444
-
1445
- // Check if previous line is closing line of collapsed
1446
- const closing = this._getCollapsedClosingLine(this.cursorLine);
1447
- if (closing) {
1448
- // Go to end of closing line
1449
- 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;
1450
1644
  } else {
1451
- // Check if previous line is inside collapsed zone
1452
- const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
1453
- if (collapsed) {
1454
- // Jump to opening line after bracket
1455
- this.cursorLine = collapsed.startLine;
1456
- const openLine = this.lines[this.cursorLine];
1457
- const bracketPos = openLine.search(/[{\[]/);
1458
- this.cursorColumn = bracketPos + 1;
1459
- } else {
1460
- this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1461
- }
1645
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1462
1646
  }
1463
1647
  }
1464
1648
  }
1465
-
1466
- this._lastStartIndex = -1;
1467
- this._scrollToCursor();
1468
- this.scheduleRender();
1469
1649
  }
1470
1650
 
1471
1651
  /**
@@ -1493,17 +1673,6 @@ class GeoJsonEditor extends HTMLElement {
1493
1673
  }
1494
1674
  }
1495
1675
 
1496
- /**
1497
- * Legacy moveCursor for compatibility
1498
- */
1499
- moveCursor(deltaLine, deltaCol) {
1500
- if (deltaLine !== 0) {
1501
- this.moveCursorSkipCollapsed(deltaLine);
1502
- } else if (deltaCol !== 0) {
1503
- this.moveCursorHorizontal(deltaCol);
1504
- }
1505
- }
1506
-
1507
1676
  /**
1508
1677
  * Handle arrow key with optional selection
1509
1678
  */
@@ -1558,7 +1727,7 @@ class GeoJsonEditor extends HTMLElement {
1558
1727
  this.selectionEnd = null;
1559
1728
  }
1560
1729
 
1561
- this._lastStartIndex = -1;
1730
+ this._invalidateRenderCache();
1562
1731
  this._scrollToCursor();
1563
1732
  this.scheduleRender();
1564
1733
  }
@@ -1573,7 +1742,7 @@ class GeoJsonEditor extends HTMLElement {
1573
1742
  this.cursorLine = lastLine;
1574
1743
  this.cursorColumn = this.lines[lastLine]?.length || 0;
1575
1744
 
1576
- this._lastStartIndex = -1;
1745
+ this._invalidateRenderCache();
1577
1746
  this._scrollToCursor();
1578
1747
  this.scheduleRender();
1579
1748
  }
@@ -1640,9 +1809,11 @@ class GeoJsonEditor extends HTMLElement {
1640
1809
  */
1641
1810
  _deleteSelection() {
1642
1811
  if (!this._hasSelection()) return false;
1643
-
1812
+
1813
+ this._saveToHistory('delete');
1814
+
1644
1815
  const { start, end } = this._normalizeSelection();
1645
-
1816
+
1646
1817
  if (start.line === end.line) {
1647
1818
  // Single line selection
1648
1819
  const line = this.lines[start.line];
@@ -1654,12 +1825,12 @@ class GeoJsonEditor extends HTMLElement {
1654
1825
  this.lines[start.line] = startLine + endLine;
1655
1826
  this.lines.splice(start.line + 1, end.line - start.line);
1656
1827
  }
1657
-
1828
+
1658
1829
  this.cursorLine = start.line;
1659
1830
  this.cursorColumn = start.column;
1660
1831
  this.selectionStart = null;
1661
1832
  this.selectionEnd = null;
1662
-
1833
+
1663
1834
  return true;
1664
1835
  }
1665
1836
 
@@ -1668,10 +1839,10 @@ class GeoJsonEditor extends HTMLElement {
1668
1839
  if (this._hasSelection()) {
1669
1840
  this._deleteSelection();
1670
1841
  }
1671
-
1842
+
1672
1843
  // Block insertion in hidden collapsed zones
1673
1844
  if (this._getCollapsedRangeForLine(this.cursorLine)) return;
1674
-
1845
+
1675
1846
  // On closing line, only allow after bracket
1676
1847
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1677
1848
  if (onClosingLine) {
@@ -1679,7 +1850,7 @@ class GeoJsonEditor extends HTMLElement {
1679
1850
  const bracketPos = this._getClosingBracketPos(line);
1680
1851
  if (this.cursorColumn <= bracketPos) return;
1681
1852
  }
1682
-
1853
+
1683
1854
  // On collapsed opening line, only allow before bracket
1684
1855
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1685
1856
  if (onCollapsed) {
@@ -1687,7 +1858,10 @@ class GeoJsonEditor extends HTMLElement {
1687
1858
  const bracketPos = line.search(/[{\[]/);
1688
1859
  if (this.cursorColumn > bracketPos) return;
1689
1860
  }
1690
-
1861
+
1862
+ // Save to history before making changes
1863
+ this._saveToHistory('insert');
1864
+
1691
1865
  // Handle empty editor case
1692
1866
  if (this.lines.length === 0) {
1693
1867
  // Split text by newlines to properly handle multi-line paste
@@ -1735,11 +1909,13 @@ class GeoJsonEditor extends HTMLElement {
1735
1909
  e.preventDefault();
1736
1910
  if (this._hasSelection()) {
1737
1911
  e.clipboardData.setData('text/plain', this._getSelectedText());
1912
+ this._saveToHistory('cut');
1738
1913
  this._deleteSelection();
1739
1914
  this.formatAndUpdate();
1740
1915
  } else {
1741
1916
  // Cut all content
1742
1917
  e.clipboardData.setData('text/plain', this.getContent());
1918
+ this._saveToHistory('cut');
1743
1919
  this.lines = [];
1744
1920
  this.cursorLine = 0;
1745
1921
  this.cursorColumn = 0;
@@ -1885,7 +2061,7 @@ class GeoJsonEditor extends HTMLElement {
1885
2061
 
1886
2062
  // Use updateView - don't rebuild nodeId mappings since content didn't change
1887
2063
  this.updateView();
1888
- this._lastStartIndex = -1; // Force re-render
2064
+ this._invalidateRenderCache(); // Force re-render
1889
2065
  this.scheduleRender();
1890
2066
  }
1891
2067
 
@@ -2266,178 +2442,164 @@ class GeoJsonEditor extends HTMLElement {
2266
2442
  }
2267
2443
 
2268
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
+
2269
2454
  const contextMap = new Map();
2270
2455
  const contextStack = [];
2271
2456
  let pendingContext = null;
2272
-
2273
- for (let i = 0; i < this.lines.length; i++) {
2457
+
2458
+ for (let i = 0; i < linesLength; i++) {
2274
2459
  const line = this.lines[i];
2275
2460
  const currentContext = contextStack[contextStack.length - 1]?.context || 'Feature';
2276
2461
  contextMap.set(i, currentContext);
2277
-
2462
+
2278
2463
  // Check for context-changing keys
2279
- if (/"geometry"\s*:/.test(line)) pendingContext = 'geometry';
2280
- else if (/"properties"\s*:/.test(line)) pendingContext = 'properties';
2281
- else if (/"features"\s*:/.test(line)) pendingContext = 'Feature';
2282
-
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
+
2283
2468
  // Track brackets
2284
2469
  const openBraces = (line.match(/\{/g) || []).length;
2285
2470
  const closeBraces = (line.match(/\}/g) || []).length;
2286
2471
  const openBrackets = (line.match(/\[/g) || []).length;
2287
2472
  const closeBrackets = (line.match(/\]/g) || []).length;
2288
-
2473
+
2289
2474
  for (let j = 0; j < openBraces + openBrackets; j++) {
2290
2475
  contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
2291
2476
  pendingContext = null;
2292
2477
  }
2293
-
2478
+
2294
2479
  for (let j = 0; j < closeBraces + closeBrackets && contextStack.length > 0; j++) {
2295
2480
  contextStack.pop();
2296
2481
  }
2297
2482
  }
2298
-
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
+
2299
2490
  return contextMap;
2300
2491
  }
2301
2492
 
2302
2493
  _highlightSyntax(text, context, meta) {
2303
2494
  if (!text) return '';
2304
-
2495
+
2305
2496
  // For collapsed nodes, truncate the text at the opening bracket
2306
2497
  let displayText = text;
2307
2498
  let collapsedBracket = null;
2308
-
2499
+
2309
2500
  if (meta?.collapseButton?.isCollapsed) {
2310
2501
  // Match "key": { or "key": [
2311
- const bracketMatch = text.match(/^(\s*"[^"]+"\s*:\s*)([{\[])/);
2502
+ const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
2312
2503
  // Also match standalone { or [ (root Feature objects)
2313
- const rootMatch = !bracketMatch && text.match(/^(\s*)([{\[]),?\s*$/);
2314
-
2504
+ const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
2505
+
2315
2506
  if (bracketMatch) {
2316
- // Keep only the part up to and including the opening bracket
2317
2507
  displayText = bracketMatch[1] + bracketMatch[2];
2318
2508
  collapsedBracket = bracketMatch[2];
2319
2509
  } else if (rootMatch) {
2320
- // Root object - just keep the bracket
2321
2510
  displayText = rootMatch[1] + rootMatch[2];
2322
2511
  collapsedBracket = rootMatch[2];
2323
2512
  }
2324
2513
  }
2325
-
2514
+
2326
2515
  // Escape HTML first
2327
2516
  let result = displayText
2328
- .replace(/&/g, '&amp;')
2329
- .replace(/</g, '&lt;')
2330
- .replace(/>/g, '&gt;');
2331
-
2517
+ .replace(RE_ESCAPE_AMP, '&amp;')
2518
+ .replace(RE_ESCAPE_LT, '&lt;')
2519
+ .replace(RE_ESCAPE_GT, '&gt;');
2520
+
2332
2521
  // Punctuation FIRST (before other replacements can interfere)
2333
- result = result.replace(/([{}[\],:])/g, '<span class="json-punctuation">$1</span>');
2334
-
2522
+ result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
2523
+
2335
2524
  // JSON keys - match "key" followed by :
2336
2525
  // In properties context, all keys are treated as regular JSON keys
2337
- 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) => {
2338
2528
  if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
2339
2529
  return `<span class="geojson-key">"${key}"</span>${colon}`;
2340
2530
  }
2341
2531
  return `<span class="json-key">"${key}"</span>${colon}`;
2342
2532
  });
2343
-
2533
+
2344
2534
  // Type values - "type": "Value" - but NOT inside properties context
2345
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2346
2535
  if (context !== 'properties') {
2347
- result = result.replace(
2348
- /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>(\s*)"([^"]*)"/g,
2349
- (match, space, type) => {
2350
- const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
2351
- const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
2352
- return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
2353
- }
2354
- );
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
+ });
2355
2542
  }
2356
2543
 
2357
2544
  // String values (not already wrapped in spans)
2358
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2359
- result = result.replace(
2360
- /(<span class="json-punctuation">:<\/span>)(\s*)"([^"]*)"/g,
2361
- (match, colon, space, val) => {
2362
- // Don't double-wrap if already has a span after colon
2363
- if (match.includes('geojson-type') || match.includes('json-string')) return match;
2364
-
2365
- // Check if it's a color value (hex) - use ::before for swatch via CSS class
2366
- if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(val)) {
2367
- return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
2368
- }
2369
-
2370
- return `${colon}${space}<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>`;
2371
2550
  }
2372
- );
2551
+ return `${colon}${space}<span class="json-string">"${val}"</span>`;
2552
+ });
2373
2553
 
2374
2554
  // Numbers after colon
2375
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2376
- result = result.replace(
2377
- /(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2378
- '$1$2<span class="json-number">$3</span>'
2379
- );
2555
+ RE_NUMBERS_COLON.lastIndex = 0;
2556
+ result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
2380
2557
 
2381
2558
  // Numbers in arrays (after [ or ,)
2382
- result = result.replace(
2383
- /(<span class="json-punctuation">[\[,]<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2384
- '$1$2<span class="json-number">$3</span>'
2385
- );
2559
+ RE_NUMBERS_ARRAY.lastIndex = 0;
2560
+ result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
2386
2561
 
2387
2562
  // Standalone numbers at start of line (coordinates arrays)
2388
- result = result.replace(
2389
- /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim,
2390
- '$1<span class="json-number">$2</span>'
2391
- );
2563
+ RE_NUMBERS_START.lastIndex = 0;
2564
+ result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
2392
2565
 
2393
2566
  // Booleans - use ::before for checkbox via CSS class
2394
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2395
- result = result.replace(
2396
- /(<span class="json-punctuation">:<\/span>)(\s*)(true|false)/g,
2397
- (match, colon, space, val) => {
2398
- const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
2399
- return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
2400
- }
2401
- );
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
+ });
2402
2572
 
2403
2573
  // Null
2404
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2405
- result = result.replace(
2406
- /(<span class="json-punctuation">:<\/span>)(\s*)(null)/g,
2407
- '$1$2<span class="json-null">$3</span>'
2408
- );
2409
-
2410
- // 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
2411
2578
  if (collapsedBracket) {
2412
2579
  const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
2413
- // Replace the last punctuation span (the opening bracket) with collapsed style class
2414
2580
  result = result.replace(
2415
2581
  new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
2416
2582
  `<span class="${bracketClass}">${collapsedBracket}</span>`
2417
2583
  );
2418
2584
  }
2419
-
2420
- // Mark unrecognized text as error - text that's not inside a span and not just whitespace
2421
- // This catches invalid JSON like unquoted strings, malformed values, etc.
2422
- result = result.replace(
2423
- /(<\/span>|^)([^<]+)(<span|$)/g,
2424
- (match, before, text, after) => {
2425
- // Skip if text is only whitespace or empty
2426
- if (!text || /^\s*$/.test(text)) return match;
2427
- // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
2428
- // Keep whitespace as-is, wrap any non-whitespace unrecognized token
2429
- const parts = text.split(/(\s+)/);
2430
- let hasError = false;
2431
- const processed = parts.map(part => {
2432
- // If it's whitespace, keep it
2433
- if (/^\s*$/.test(part)) return part;
2434
- // Mark as error
2435
- hasError = true;
2436
- return `<span class="json-error">${part}</span>`;
2437
- }).join('');
2438
- return hasError ? before + processed + after : match;
2439
- }
2440
- );
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
+ });
2441
2603
 
2442
2604
  // Note: visibility is now handled at line level (has-visibility class on .line element)
2443
2605
 
@@ -2496,6 +2658,9 @@ class GeoJsonEditor extends HTMLElement {
2496
2658
  }
2497
2659
 
2498
2660
  removeAll() {
2661
+ if (this.lines.length > 0) {
2662
+ this._saveToHistory('removeAll');
2663
+ }
2499
2664
  const removed = this._parseFeatures();
2500
2665
  this.lines = [];
2501
2666
  this.collapsedNodes.clear();
@@ -2521,6 +2686,113 @@ class GeoJsonEditor extends HTMLElement {
2521
2686
  this.emitChange();
2522
2687
  }
2523
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
+
2524
2796
  _parseFeatures() {
2525
2797
  try {
2526
2798
  const content = this.lines.join('\n');