@softwarity/geojson-editor 1.0.18 → 1.0.20

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.
@@ -66,6 +66,7 @@ class GeoJsonEditor extends HTMLElement {
66
66
  private _nodeIdCounter: number = 0;
67
67
  private _lineToNodeId: Map<number, string> = new Map();
68
68
  private _nodeIdToLines: Map<string, NodeRangeInfo> = new Map();
69
+ private _openedNodeKeys: Set<string> = new Set(); // UniqueKeys (nodeKey:occurrence) that user opened
69
70
 
70
71
  // ========== Derived State (computed from model) ==========
71
72
  visibleLines: VisibleLine[] = [];
@@ -114,6 +115,7 @@ class GeoJsonEditor extends HTMLElement {
114
115
  private _contextMapLinesLength: number = 0;
115
116
  private _contextMapFirstLine: string | undefined = undefined;
116
117
  private _contextMapLastLine: string | undefined = undefined;
118
+ private _errorLinesCache: Set<number> | null = null;
117
119
 
118
120
  // ========== Cached DOM Elements ==========
119
121
  private _viewport: HTMLElement | null = null;
@@ -129,6 +131,10 @@ class GeoJsonEditor extends HTMLElement {
129
131
  private _placeholderLayer: HTMLElement | null = null;
130
132
  private _editorPrefix: HTMLElement | null = null;
131
133
  private _editorSuffix: HTMLElement | null = null;
134
+ private _errorNav: HTMLElement | null = null;
135
+ private _errorCount: HTMLElement | null = null;
136
+ private _prevErrorBtn: HTMLButtonElement | null = null;
137
+ private _nextErrorBtn: HTMLButtonElement | null = null;
132
138
 
133
139
  constructor() {
134
140
  super();
@@ -367,38 +373,31 @@ class GeoJsonEditor extends HTMLElement {
367
373
  * Preserves collapsed state by matching nodeKey + sequential occurrence
368
374
  */
369
375
  private _rebuildNodeIdMappings() {
370
- // Save old state to try to preserve collapsed nodes
371
- const oldCollapsed = new Set(this.collapsedNodes);
372
- const oldNodeKeyMap = new Map(); // nodeId -> nodeKey
373
- for (const [nodeId, info] of this._nodeIdToLines) {
374
- if (info.nodeKey) oldNodeKeyMap.set(nodeId, info.nodeKey);
375
- }
376
-
377
- // Build list of collapsed nodeKeys for matching
378
- const collapsedNodeKeys = [];
379
- for (const nodeId of oldCollapsed) {
380
- const nodeKey = oldNodeKeyMap.get(nodeId);
381
- if (nodeKey) collapsedNodeKeys.push(nodeKey);
376
+ // Save collapsed uniqueKeys from old state
377
+ const collapsedUniqueKeys = new Set<string>();
378
+ for (const nodeId of this.collapsedNodes) {
379
+ const info = this._nodeIdToLines.get(nodeId);
380
+ if (info?.uniqueKey) collapsedUniqueKeys.add(info.uniqueKey);
382
381
  }
383
-
382
+
384
383
  // Reset mappings
385
384
  this._nodeIdCounter = 0;
386
385
  this._lineToNodeId.clear();
387
386
  this._nodeIdToLines.clear();
388
387
  this.collapsedNodes.clear();
389
-
390
- // Track occurrences of each nodeKey for matching
391
- const nodeKeyOccurrences = new Map();
392
-
388
+
389
+ // Track occurrences of each nodeKey
390
+ const nodeKeyOccurrences = new Map<string, number>();
391
+
393
392
  // Assign fresh IDs to all collapsible nodes
394
393
  for (let i = 0; i < this.lines.length; i++) {
395
394
  const line = this.lines[i];
396
-
395
+
397
396
  // Match "key": { or "key": [
398
397
  const kvMatch = line.match(RE_KV_MATCH);
399
398
  // Also match standalone { or {, (root Feature objects)
400
399
  const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
401
-
400
+
402
401
  if (!kvMatch && !rootMatch) continue;
403
402
 
404
403
  let nodeKey: string;
@@ -414,30 +413,26 @@ class GeoJsonEditor extends HTMLElement {
414
413
  } else {
415
414
  continue;
416
415
  }
417
-
416
+
418
417
  // Check if closes on same line
419
418
  const rest = line.substring(line.indexOf(openBracket) + 1);
420
419
  const counts = countBrackets(rest, openBracket);
421
420
  if (counts.close > counts.open) continue;
422
-
421
+
423
422
  const endLine = this._findClosingLine(i, openBracket);
424
423
  if (endLine === -1 || endLine === i) continue;
425
-
426
- // Generate unique ID for this node
424
+
425
+ // Generate unique ID and unique key for this node
427
426
  const nodeId = this._generateNodeId();
428
-
429
- this._lineToNodeId.set(i, nodeId);
430
- this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, isRootFeature: !!rootMatch });
431
-
432
- // Track occurrence of this nodeKey
433
427
  const occurrence = nodeKeyOccurrences.get(nodeKey) || 0;
434
428
  nodeKeyOccurrences.set(nodeKey, occurrence + 1);
435
-
436
- // Check if this nodeKey was previously collapsed
437
- const keyIndex = collapsedNodeKeys.indexOf(nodeKey);
438
- if (keyIndex !== -1) {
439
- // Remove from list so we don't match it again
440
- collapsedNodeKeys.splice(keyIndex, 1);
429
+ const uniqueKey = `${nodeKey}:${occurrence}`;
430
+
431
+ this._lineToNodeId.set(i, nodeId);
432
+ this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, uniqueKey, isRootFeature: !!rootMatch });
433
+
434
+ // Restore collapsed state if was collapsed and not explicitly opened
435
+ if (collapsedUniqueKeys.has(uniqueKey) && !this._openedNodeKeys.has(uniqueKey)) {
441
436
  this.collapsedNodes.add(nodeId);
442
437
  }
443
438
  }
@@ -533,6 +528,10 @@ class GeoJsonEditor extends HTMLElement {
533
528
  this._placeholderLayer = this._id('placeholderLayer');
534
529
  this._editorPrefix = this._id('editorPrefix');
535
530
  this._editorSuffix = this._id('editorSuffix');
531
+ this._errorNav = this._id('errorNav');
532
+ this._errorCount = this._id('errorCount');
533
+ this._prevErrorBtn = this._id('prevErrorBtn') as HTMLButtonElement;
534
+ this._nextErrorBtn = this._id('nextErrorBtn') as HTMLButtonElement;
536
535
  }
537
536
 
538
537
  // ========== Event Listeners ==========
@@ -587,6 +586,17 @@ class GeoJsonEditor extends HTMLElement {
587
586
  // Prevent default to avoid losing focus after click
588
587
  e.preventDefault();
589
588
 
589
+ // Double-click: select word (e.detail === 2)
590
+ if (e.detail === 2) {
591
+ const pos = this._getPositionFromClick(e);
592
+ this._selectWordAt(pos.line, pos.column);
593
+ this._isSelecting = false;
594
+ hiddenTextarea.focus();
595
+ this._invalidateRenderCache();
596
+ this.scheduleRender();
597
+ return;
598
+ }
599
+
590
600
  // Calculate click position
591
601
  const pos = this._getPositionFromClick(e);
592
602
 
@@ -728,6 +738,14 @@ class GeoJsonEditor extends HTMLElement {
728
738
  this.removeAll();
729
739
  });
730
740
 
741
+ // Error navigation buttons
742
+ this._prevErrorBtn?.addEventListener('click', () => {
743
+ this.goToPrevError();
744
+ });
745
+ this._nextErrorBtn?.addEventListener('click', () => {
746
+ this.goToNextError();
747
+ });
748
+
731
749
  // Initial readonly state
732
750
  this.updateReadonly();
733
751
  }
@@ -763,6 +781,7 @@ class GeoJsonEditor extends HTMLElement {
763
781
  // Clear state for new content
764
782
  this.collapsedNodes.clear();
765
783
  this.hiddenFeatures.clear();
784
+ this._openedNodeKeys.clear();
766
785
  this._lineToNodeId.clear();
767
786
  this._nodeIdToLines.clear();
768
787
  this.cursorLine = 0;
@@ -794,8 +813,9 @@ class GeoJsonEditor extends HTMLElement {
794
813
  * Rebuilds line-to-nodeId mapping while preserving collapsed state
795
814
  */
796
815
  updateModel() {
797
- // Invalidate context map cache since content changed
816
+ // Invalidate caches since content changed
798
817
  this._contextMapCache = null;
818
+ this._errorLinesCache = null;
799
819
 
800
820
  // Rebuild lineToNodeId mapping (may shift due to edits)
801
821
  this._rebuildNodeIdMappings();
@@ -891,9 +911,12 @@ class GeoJsonEditor extends HTMLElement {
891
911
  */
892
912
  computeLineMetadata() {
893
913
  this.lineMetadata.clear();
894
-
914
+
895
915
  const collapsibleRanges = this._findCollapsibleRanges();
896
-
916
+
917
+ // Compute error lines once (cached)
918
+ const errorLines = this._computeErrorLines();
919
+
897
920
  for (let i = 0; i < this.lines.length; i++) {
898
921
  const line = this.lines[i];
899
922
  const meta: LineMeta = {
@@ -903,9 +926,10 @@ class GeoJsonEditor extends HTMLElement {
903
926
  visibilityButton: null,
904
927
  isHidden: false,
905
928
  isCollapsed: false,
906
- featureKey: null
929
+ featureKey: null,
930
+ hasError: errorLines.has(i)
907
931
  };
908
-
932
+
909
933
  // Detect colors and booleans in a single pass
910
934
  RE_ATTR_VALUE.lastIndex = 0;
911
935
  let match: RegExpExecArray | null;
@@ -925,7 +949,7 @@ class GeoJsonEditor extends HTMLElement {
925
949
  }
926
950
  }
927
951
  }
928
-
952
+
929
953
  // Check if line starts a collapsible node
930
954
  const collapsible = collapsibleRanges.find(r => r.startLine === i);
931
955
  if (collapsible) {
@@ -935,15 +959,15 @@ class GeoJsonEditor extends HTMLElement {
935
959
  isCollapsed: this.collapsedNodes.has(collapsible.nodeId)
936
960
  };
937
961
  }
938
-
962
+
939
963
  // Check if line is inside a collapsed node (exclude closing bracket line)
940
- const insideCollapsed = collapsibleRanges.find(r =>
964
+ const insideCollapsed = collapsibleRanges.find(r =>
941
965
  this.collapsedNodes.has(r.nodeId) && i > r.startLine && i < r.endLine
942
966
  );
943
967
  if (insideCollapsed) {
944
968
  meta.isCollapsed = true;
945
969
  }
946
-
970
+
947
971
  // Check if line belongs to a hidden feature
948
972
  for (const [featureKey, range] of this.featureRanges) {
949
973
  if (i >= range.startLine && i <= range.endLine) {
@@ -961,11 +985,152 @@ class GeoJsonEditor extends HTMLElement {
961
985
  break;
962
986
  }
963
987
  }
964
-
988
+
965
989
  this.lineMetadata.set(i, meta);
966
990
  }
967
991
  }
968
992
 
993
+ /**
994
+ * Compute error lines (syntax highlighting + structural errors)
995
+ * Called once per model update, result is used by computeLineMetadata
996
+ */
997
+ private _computeErrorLines(): Set<number> {
998
+ if (this._errorLinesCache !== null) {
999
+ return this._errorLinesCache;
1000
+ }
1001
+
1002
+ const errorLines = new Set<number>();
1003
+
1004
+ // Check syntax highlighting errors for each line
1005
+ for (let i = 0; i < this.lines.length; i++) {
1006
+ const highlighted = highlightSyntax(this.lines[i], '', undefined);
1007
+ if (highlighted.includes('json-error')) {
1008
+ errorLines.add(i);
1009
+ }
1010
+ }
1011
+
1012
+ // Check structural error from JSON.parse
1013
+ try {
1014
+ const content = this.lines.join('\n');
1015
+ const wrapped = '[' + content + ']';
1016
+ JSON.parse(wrapped);
1017
+ } catch (e) {
1018
+ if (e instanceof Error) {
1019
+ // Try to extract line number from error message
1020
+ // Chrome/Node: "... at line X column Y"
1021
+ const lineMatch = e.message.match(/line (\d+)/);
1022
+ if (lineMatch) {
1023
+ // Subtract 1 because we wrapped with '[' on first line
1024
+ const errorLine = Math.max(0, parseInt(lineMatch[1], 10) - 1);
1025
+ errorLines.add(errorLine);
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ this._errorLinesCache = errorLines;
1031
+ return errorLines;
1032
+ }
1033
+
1034
+ /**
1035
+ * Get all lines that have errors (for navigation and counting)
1036
+ * Returns array of line indices sorted by line number
1037
+ */
1038
+ private _getErrorLines(): number[] {
1039
+ const errorLines: number[] = [];
1040
+ for (const [lineIndex, meta] of this.lineMetadata) {
1041
+ if (meta.hasError) {
1042
+ errorLines.push(lineIndex);
1043
+ }
1044
+ }
1045
+ return errorLines.sort((a, b) => a - b);
1046
+ }
1047
+
1048
+ /**
1049
+ * Navigate to the next error line
1050
+ */
1051
+ goToNextError(): boolean {
1052
+ const errorLines = this._getErrorLines();
1053
+ if (errorLines.length === 0) return false;
1054
+
1055
+ // Find next error after current cursor position
1056
+ const nextError = errorLines.find(line => line > this.cursorLine);
1057
+ const targetLine = nextError !== undefined ? nextError : errorLines[0]; // Wrap to first
1058
+
1059
+ return this._goToErrorLine(targetLine);
1060
+ }
1061
+
1062
+ /**
1063
+ * Navigate to the previous error line
1064
+ */
1065
+ goToPrevError(): boolean {
1066
+ const errorLines = this._getErrorLines();
1067
+ if (errorLines.length === 0) return false;
1068
+
1069
+ // Find previous error before current cursor position
1070
+ const prevErrors = errorLines.filter(line => line < this.cursorLine);
1071
+ const targetLine = prevErrors.length > 0 ? prevErrors[prevErrors.length - 1] : errorLines[errorLines.length - 1]; // Wrap to last
1072
+
1073
+ return this._goToErrorLine(targetLine);
1074
+ }
1075
+
1076
+ /**
1077
+ * Expand all collapsed nodes containing a specific line
1078
+ * Returns true if any nodes were expanded
1079
+ */
1080
+ private _expandNodesContainingLine(lineIndex: number): boolean {
1081
+ let expanded = false;
1082
+ for (const [nodeId, nodeInfo] of this._nodeIdToLines) {
1083
+ if (this.collapsedNodes.has(nodeId) && lineIndex > nodeInfo.startLine && lineIndex <= nodeInfo.endLine) {
1084
+ this.collapsedNodes.delete(nodeId);
1085
+ // Track that this node was opened - don't re-collapse during edits
1086
+ if (nodeInfo.uniqueKey) {
1087
+ this._openedNodeKeys.add(nodeInfo.uniqueKey);
1088
+ }
1089
+ expanded = true;
1090
+ }
1091
+ }
1092
+ return expanded;
1093
+ }
1094
+
1095
+ /**
1096
+ * Navigate to a specific error line
1097
+ */
1098
+ private _goToErrorLine(lineIndex: number): boolean {
1099
+ if (this._expandNodesContainingLine(lineIndex)) {
1100
+ this.updateView();
1101
+ }
1102
+
1103
+ this.cursorLine = lineIndex;
1104
+ this.cursorColumn = 0;
1105
+ this._invalidateRenderCache();
1106
+ this._scrollToCursor(true); // Center the error line
1107
+ this.renderViewport();
1108
+ this._updateErrorDisplay();
1109
+
1110
+ // Focus the editor
1111
+ this._hiddenTextarea?.focus();
1112
+ return true;
1113
+ }
1114
+
1115
+ /**
1116
+ * Expand all collapsed nodes that contain error lines
1117
+ */
1118
+ private _expandErrorNodes(): void {
1119
+ const errorLines = this._getErrorLines();
1120
+ if (errorLines.length === 0) return;
1121
+
1122
+ let expanded = false;
1123
+ for (const errorLine of errorLines) {
1124
+ if (this._expandNodesContainingLine(errorLine)) {
1125
+ expanded = true;
1126
+ }
1127
+ }
1128
+
1129
+ if (expanded) {
1130
+ this.updateView();
1131
+ }
1132
+ }
1133
+
969
1134
  /**
970
1135
  * Compute which lines are visible (not inside collapsed nodes)
971
1136
  */
@@ -1012,10 +1177,15 @@ class GeoJsonEditor extends HTMLElement {
1012
1177
 
1013
1178
  const totalLines = this.visibleLines.length;
1014
1179
  const totalHeight = totalLines * this.lineHeight;
1015
-
1016
- // Set total scrollable height (only once or when content changes)
1180
+
1181
+ // Set total scrollable dimensions (height and width based on content)
1017
1182
  if (scrollContent) {
1018
1183
  scrollContent.style.height = `${totalHeight}px`;
1184
+ // Calculate max line width to update horizontal scroll
1185
+ const charWidth = this._getCharWidth();
1186
+ const maxLineLength = this.lines.reduce((max, line) => Math.max(max, line.length), 0);
1187
+ const minWidth = maxLineLength * charWidth + 20; // 20px padding
1188
+ scrollContent.style.minWidth = `${minWidth}px`;
1019
1189
  }
1020
1190
 
1021
1191
  // Calculate visible range based on scroll position
@@ -1206,18 +1376,23 @@ class GeoJsonEditor extends HTMLElement {
1206
1376
  for (let i = startIndex; i < endIndex; i++) {
1207
1377
  const lineData = this.visibleLines[i];
1208
1378
  if (!lineData) continue;
1209
-
1379
+
1210
1380
  const gutterLine = _ce('div');
1211
1381
  gutterLine.className = 'gutter-line';
1212
-
1382
+
1213
1383
  const meta = lineData.meta;
1214
-
1384
+
1385
+ // Add error indicator class
1386
+ if (meta?.hasError) {
1387
+ gutterLine.classList.add('has-error');
1388
+ }
1389
+
1215
1390
  // Line number first
1216
1391
  const lineNum = _ce('span');
1217
1392
  lineNum.className = 'line-number';
1218
1393
  lineNum.textContent = String(lineData.index + 1);
1219
1394
  gutterLine.appendChild(lineNum);
1220
-
1395
+
1221
1396
  // Collapse column (always present for alignment)
1222
1397
  const collapseCol = _ce('div');
1223
1398
  collapseCol.className = 'collapse-column';
@@ -1231,7 +1406,7 @@ class GeoJsonEditor extends HTMLElement {
1231
1406
  collapseCol.appendChild(btn);
1232
1407
  }
1233
1408
  gutterLine.appendChild(collapseCol);
1234
-
1409
+
1235
1410
  fragment.appendChild(gutterLine);
1236
1411
  }
1237
1412
 
@@ -1355,6 +1530,8 @@ class GeoJsonEditor extends HTMLElement {
1355
1530
  'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1356
1531
  'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1357
1532
  'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1533
+ 'PageUp': () => this._handlePageUpDown('up', e.shiftKey),
1534
+ 'PageDown': () => this._handlePageUpDown('down', e.shiftKey),
1358
1535
  'Tab': () => this._handleTab(e.shiftKey, ctx),
1359
1536
  'Insert': () => { this._insertMode = !this._insertMode; this.scheduleRender(); }
1360
1537
  };
@@ -1418,8 +1595,7 @@ class GeoJsonEditor extends HTMLElement {
1418
1595
  // Block in collapsed zones
1419
1596
  if (ctx.inCollapsedZone) return;
1420
1597
 
1421
- // Normal Enter: insert newline
1422
- this.insertNewline();
1598
+ // Enter anywhere else: do nothing (JSON structure is managed automatically)
1423
1599
  }
1424
1600
 
1425
1601
  private _handleBackspace(ctx: CollapsedZoneContext): void {
@@ -1502,6 +1678,7 @@ class GeoJsonEditor extends HTMLElement {
1502
1678
 
1503
1679
  /**
1504
1680
  * Navigate to the next attribute (key or value) in the JSON
1681
+ * Also stops on collapsed node brackets to allow expansion with Enter
1505
1682
  */
1506
1683
  private _navigateToNextAttribute(): void {
1507
1684
  const totalLines = this.visibleLines.length;
@@ -1514,13 +1691,17 @@ class GeoJsonEditor extends HTMLElement {
1514
1691
  const line = this.lines[vl.index];
1515
1692
  const startCol = (i === currentVisibleIdx) ? this.cursorColumn : 0;
1516
1693
 
1517
- const pos = this._findNextAttributeInLine(line, startCol);
1694
+ const pos = this._findNextAttributeOrBracket(line, startCol, vl.index);
1518
1695
  if (pos !== null) {
1519
1696
  this.cursorLine = vl.index;
1520
1697
  this.cursorColumn = pos.start;
1521
- // Select the attribute key or value
1522
- this.selectionStart = { line: vl.index, column: pos.start };
1523
- this.selectionEnd = { line: vl.index, column: pos.end };
1698
+ // Select the attribute key or value (not brackets)
1699
+ if (!pos.isBracket) {
1700
+ this.selectionStart = { line: vl.index, column: pos.start };
1701
+ this.selectionEnd = { line: vl.index, column: pos.end };
1702
+ } else {
1703
+ this._clearSelection();
1704
+ }
1524
1705
  this._scrollToCursor();
1525
1706
  this._invalidateRenderCache();
1526
1707
  this.scheduleRender();
@@ -1532,12 +1713,16 @@ class GeoJsonEditor extends HTMLElement {
1532
1713
  for (let i = 0; i < currentVisibleIdx; i++) {
1533
1714
  const vl = this.visibleLines[i];
1534
1715
  const line = this.lines[vl.index];
1535
- const pos = this._findNextAttributeInLine(line, 0);
1716
+ const pos = this._findNextAttributeOrBracket(line, 0, vl.index);
1536
1717
  if (pos !== null) {
1537
1718
  this.cursorLine = vl.index;
1538
1719
  this.cursorColumn = pos.start;
1539
- this.selectionStart = { line: vl.index, column: pos.start };
1540
- this.selectionEnd = { line: vl.index, column: pos.end };
1720
+ if (!pos.isBracket) {
1721
+ this.selectionStart = { line: vl.index, column: pos.start };
1722
+ this.selectionEnd = { line: vl.index, column: pos.end };
1723
+ } else {
1724
+ this._clearSelection();
1725
+ }
1541
1726
  this._scrollToCursor();
1542
1727
  this._invalidateRenderCache();
1543
1728
  this.scheduleRender();
@@ -1548,6 +1733,7 @@ class GeoJsonEditor extends HTMLElement {
1548
1733
 
1549
1734
  /**
1550
1735
  * Navigate to the previous attribute (key or value) in the JSON
1736
+ * Also stops on collapsed node brackets to allow expansion with Enter
1551
1737
  */
1552
1738
  private _navigateToPrevAttribute(): void {
1553
1739
  const totalLines = this.visibleLines.length;
@@ -1560,12 +1746,16 @@ class GeoJsonEditor extends HTMLElement {
1560
1746
  const line = this.lines[vl.index];
1561
1747
  const endCol = (i === currentVisibleIdx) ? this.cursorColumn : line.length;
1562
1748
 
1563
- const pos = this._findPrevAttributeInLine(line, endCol);
1749
+ const pos = this._findPrevAttributeOrBracket(line, endCol, vl.index);
1564
1750
  if (pos !== null) {
1565
1751
  this.cursorLine = vl.index;
1566
1752
  this.cursorColumn = pos.start;
1567
- this.selectionStart = { line: vl.index, column: pos.start };
1568
- this.selectionEnd = { line: vl.index, column: pos.end };
1753
+ if (!pos.isBracket) {
1754
+ this.selectionStart = { line: vl.index, column: pos.start };
1755
+ this.selectionEnd = { line: vl.index, column: pos.end };
1756
+ } else {
1757
+ this._clearSelection();
1758
+ }
1569
1759
  this._scrollToCursor();
1570
1760
  this._invalidateRenderCache();
1571
1761
  this.scheduleRender();
@@ -1577,12 +1767,16 @@ class GeoJsonEditor extends HTMLElement {
1577
1767
  for (let i = totalLines - 1; i > currentVisibleIdx; i--) {
1578
1768
  const vl = this.visibleLines[i];
1579
1769
  const line = this.lines[vl.index];
1580
- const pos = this._findPrevAttributeInLine(line, line.length);
1770
+ const pos = this._findPrevAttributeOrBracket(line, line.length, vl.index);
1581
1771
  if (pos !== null) {
1582
1772
  this.cursorLine = vl.index;
1583
1773
  this.cursorColumn = pos.start;
1584
- this.selectionStart = { line: vl.index, column: pos.start };
1585
- this.selectionEnd = { line: vl.index, column: pos.end };
1774
+ if (!pos.isBracket) {
1775
+ this.selectionStart = { line: vl.index, column: pos.start };
1776
+ this.selectionEnd = { line: vl.index, column: pos.end };
1777
+ } else {
1778
+ this._clearSelection();
1779
+ }
1586
1780
  this._scrollToCursor();
1587
1781
  this._invalidateRenderCache();
1588
1782
  this.scheduleRender();
@@ -1594,71 +1788,88 @@ class GeoJsonEditor extends HTMLElement {
1594
1788
  /**
1595
1789
  * Find next attribute position in a line after startCol
1596
1790
  * Returns {start, end} for the key or value, or null if none found
1791
+ * Also finds standalone values (numbers in arrays, etc.)
1597
1792
  */
1598
1793
  private _findNextAttributeInLine(line: string, startCol: number): { start: number; end: number } | null {
1599
- // Pattern: "key": value where value can be "string", number, true, false, null
1600
- const re = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1794
+ // Collect all navigable positions
1795
+ const positions: { start: number; end: number }[] = [];
1796
+
1797
+ // Pattern for "key": value pairs
1798
+ const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1601
1799
  let match;
1602
1800
 
1603
- while ((match = re.exec(line)) !== null) {
1801
+ while ((match = keyValueRe.exec(line)) !== null) {
1604
1802
  const keyStart = match.index + 1; // Skip opening quote
1605
1803
  const keyEnd = keyStart + match[1].length;
1606
-
1607
- // If key is after startCol, return key position
1608
- if (keyStart > startCol) {
1609
- return { start: keyStart, end: keyEnd };
1610
- }
1804
+ positions.push({ start: keyStart, end: keyEnd });
1611
1805
 
1612
1806
  // Check if there's a value (string, number, boolean, null)
1613
1807
  if (match[2] !== undefined) {
1614
- // String value - find its position
1808
+ // String value
1615
1809
  const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
1616
1810
  if (valueMatch) {
1617
1811
  const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1618
1812
  const valueEnd = valueStart + match[2].length;
1619
- if (valueStart > startCol) {
1620
- return { start: valueStart, end: valueEnd };
1621
- }
1813
+ positions.push({ start: valueStart, end: valueEnd });
1622
1814
  }
1623
1815
  } else if (match[3] !== undefined) {
1624
- // Number value
1816
+ // Number value after colon
1625
1817
  const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1626
1818
  if (numMatch) {
1627
1819
  const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1628
1820
  const valueEnd = valueStart + numMatch[1].length;
1629
- if (valueStart > startCol) {
1630
- return { start: valueStart, end: valueEnd };
1631
- }
1821
+ positions.push({ start: valueStart, end: valueEnd });
1632
1822
  }
1633
1823
  } else {
1634
- // Boolean or null - check after the colon
1824
+ // Boolean or null
1635
1825
  const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1636
1826
  if (boolMatch) {
1637
1827
  const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1638
1828
  const valueEnd = valueStart + boolMatch[1].length;
1639
- if (valueStart > startCol) {
1640
- return { start: valueStart, end: valueEnd };
1641
- }
1829
+ positions.push({ start: valueStart, end: valueEnd });
1642
1830
  }
1643
1831
  }
1644
1832
  }
1645
1833
 
1834
+ // Also find standalone numbers (not after a colon) - for array elements
1835
+ const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
1836
+ while ((match = standaloneNumRe.exec(line)) !== null) {
1837
+ const numStr = match[1];
1838
+ const numStart = match.index + match[0].indexOf(numStr);
1839
+ const numEnd = numStart + numStr.length;
1840
+ // Avoid duplicates (numbers already captured by key-value pattern)
1841
+ if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
1842
+ positions.push({ start: numStart, end: numEnd });
1843
+ }
1844
+ }
1845
+
1846
+ // Sort by start position and find first after startCol
1847
+ positions.sort((a, b) => a.start - b.start);
1848
+ for (const pos of positions) {
1849
+ if (pos.start > startCol) {
1850
+ return pos;
1851
+ }
1852
+ }
1853
+
1646
1854
  return null;
1647
1855
  }
1648
1856
 
1649
1857
  /**
1650
1858
  * Find previous attribute position in a line before endCol
1859
+ * Also finds standalone values (numbers in arrays, etc.)
1651
1860
  */
1652
1861
  private _findPrevAttributeInLine(line: string, endCol: number): { start: number; end: number } | null {
1653
- // Collect all attributes in the line
1654
- const attrs: { start: number; end: number }[] = [];
1655
- const re = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1862
+ // Collect all navigable positions
1863
+ const positions: { start: number; end: number }[] = [];
1864
+
1865
+ // Pattern for "key": value pairs
1866
+ const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1656
1867
  let match;
1657
1868
 
1658
- while ((match = re.exec(line)) !== null) {
1869
+ while ((match = keyValueRe.exec(line)) !== null) {
1659
1870
  const keyStart = match.index + 1;
1660
1871
  const keyEnd = keyStart + match[1].length;
1661
- attrs.push({ start: keyStart, end: keyEnd });
1872
+ positions.push({ start: keyStart, end: keyEnd });
1662
1873
 
1663
1874
  // Check for value
1664
1875
  if (match[2] !== undefined) {
@@ -1666,35 +1877,120 @@ class GeoJsonEditor extends HTMLElement {
1666
1877
  if (valueMatch) {
1667
1878
  const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1668
1879
  const valueEnd = valueStart + match[2].length;
1669
- attrs.push({ start: valueStart, end: valueEnd });
1880
+ positions.push({ start: valueStart, end: valueEnd });
1670
1881
  }
1671
1882
  } else if (match[3] !== undefined) {
1672
1883
  const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1673
1884
  if (numMatch) {
1674
1885
  const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1675
1886
  const valueEnd = valueStart + numMatch[1].length;
1676
- attrs.push({ start: valueStart, end: valueEnd });
1887
+ positions.push({ start: valueStart, end: valueEnd });
1677
1888
  }
1678
1889
  } else {
1679
1890
  const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1680
1891
  if (boolMatch) {
1681
1892
  const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1682
1893
  const valueEnd = valueStart + boolMatch[1].length;
1683
- attrs.push({ start: valueStart, end: valueEnd });
1894
+ positions.push({ start: valueStart, end: valueEnd });
1684
1895
  }
1685
1896
  }
1686
1897
  }
1687
1898
 
1688
- // Find the last attribute that ends before endCol
1689
- for (let i = attrs.length - 1; i >= 0; i--) {
1690
- if (attrs[i].end < endCol) {
1691
- return attrs[i];
1899
+ // Also find standalone numbers (not after a colon) - for array elements
1900
+ const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
1901
+ while ((match = standaloneNumRe.exec(line)) !== null) {
1902
+ const numStr = match[1];
1903
+ const numStart = match.index + match[0].indexOf(numStr);
1904
+ const numEnd = numStart + numStr.length;
1905
+ // Avoid duplicates
1906
+ if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
1907
+ positions.push({ start: numStart, end: numEnd });
1908
+ }
1909
+ }
1910
+
1911
+ // Sort by start position and find last that ends before endCol
1912
+ positions.sort((a, b) => a.start - b.start);
1913
+ for (let i = positions.length - 1; i >= 0; i--) {
1914
+ if (positions[i].end < endCol) {
1915
+ return positions[i];
1692
1916
  }
1693
1917
  }
1694
1918
 
1695
1919
  return null;
1696
1920
  }
1697
1921
 
1922
+ /**
1923
+ * Find bracket position in a line (opening bracket for collapsible nodes)
1924
+ * Looks for { or [ at end of line (for both expanded and collapsed nodes)
1925
+ * Returns position AFTER the bracket, or null if not found
1926
+ */
1927
+ private _findBracketInLine(line: string): number | null {
1928
+ // Look for { or [ at end of line (indicates a collapsible node)
1929
+ // Works for both expanded and collapsed nodes - collapsed nodes still have
1930
+ // the bracket in raw text, the "..." is only added visually via CSS
1931
+ const bracketMatch = line.match(/[\[{]\s*$/);
1932
+ if (bracketMatch && bracketMatch.index !== undefined) {
1933
+ return bracketMatch.index + 1; // Position after bracket
1934
+ }
1935
+ return null;
1936
+ }
1937
+
1938
+ /**
1939
+ * Find next attribute or bracket position in a line
1940
+ * Returns position with isBracket flag to indicate if it's a bracket
1941
+ * For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
1942
+ * Stops on ALL opening brackets to allow collapse/expand navigation
1943
+ */
1944
+ private _findNextAttributeOrBracket(line: string, startCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
1945
+ // First check for regular attributes
1946
+ const attrPos = this._findNextAttributeInLine(line, startCol);
1947
+
1948
+ // Find opening bracket position (collapsed or expanded)
1949
+ const bracketPos = this._findBracketInLine(line);
1950
+
1951
+ // Return whichever comes first after startCol
1952
+ if (attrPos !== null && bracketPos !== null) {
1953
+ if (bracketPos > startCol && (bracketPos < attrPos.start)) {
1954
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1955
+ }
1956
+ return { ...attrPos, isBracket: false };
1957
+ } else if (attrPos !== null) {
1958
+ return { ...attrPos, isBracket: false };
1959
+ } else if (bracketPos !== null && bracketPos > startCol) {
1960
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1961
+ }
1962
+
1963
+ return null;
1964
+ }
1965
+
1966
+ /**
1967
+ * Find previous attribute or bracket position in a line
1968
+ * Returns position with isBracket flag to indicate if it's a bracket
1969
+ * For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
1970
+ * Stops on ALL opening brackets to allow collapse/expand navigation
1971
+ */
1972
+ private _findPrevAttributeOrBracket(line: string, endCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
1973
+ // First check for regular attributes
1974
+ const attrPos = this._findPrevAttributeInLine(line, endCol);
1975
+
1976
+ // Find opening bracket position (collapsed or expanded)
1977
+ const bracketPos = this._findBracketInLine(line);
1978
+
1979
+ // Return whichever comes last STRICTLY BEFORE endCol (to avoid staying in place)
1980
+ if (attrPos !== null && bracketPos !== null) {
1981
+ if (bracketPos < endCol && bracketPos > attrPos.end) {
1982
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1983
+ }
1984
+ return { ...attrPos, isBracket: false };
1985
+ } else if (attrPos !== null) {
1986
+ return { ...attrPos, isBracket: false };
1987
+ } else if (bracketPos !== null && bracketPos < endCol) {
1988
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1989
+ }
1990
+
1991
+ return null;
1992
+ }
1993
+
1698
1994
  insertNewline() {
1699
1995
  this._saveToHistory('newline');
1700
1996
 
@@ -1890,26 +2186,34 @@ class GeoJsonEditor extends HTMLElement {
1890
2186
 
1891
2187
  /**
1892
2188
  * Scroll viewport to ensure cursor is visible
2189
+ * @param center - if true, center the cursor line in the viewport
1893
2190
  */
1894
- private _scrollToCursor() {
2191
+ private _scrollToCursor(center = false) {
1895
2192
  const viewport = this._viewport;
1896
2193
  if (!viewport) return;
1897
-
2194
+
1898
2195
  // Find the visible line index for the cursor
1899
2196
  const visibleIndex = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1900
2197
  if (visibleIndex === -1) return;
1901
-
2198
+
1902
2199
  const cursorY = visibleIndex * this.lineHeight;
1903
- const viewportTop = viewport.scrollTop;
1904
- const viewportBottom = viewportTop + viewport.clientHeight;
1905
-
1906
- // Scroll up if cursor is above viewport
1907
- if (cursorY < viewportTop) {
1908
- viewport.scrollTop = cursorY;
1909
- }
1910
- // Scroll down if cursor is below viewport
1911
- else if (cursorY + this.lineHeight > viewportBottom) {
1912
- viewport.scrollTop = cursorY + this.lineHeight - viewport.clientHeight;
2200
+ const viewportHeight = viewport.clientHeight;
2201
+
2202
+ if (center) {
2203
+ // Center the cursor line in the viewport
2204
+ viewport.scrollTop = Math.max(0, cursorY - viewportHeight / 2 + this.lineHeight / 2);
2205
+ } else {
2206
+ const viewportTop = viewport.scrollTop;
2207
+ const viewportBottom = viewportTop + viewportHeight;
2208
+
2209
+ // Scroll up if cursor is above viewport
2210
+ if (cursorY < viewportTop) {
2211
+ viewport.scrollTop = cursorY;
2212
+ }
2213
+ // Scroll down if cursor is below viewport
2214
+ else if (cursorY + this.lineHeight > viewportBottom) {
2215
+ viewport.scrollTop = cursorY + this.lineHeight - viewportHeight;
2216
+ }
1913
2217
  }
1914
2218
  }
1915
2219
 
@@ -2064,18 +2368,32 @@ class GeoJsonEditor extends HTMLElement {
2064
2368
  if (isShift && !this.selectionStart) {
2065
2369
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
2066
2370
  }
2067
-
2371
+
2068
2372
  if (key === 'home') {
2069
2373
  if (onClosingLine) {
2374
+ // On closing line of collapsed node: go to start line
2070
2375
  this.cursorLine = onClosingLine.startLine;
2376
+ this.cursorColumn = 0;
2377
+ } else if (this.cursorColumn === 0) {
2378
+ // Already at start of line: go to start of document
2379
+ this.cursorLine = 0;
2380
+ this.cursorColumn = 0;
2381
+ } else {
2382
+ // Go to start of line
2383
+ this.cursorColumn = 0;
2071
2384
  }
2072
- this.cursorColumn = 0;
2073
2385
  } else {
2074
- if (this.cursorLine < this.lines.length) {
2075
- this.cursorColumn = this.lines[this.cursorLine].length;
2386
+ const lineLength = this.lines[this.cursorLine]?.length || 0;
2387
+ if (this.cursorColumn === lineLength) {
2388
+ // Already at end of line: go to end of document
2389
+ this.cursorLine = this.lines.length - 1;
2390
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
2391
+ } else {
2392
+ // Go to end of line
2393
+ this.cursorColumn = lineLength;
2076
2394
  }
2077
2395
  }
2078
-
2396
+
2079
2397
  // Update selection end if shift is pressed
2080
2398
  if (isShift) {
2081
2399
  this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
@@ -2083,7 +2401,50 @@ class GeoJsonEditor extends HTMLElement {
2083
2401
  this.selectionStart = null;
2084
2402
  this.selectionEnd = null;
2085
2403
  }
2086
-
2404
+
2405
+ this._invalidateRenderCache();
2406
+ this._scrollToCursor();
2407
+ this.scheduleRender();
2408
+ }
2409
+
2410
+ /**
2411
+ * Handle PageUp/PageDown
2412
+ */
2413
+ private _handlePageUpDown(direction: 'up' | 'down', isShift: boolean): void {
2414
+ // Start selection if shift is pressed and no selection exists
2415
+ if (isShift && !this.selectionStart) {
2416
+ this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
2417
+ }
2418
+
2419
+ const viewport = this._viewport;
2420
+ if (!viewport) return;
2421
+
2422
+ // Calculate how many lines fit in the viewport
2423
+ const linesPerPage = Math.floor(viewport.clientHeight / this.lineHeight);
2424
+
2425
+ if (direction === 'up') {
2426
+ // Find current visible index and move up by page
2427
+ const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
2428
+ const newVisibleIdx = Math.max(0, currentVisibleIdx - linesPerPage);
2429
+ this.cursorLine = this.visibleLines[newVisibleIdx]?.index || 0;
2430
+ } else {
2431
+ // Find current visible index and move down by page
2432
+ const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
2433
+ const newVisibleIdx = Math.min(this.visibleLines.length - 1, currentVisibleIdx + linesPerPage);
2434
+ this.cursorLine = this.visibleLines[newVisibleIdx]?.index || this.lines.length - 1;
2435
+ }
2436
+
2437
+ // Clamp cursor column to line length
2438
+ this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
2439
+
2440
+ // Update selection end if shift is pressed
2441
+ if (isShift) {
2442
+ this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
2443
+ } else {
2444
+ this.selectionStart = null;
2445
+ this.selectionEnd = null;
2446
+ }
2447
+
2087
2448
  this._invalidateRenderCache();
2088
2449
  this._scrollToCursor();
2089
2450
  this.scheduleRender();
@@ -2098,9 +2459,9 @@ class GeoJsonEditor extends HTMLElement {
2098
2459
  this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
2099
2460
  this.cursorLine = lastLine;
2100
2461
  this.cursorColumn = this.lines[lastLine]?.length || 0;
2101
-
2462
+
2102
2463
  this._invalidateRenderCache();
2103
- this._scrollToCursor();
2464
+ // Don't scroll - viewport should stay in place when selecting all
2104
2465
  this.scheduleRender();
2105
2466
  }
2106
2467
 
@@ -2160,6 +2521,78 @@ class GeoJsonEditor extends HTMLElement {
2160
2521
  this.selectionEnd = null;
2161
2522
  }
2162
2523
 
2524
+ /**
2525
+ * Select word/token at given position (for double-click)
2526
+ */
2527
+ private _selectWordAt(line: number, column: number): void {
2528
+ if (line < 0 || line >= this.lines.length) return;
2529
+ const lineContent = this.lines[line];
2530
+ if (!lineContent || column > lineContent.length) return;
2531
+
2532
+ // Define word characters (letters, digits, underscore, hyphen for JSON keys)
2533
+ const isWordChar = (ch: string) => /[\w\-]/.test(ch);
2534
+
2535
+ // Check if we're inside a string (find surrounding quotes)
2536
+ let inString = false;
2537
+ let stringStart = -1;
2538
+ let stringEnd = -1;
2539
+ let escaped = false;
2540
+
2541
+ for (let i = 0; i < lineContent.length; i++) {
2542
+ const ch = lineContent[i];
2543
+ if (escaped) {
2544
+ escaped = false;
2545
+ continue;
2546
+ }
2547
+ if (ch === '\\') {
2548
+ escaped = true;
2549
+ continue;
2550
+ }
2551
+ if (ch === '"') {
2552
+ if (!inString) {
2553
+ inString = true;
2554
+ stringStart = i;
2555
+ } else {
2556
+ stringEnd = i;
2557
+ // Check if column is within this string (including quotes)
2558
+ if (column >= stringStart && column <= stringEnd) {
2559
+ // Select the string content (without quotes)
2560
+ this.selectionStart = { line, column: stringStart + 1 };
2561
+ this.selectionEnd = { line, column: stringEnd };
2562
+ this.cursorLine = line;
2563
+ this.cursorColumn = stringEnd;
2564
+ return;
2565
+ }
2566
+ inString = false;
2567
+ stringStart = -1;
2568
+ stringEnd = -1;
2569
+ }
2570
+ }
2571
+ }
2572
+
2573
+ // Not in a string - select word characters
2574
+ let start = column;
2575
+ let end = column;
2576
+
2577
+ // Find start of word
2578
+ while (start > 0 && isWordChar(lineContent[start - 1])) {
2579
+ start--;
2580
+ }
2581
+
2582
+ // Find end of word
2583
+ while (end < lineContent.length && isWordChar(lineContent[end])) {
2584
+ end++;
2585
+ }
2586
+
2587
+ // If we found a word, select it
2588
+ if (start < end) {
2589
+ this.selectionStart = { line, column: start };
2590
+ this.selectionEnd = { line, column: end };
2591
+ this.cursorLine = line;
2592
+ this.cursorColumn = end;
2593
+ }
2594
+ }
2595
+
2163
2596
  /**
2164
2597
  * Delete selected text
2165
2598
  */
@@ -2252,15 +2685,22 @@ class GeoJsonEditor extends HTMLElement {
2252
2685
  this.insertText(text);
2253
2686
  }
2254
2687
 
2688
+ // Cancel any pending render from insertText/formatAndUpdate
2689
+ if (this.renderTimer) {
2690
+ cancelAnimationFrame(this.renderTimer);
2691
+ this.renderTimer = undefined;
2692
+ }
2693
+
2255
2694
  // Auto-collapse coordinates after pasting into empty editor
2256
2695
  if (wasEmpty && this.lines.length > 0) {
2257
- // Cancel pending render, collapse first, then render once
2258
- if (this.renderTimer) {
2259
- cancelAnimationFrame(this.renderTimer);
2260
- this.renderTimer = undefined;
2261
- }
2262
2696
  this.autoCollapseCoordinates();
2263
2697
  }
2698
+
2699
+ // Expand any collapsed nodes that contain errors
2700
+ this._expandErrorNodes();
2701
+
2702
+ // Force immediate render (not via RAF) to ensure content displays instantly
2703
+ this.renderViewport();
2264
2704
  }
2265
2705
 
2266
2706
  handleCopy(e: ClipboardEvent): void {
@@ -2430,14 +2870,23 @@ class GeoJsonEditor extends HTMLElement {
2430
2870
  }
2431
2871
 
2432
2872
  // ========== Collapse/Expand ==========
2433
-
2873
+
2434
2874
  toggleCollapse(nodeId: string): void {
2875
+ const nodeInfo = this._nodeIdToLines.get(nodeId);
2435
2876
  if (this.collapsedNodes.has(nodeId)) {
2436
2877
  this.collapsedNodes.delete(nodeId);
2878
+ // Track that user opened this node - don't re-collapse during edits
2879
+ if (nodeInfo?.uniqueKey) {
2880
+ this._openedNodeKeys.add(nodeInfo.uniqueKey);
2881
+ }
2437
2882
  } else {
2438
2883
  this.collapsedNodes.add(nodeId);
2884
+ // User closed it - allow re-collapse
2885
+ if (nodeInfo?.uniqueKey) {
2886
+ this._openedNodeKeys.delete(nodeInfo.uniqueKey);
2887
+ }
2439
2888
  }
2440
-
2889
+
2441
2890
  // Use updateView - don't rebuild nodeId mappings since content didn't change
2442
2891
  this.updateView();
2443
2892
  this._invalidateRenderCache(); // Force re-render
@@ -2445,13 +2894,47 @@ class GeoJsonEditor extends HTMLElement {
2445
2894
  }
2446
2895
 
2447
2896
  autoCollapseCoordinates() {
2897
+ // Don't collapse if there are errors - they should remain visible
2898
+ if (this._hasErrors()) {
2899
+ return;
2900
+ }
2448
2901
  this._applyCollapsedOption(['coordinates']);
2449
2902
  }
2450
2903
 
2904
+ /**
2905
+ * Check if current content has any errors (JSON parse errors or syntax highlighting errors)
2906
+ */
2907
+ private _hasErrors(): boolean {
2908
+ // Check JSON parse errors
2909
+ try {
2910
+ const content = this.lines.join('\n');
2911
+ const wrapped = '[' + content + ']';
2912
+ JSON.parse(wrapped);
2913
+ } catch {
2914
+ return true;
2915
+ }
2916
+
2917
+ // Check for syntax highlighting errors (json-error class)
2918
+ for (const line of this.lines) {
2919
+ const highlighted = highlightSyntax(line, '', undefined);
2920
+ if (highlighted.includes('json-error')) {
2921
+ return true;
2922
+ }
2923
+ }
2924
+
2925
+ return false;
2926
+ }
2927
+
2451
2928
  /**
2452
2929
  * Helper to apply collapsed option from API methods
2930
+ * Does not collapse if there are errors (so they remain visible)
2453
2931
  */
2454
2932
  private _applyCollapsedFromOptions(options: SetOptions, features: Feature[]): void {
2933
+ // Don't collapse if there are errors - they should remain visible
2934
+ if (this._hasErrors()) {
2935
+ return;
2936
+ }
2937
+
2455
2938
  const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2456
2939
  if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2457
2940
  this._applyCollapsedOption(collapsed, features);
@@ -2615,23 +3098,223 @@ class GeoJsonEditor extends HTMLElement {
2615
3098
  }
2616
3099
 
2617
3100
  // ========== Format and Update ==========
2618
-
3101
+
3102
+ /**
3103
+ * Best-effort formatting for invalid JSON
3104
+ * Splits on structural characters and indents as much as possible
3105
+ * @param content The content to format
3106
+ * @param skipLineIndex Optional line index to skip (keep as-is)
3107
+ */
3108
+ private _bestEffortFormat(content: string, skipLineIndex?: number): string[] {
3109
+ const sourceLines = content.split('\n');
3110
+
3111
+ // If we have a line to skip, handle it specially
3112
+ if (skipLineIndex !== undefined && skipLineIndex >= 0 && skipLineIndex < sourceLines.length) {
3113
+ const skippedLine = sourceLines[skipLineIndex];
3114
+
3115
+ // Format content before the skipped line
3116
+ const beforeContent = sourceLines.slice(0, skipLineIndex).join('\n');
3117
+ const beforeLines = beforeContent.trim() ? this._formatChunk(beforeContent) : [];
3118
+
3119
+ // Keep skipped line exactly as-is (don't re-indent, user is typing on it)
3120
+ const depthBefore = this._computeDepthAtEnd(beforeLines);
3121
+
3122
+ // Compute depth after the skipped line (including its brackets)
3123
+ const depthAfterSkipped = depthBefore + this._computeBracketDelta(skippedLine);
3124
+
3125
+ // Format content after the skipped line, starting at correct depth
3126
+ const afterContent = sourceLines.slice(skipLineIndex + 1).join('\n');
3127
+ const afterLines = afterContent.trim() ? this._formatChunk(afterContent, depthAfterSkipped) : [];
3128
+
3129
+ return [...beforeLines, skippedLine, ...afterLines];
3130
+ }
3131
+
3132
+ // No line to skip - format everything
3133
+ return this._formatChunk(content);
3134
+ }
3135
+
3136
+ /**
3137
+ * Compute the net bracket delta for a line (opens - closes)
3138
+ */
3139
+ private _computeBracketDelta(line: string): number {
3140
+ let delta = 0;
3141
+ let inString = false;
3142
+ let escaped = false;
3143
+ for (const char of line) {
3144
+ if (escaped) { escaped = false; continue; }
3145
+ if (char === '\\' && inString) { escaped = true; continue; }
3146
+ if (char === '"') { inString = !inString; continue; }
3147
+ if (inString) continue;
3148
+ if (char === '{' || char === '[') delta++;
3149
+ else if (char === '}' || char === ']') delta--;
3150
+ }
3151
+ return delta;
3152
+ }
3153
+
3154
+ /**
3155
+ * Compute the bracket depth at the end of formatted lines
3156
+ * Starts at 1 to account for FeatureCollection wrapper
3157
+ */
3158
+ private _computeDepthAtEnd(lines: string[]): number {
3159
+ let depth = 1; // Start at 1 for FeatureCollection wrapper
3160
+ for (const line of lines) {
3161
+ for (const char of line) {
3162
+ if (char === '{' || char === '[') depth++;
3163
+ else if (char === '}' || char === ']') depth = Math.max(0, depth - 1);
3164
+ }
3165
+ }
3166
+ return depth;
3167
+ }
3168
+
3169
+ /**
3170
+ * Format a chunk of JSON content
3171
+ * @param content The content to format
3172
+ * @param initialDepth Starting indentation depth (default 1 for FeatureCollection wrapper)
3173
+ */
3174
+ private _formatChunk(content: string, initialDepth: number = 1): string[] {
3175
+ const result: string[] = [];
3176
+ let currentLine = '';
3177
+ let depth = initialDepth;
3178
+ let inString = false;
3179
+ let escaped = false;
3180
+
3181
+ for (let i = 0; i < content.length; i++) {
3182
+ const char = content[i];
3183
+
3184
+ // Track escape sequences inside strings
3185
+ if (escaped) {
3186
+ currentLine += char;
3187
+ escaped = false;
3188
+ continue;
3189
+ }
3190
+
3191
+ if (char === '\\' && inString) {
3192
+ currentLine += char;
3193
+ escaped = true;
3194
+ continue;
3195
+ }
3196
+
3197
+ // Track if we're inside a string
3198
+ if (char === '"') {
3199
+ inString = !inString;
3200
+ currentLine += char;
3201
+ continue;
3202
+ }
3203
+
3204
+ // Inside string - just append
3205
+ if (inString) {
3206
+ currentLine += char;
3207
+ continue;
3208
+ }
3209
+
3210
+ // Outside string - handle structural characters
3211
+ if (char === '{' || char === '[') {
3212
+ currentLine += char;
3213
+ result.push(' '.repeat(depth) + currentLine.trim());
3214
+ depth++;
3215
+ currentLine = '';
3216
+ } else if (char === '}' || char === ']') {
3217
+ if (currentLine.trim()) {
3218
+ result.push(' '.repeat(depth) + currentLine.trim());
3219
+ }
3220
+ depth = Math.max(0, depth - 1);
3221
+ currentLine = char;
3222
+ } else if (char === ',') {
3223
+ currentLine += char;
3224
+ result.push(' '.repeat(depth) + currentLine.trim());
3225
+ currentLine = '';
3226
+ } else if (char === ':') {
3227
+ currentLine += ': '; // Add space after colon for readability
3228
+ i++; // Skip if next char is space
3229
+ if (content[i] === ' ') continue;
3230
+ i--; // Not a space, go back
3231
+ } else if (char === '\n' || char === '\r') {
3232
+ // Ignore existing newlines
3233
+ continue;
3234
+ } else {
3235
+ currentLine += char;
3236
+ }
3237
+ }
3238
+
3239
+ // Don't forget last line
3240
+ if (currentLine.trim()) {
3241
+ result.push(' '.repeat(depth) + currentLine.trim());
3242
+ }
3243
+
3244
+ return result;
3245
+ }
3246
+
2619
3247
  formatAndUpdate() {
3248
+ // Save cursor position
3249
+ const oldCursorLine = this.cursorLine;
3250
+ const oldCursorColumn = this.cursorColumn;
3251
+ const oldContent = this.lines.join('\n');
3252
+
2620
3253
  try {
2621
- const content = this.lines.join('\n');
2622
- const wrapped = '[' + content + ']';
3254
+ const wrapped = '[' + oldContent + ']';
2623
3255
  const parsed = JSON.parse(wrapped);
2624
-
3256
+
2625
3257
  const formatted = JSON.stringify(parsed, null, 2);
2626
3258
  const lines = formatted.split('\n');
2627
3259
  this.lines = lines.slice(1, -1); // Remove wrapper brackets
2628
- } catch (e) {
2629
- // Invalid JSON, keep as-is
3260
+ } catch {
3261
+ // Invalid JSON - apply best-effort formatting
3262
+ if (oldContent.trim()) {
3263
+ // Skip the cursor line only for small content (typing, not paste)
3264
+ // This avoids text jumping while user is typing
3265
+ // For paste/large insertions, format everything for proper structure
3266
+ const cursorLineContent = this.lines[oldCursorLine] || '';
3267
+ // If cursor line is short, likely typing. Long lines = paste
3268
+ const isSmallEdit = cursorLineContent.length < 80;
3269
+ const skipLine = isSmallEdit ? oldCursorLine : undefined;
3270
+ this.lines = this._bestEffortFormat(oldContent, skipLine);
3271
+ }
2630
3272
  }
2631
-
3273
+
3274
+ const newContent = this.lines.join('\n');
3275
+
3276
+ // If content didn't change, keep cursor exactly where it was
3277
+ if (newContent === oldContent) {
3278
+ this.cursorLine = oldCursorLine;
3279
+ this.cursorColumn = oldCursorColumn;
3280
+ } else {
3281
+ // Content changed due to reformatting
3282
+ // The cursor position (this.cursorLine, this.cursorColumn) was set by the calling
3283
+ // operation (insertText, insertNewline, etc.) BEFORE formatAndUpdate was called.
3284
+ // We need to adjust for indentation changes while keeping the logical position.
3285
+
3286
+ // If cursor is at column 0 (e.g., after newline), keep it there
3287
+ // This preserves expected behavior for newline insertion
3288
+ if (this.cursorColumn === 0) {
3289
+ // Just keep line, column 0 - indentation will be handled by auto-indent
3290
+ } else {
3291
+ // For other cases, try to maintain position relative to content (not indentation)
3292
+ const oldLines = oldContent.split('\n');
3293
+ const oldLineContent = oldLines[oldCursorLine] || '';
3294
+ const oldLeadingSpaces = oldLineContent.length - oldLineContent.trimStart().length;
3295
+ const oldColumnInContent = Math.max(0, oldCursorColumn - oldLeadingSpaces);
3296
+
3297
+ // Apply same offset to new line's indentation
3298
+ if (this.cursorLine < this.lines.length) {
3299
+ const newLineContent = this.lines[this.cursorLine];
3300
+ const newLeadingSpaces = newLineContent.length - newLineContent.trimStart().length;
3301
+ this.cursorColumn = newLeadingSpaces + oldColumnInContent;
3302
+ }
3303
+ }
3304
+ }
3305
+
3306
+ // Clamp cursor to valid range
3307
+ this.cursorLine = Math.min(this.cursorLine, Math.max(0, this.lines.length - 1));
3308
+ this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
3309
+
2632
3310
  this.updateModel();
3311
+
3312
+ // Expand any nodes that contain errors (prevents closing edited nodes with typos)
3313
+ this._expandErrorNodes();
3314
+
2633
3315
  this.scheduleRender();
2634
3316
  this.updatePlaceholderVisibility();
3317
+ this._updateErrorDisplay();
2635
3318
  this.emitChange();
2636
3319
  }
2637
3320
 
@@ -2691,6 +3374,21 @@ class GeoJsonEditor extends HTMLElement {
2691
3374
  }
2692
3375
  }
2693
3376
 
3377
+ /**
3378
+ * Update error display (counter and navigation visibility)
3379
+ */
3380
+ private _updateErrorDisplay() {
3381
+ const errorLines = this._getErrorLines();
3382
+ const count = errorLines.length;
3383
+
3384
+ if (this._errorNav) {
3385
+ this._errorNav.classList.toggle('visible', count > 0);
3386
+ }
3387
+ if (this._errorCount) {
3388
+ this._errorCount.textContent = count > 0 ? String(count) : '';
3389
+ }
3390
+ }
3391
+
2694
3392
  updatePlaceholderContent() {
2695
3393
  if (this._placeholderLayer) {
2696
3394
  this._placeholderLayer.textContent = this.placeholder;