@softwarity/geojson-editor 1.0.18 → 1.0.19

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 ==========
@@ -728,6 +727,14 @@ class GeoJsonEditor extends HTMLElement {
728
727
  this.removeAll();
729
728
  });
730
729
 
730
+ // Error navigation buttons
731
+ this._prevErrorBtn?.addEventListener('click', () => {
732
+ this.goToPrevError();
733
+ });
734
+ this._nextErrorBtn?.addEventListener('click', () => {
735
+ this.goToNextError();
736
+ });
737
+
731
738
  // Initial readonly state
732
739
  this.updateReadonly();
733
740
  }
@@ -763,6 +770,7 @@ class GeoJsonEditor extends HTMLElement {
763
770
  // Clear state for new content
764
771
  this.collapsedNodes.clear();
765
772
  this.hiddenFeatures.clear();
773
+ this._openedNodeKeys.clear();
766
774
  this._lineToNodeId.clear();
767
775
  this._nodeIdToLines.clear();
768
776
  this.cursorLine = 0;
@@ -794,8 +802,9 @@ class GeoJsonEditor extends HTMLElement {
794
802
  * Rebuilds line-to-nodeId mapping while preserving collapsed state
795
803
  */
796
804
  updateModel() {
797
- // Invalidate context map cache since content changed
805
+ // Invalidate caches since content changed
798
806
  this._contextMapCache = null;
807
+ this._errorLinesCache = null;
799
808
 
800
809
  // Rebuild lineToNodeId mapping (may shift due to edits)
801
810
  this._rebuildNodeIdMappings();
@@ -891,9 +900,12 @@ class GeoJsonEditor extends HTMLElement {
891
900
  */
892
901
  computeLineMetadata() {
893
902
  this.lineMetadata.clear();
894
-
903
+
895
904
  const collapsibleRanges = this._findCollapsibleRanges();
896
-
905
+
906
+ // Compute error lines once (cached)
907
+ const errorLines = this._computeErrorLines();
908
+
897
909
  for (let i = 0; i < this.lines.length; i++) {
898
910
  const line = this.lines[i];
899
911
  const meta: LineMeta = {
@@ -903,9 +915,10 @@ class GeoJsonEditor extends HTMLElement {
903
915
  visibilityButton: null,
904
916
  isHidden: false,
905
917
  isCollapsed: false,
906
- featureKey: null
918
+ featureKey: null,
919
+ hasError: errorLines.has(i)
907
920
  };
908
-
921
+
909
922
  // Detect colors and booleans in a single pass
910
923
  RE_ATTR_VALUE.lastIndex = 0;
911
924
  let match: RegExpExecArray | null;
@@ -925,7 +938,7 @@ class GeoJsonEditor extends HTMLElement {
925
938
  }
926
939
  }
927
940
  }
928
-
941
+
929
942
  // Check if line starts a collapsible node
930
943
  const collapsible = collapsibleRanges.find(r => r.startLine === i);
931
944
  if (collapsible) {
@@ -935,15 +948,15 @@ class GeoJsonEditor extends HTMLElement {
935
948
  isCollapsed: this.collapsedNodes.has(collapsible.nodeId)
936
949
  };
937
950
  }
938
-
951
+
939
952
  // Check if line is inside a collapsed node (exclude closing bracket line)
940
- const insideCollapsed = collapsibleRanges.find(r =>
953
+ const insideCollapsed = collapsibleRanges.find(r =>
941
954
  this.collapsedNodes.has(r.nodeId) && i > r.startLine && i < r.endLine
942
955
  );
943
956
  if (insideCollapsed) {
944
957
  meta.isCollapsed = true;
945
958
  }
946
-
959
+
947
960
  // Check if line belongs to a hidden feature
948
961
  for (const [featureKey, range] of this.featureRanges) {
949
962
  if (i >= range.startLine && i <= range.endLine) {
@@ -961,11 +974,152 @@ class GeoJsonEditor extends HTMLElement {
961
974
  break;
962
975
  }
963
976
  }
964
-
977
+
965
978
  this.lineMetadata.set(i, meta);
966
979
  }
967
980
  }
968
981
 
982
+ /**
983
+ * Compute error lines (syntax highlighting + structural errors)
984
+ * Called once per model update, result is used by computeLineMetadata
985
+ */
986
+ private _computeErrorLines(): Set<number> {
987
+ if (this._errorLinesCache !== null) {
988
+ return this._errorLinesCache;
989
+ }
990
+
991
+ const errorLines = new Set<number>();
992
+
993
+ // Check syntax highlighting errors for each line
994
+ for (let i = 0; i < this.lines.length; i++) {
995
+ const highlighted = highlightSyntax(this.lines[i], '', undefined);
996
+ if (highlighted.includes('json-error')) {
997
+ errorLines.add(i);
998
+ }
999
+ }
1000
+
1001
+ // Check structural error from JSON.parse
1002
+ try {
1003
+ const content = this.lines.join('\n');
1004
+ const wrapped = '[' + content + ']';
1005
+ JSON.parse(wrapped);
1006
+ } catch (e) {
1007
+ if (e instanceof Error) {
1008
+ // Try to extract line number from error message
1009
+ // Chrome/Node: "... at line X column Y"
1010
+ const lineMatch = e.message.match(/line (\d+)/);
1011
+ if (lineMatch) {
1012
+ // Subtract 1 because we wrapped with '[' on first line
1013
+ const errorLine = Math.max(0, parseInt(lineMatch[1], 10) - 1);
1014
+ errorLines.add(errorLine);
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ this._errorLinesCache = errorLines;
1020
+ return errorLines;
1021
+ }
1022
+
1023
+ /**
1024
+ * Get all lines that have errors (for navigation and counting)
1025
+ * Returns array of line indices sorted by line number
1026
+ */
1027
+ private _getErrorLines(): number[] {
1028
+ const errorLines: number[] = [];
1029
+ for (const [lineIndex, meta] of this.lineMetadata) {
1030
+ if (meta.hasError) {
1031
+ errorLines.push(lineIndex);
1032
+ }
1033
+ }
1034
+ return errorLines.sort((a, b) => a - b);
1035
+ }
1036
+
1037
+ /**
1038
+ * Navigate to the next error line
1039
+ */
1040
+ goToNextError(): boolean {
1041
+ const errorLines = this._getErrorLines();
1042
+ if (errorLines.length === 0) return false;
1043
+
1044
+ // Find next error after current cursor position
1045
+ const nextError = errorLines.find(line => line > this.cursorLine);
1046
+ const targetLine = nextError !== undefined ? nextError : errorLines[0]; // Wrap to first
1047
+
1048
+ return this._goToErrorLine(targetLine);
1049
+ }
1050
+
1051
+ /**
1052
+ * Navigate to the previous error line
1053
+ */
1054
+ goToPrevError(): boolean {
1055
+ const errorLines = this._getErrorLines();
1056
+ if (errorLines.length === 0) return false;
1057
+
1058
+ // Find previous error before current cursor position
1059
+ const prevErrors = errorLines.filter(line => line < this.cursorLine);
1060
+ const targetLine = prevErrors.length > 0 ? prevErrors[prevErrors.length - 1] : errorLines[errorLines.length - 1]; // Wrap to last
1061
+
1062
+ return this._goToErrorLine(targetLine);
1063
+ }
1064
+
1065
+ /**
1066
+ * Expand all collapsed nodes containing a specific line
1067
+ * Returns true if any nodes were expanded
1068
+ */
1069
+ private _expandNodesContainingLine(lineIndex: number): boolean {
1070
+ let expanded = false;
1071
+ for (const [nodeId, nodeInfo] of this._nodeIdToLines) {
1072
+ if (this.collapsedNodes.has(nodeId) && lineIndex > nodeInfo.startLine && lineIndex <= nodeInfo.endLine) {
1073
+ this.collapsedNodes.delete(nodeId);
1074
+ // Track that this node was opened - don't re-collapse during edits
1075
+ if (nodeInfo.uniqueKey) {
1076
+ this._openedNodeKeys.add(nodeInfo.uniqueKey);
1077
+ }
1078
+ expanded = true;
1079
+ }
1080
+ }
1081
+ return expanded;
1082
+ }
1083
+
1084
+ /**
1085
+ * Navigate to a specific error line
1086
+ */
1087
+ private _goToErrorLine(lineIndex: number): boolean {
1088
+ if (this._expandNodesContainingLine(lineIndex)) {
1089
+ this.updateView();
1090
+ }
1091
+
1092
+ this.cursorLine = lineIndex;
1093
+ this.cursorColumn = 0;
1094
+ this._invalidateRenderCache();
1095
+ this._scrollToCursor(true); // Center the error line
1096
+ this.renderViewport();
1097
+ this._updateErrorDisplay();
1098
+
1099
+ // Focus the editor
1100
+ this._hiddenTextarea?.focus();
1101
+ return true;
1102
+ }
1103
+
1104
+ /**
1105
+ * Expand all collapsed nodes that contain error lines
1106
+ */
1107
+ private _expandErrorNodes(): void {
1108
+ const errorLines = this._getErrorLines();
1109
+ if (errorLines.length === 0) return;
1110
+
1111
+ let expanded = false;
1112
+ for (const errorLine of errorLines) {
1113
+ if (this._expandNodesContainingLine(errorLine)) {
1114
+ expanded = true;
1115
+ }
1116
+ }
1117
+
1118
+ if (expanded) {
1119
+ this.updateView();
1120
+ }
1121
+ }
1122
+
969
1123
  /**
970
1124
  * Compute which lines are visible (not inside collapsed nodes)
971
1125
  */
@@ -1012,10 +1166,15 @@ class GeoJsonEditor extends HTMLElement {
1012
1166
 
1013
1167
  const totalLines = this.visibleLines.length;
1014
1168
  const totalHeight = totalLines * this.lineHeight;
1015
-
1016
- // Set total scrollable height (only once or when content changes)
1169
+
1170
+ // Set total scrollable dimensions (height and width based on content)
1017
1171
  if (scrollContent) {
1018
1172
  scrollContent.style.height = `${totalHeight}px`;
1173
+ // Calculate max line width to update horizontal scroll
1174
+ const charWidth = this._getCharWidth();
1175
+ const maxLineLength = this.lines.reduce((max, line) => Math.max(max, line.length), 0);
1176
+ const minWidth = maxLineLength * charWidth + 20; // 20px padding
1177
+ scrollContent.style.minWidth = `${minWidth}px`;
1019
1178
  }
1020
1179
 
1021
1180
  // Calculate visible range based on scroll position
@@ -1206,18 +1365,23 @@ class GeoJsonEditor extends HTMLElement {
1206
1365
  for (let i = startIndex; i < endIndex; i++) {
1207
1366
  const lineData = this.visibleLines[i];
1208
1367
  if (!lineData) continue;
1209
-
1368
+
1210
1369
  const gutterLine = _ce('div');
1211
1370
  gutterLine.className = 'gutter-line';
1212
-
1371
+
1213
1372
  const meta = lineData.meta;
1214
-
1373
+
1374
+ // Add error indicator class
1375
+ if (meta?.hasError) {
1376
+ gutterLine.classList.add('has-error');
1377
+ }
1378
+
1215
1379
  // Line number first
1216
1380
  const lineNum = _ce('span');
1217
1381
  lineNum.className = 'line-number';
1218
1382
  lineNum.textContent = String(lineData.index + 1);
1219
1383
  gutterLine.appendChild(lineNum);
1220
-
1384
+
1221
1385
  // Collapse column (always present for alignment)
1222
1386
  const collapseCol = _ce('div');
1223
1387
  collapseCol.className = 'collapse-column';
@@ -1231,7 +1395,7 @@ class GeoJsonEditor extends HTMLElement {
1231
1395
  collapseCol.appendChild(btn);
1232
1396
  }
1233
1397
  gutterLine.appendChild(collapseCol);
1234
-
1398
+
1235
1399
  fragment.appendChild(gutterLine);
1236
1400
  }
1237
1401
 
@@ -1355,6 +1519,8 @@ class GeoJsonEditor extends HTMLElement {
1355
1519
  'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1356
1520
  'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1357
1521
  'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1522
+ 'PageUp': () => this._handlePageUpDown('up', e.shiftKey),
1523
+ 'PageDown': () => this._handlePageUpDown('down', e.shiftKey),
1358
1524
  'Tab': () => this._handleTab(e.shiftKey, ctx),
1359
1525
  'Insert': () => { this._insertMode = !this._insertMode; this.scheduleRender(); }
1360
1526
  };
@@ -1418,8 +1584,7 @@ class GeoJsonEditor extends HTMLElement {
1418
1584
  // Block in collapsed zones
1419
1585
  if (ctx.inCollapsedZone) return;
1420
1586
 
1421
- // Normal Enter: insert newline
1422
- this.insertNewline();
1587
+ // Enter anywhere else: do nothing (JSON structure is managed automatically)
1423
1588
  }
1424
1589
 
1425
1590
  private _handleBackspace(ctx: CollapsedZoneContext): void {
@@ -1502,6 +1667,7 @@ class GeoJsonEditor extends HTMLElement {
1502
1667
 
1503
1668
  /**
1504
1669
  * Navigate to the next attribute (key or value) in the JSON
1670
+ * Also stops on collapsed node brackets to allow expansion with Enter
1505
1671
  */
1506
1672
  private _navigateToNextAttribute(): void {
1507
1673
  const totalLines = this.visibleLines.length;
@@ -1514,13 +1680,17 @@ class GeoJsonEditor extends HTMLElement {
1514
1680
  const line = this.lines[vl.index];
1515
1681
  const startCol = (i === currentVisibleIdx) ? this.cursorColumn : 0;
1516
1682
 
1517
- const pos = this._findNextAttributeInLine(line, startCol);
1683
+ const pos = this._findNextAttributeOrBracket(line, startCol, vl.index);
1518
1684
  if (pos !== null) {
1519
1685
  this.cursorLine = vl.index;
1520
1686
  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 };
1687
+ // Select the attribute key or value (not brackets)
1688
+ if (!pos.isBracket) {
1689
+ this.selectionStart = { line: vl.index, column: pos.start };
1690
+ this.selectionEnd = { line: vl.index, column: pos.end };
1691
+ } else {
1692
+ this._clearSelection();
1693
+ }
1524
1694
  this._scrollToCursor();
1525
1695
  this._invalidateRenderCache();
1526
1696
  this.scheduleRender();
@@ -1532,12 +1702,16 @@ class GeoJsonEditor extends HTMLElement {
1532
1702
  for (let i = 0; i < currentVisibleIdx; i++) {
1533
1703
  const vl = this.visibleLines[i];
1534
1704
  const line = this.lines[vl.index];
1535
- const pos = this._findNextAttributeInLine(line, 0);
1705
+ const pos = this._findNextAttributeOrBracket(line, 0, vl.index);
1536
1706
  if (pos !== null) {
1537
1707
  this.cursorLine = vl.index;
1538
1708
  this.cursorColumn = pos.start;
1539
- this.selectionStart = { line: vl.index, column: pos.start };
1540
- this.selectionEnd = { line: vl.index, column: pos.end };
1709
+ if (!pos.isBracket) {
1710
+ this.selectionStart = { line: vl.index, column: pos.start };
1711
+ this.selectionEnd = { line: vl.index, column: pos.end };
1712
+ } else {
1713
+ this._clearSelection();
1714
+ }
1541
1715
  this._scrollToCursor();
1542
1716
  this._invalidateRenderCache();
1543
1717
  this.scheduleRender();
@@ -1548,6 +1722,7 @@ class GeoJsonEditor extends HTMLElement {
1548
1722
 
1549
1723
  /**
1550
1724
  * Navigate to the previous attribute (key or value) in the JSON
1725
+ * Also stops on collapsed node brackets to allow expansion with Enter
1551
1726
  */
1552
1727
  private _navigateToPrevAttribute(): void {
1553
1728
  const totalLines = this.visibleLines.length;
@@ -1560,12 +1735,16 @@ class GeoJsonEditor extends HTMLElement {
1560
1735
  const line = this.lines[vl.index];
1561
1736
  const endCol = (i === currentVisibleIdx) ? this.cursorColumn : line.length;
1562
1737
 
1563
- const pos = this._findPrevAttributeInLine(line, endCol);
1738
+ const pos = this._findPrevAttributeOrBracket(line, endCol, vl.index);
1564
1739
  if (pos !== null) {
1565
1740
  this.cursorLine = vl.index;
1566
1741
  this.cursorColumn = pos.start;
1567
- this.selectionStart = { line: vl.index, column: pos.start };
1568
- this.selectionEnd = { line: vl.index, column: pos.end };
1742
+ if (!pos.isBracket) {
1743
+ this.selectionStart = { line: vl.index, column: pos.start };
1744
+ this.selectionEnd = { line: vl.index, column: pos.end };
1745
+ } else {
1746
+ this._clearSelection();
1747
+ }
1569
1748
  this._scrollToCursor();
1570
1749
  this._invalidateRenderCache();
1571
1750
  this.scheduleRender();
@@ -1577,12 +1756,16 @@ class GeoJsonEditor extends HTMLElement {
1577
1756
  for (let i = totalLines - 1; i > currentVisibleIdx; i--) {
1578
1757
  const vl = this.visibleLines[i];
1579
1758
  const line = this.lines[vl.index];
1580
- const pos = this._findPrevAttributeInLine(line, line.length);
1759
+ const pos = this._findPrevAttributeOrBracket(line, line.length, vl.index);
1581
1760
  if (pos !== null) {
1582
1761
  this.cursorLine = vl.index;
1583
1762
  this.cursorColumn = pos.start;
1584
- this.selectionStart = { line: vl.index, column: pos.start };
1585
- this.selectionEnd = { line: vl.index, column: pos.end };
1763
+ if (!pos.isBracket) {
1764
+ this.selectionStart = { line: vl.index, column: pos.start };
1765
+ this.selectionEnd = { line: vl.index, column: pos.end };
1766
+ } else {
1767
+ this._clearSelection();
1768
+ }
1586
1769
  this._scrollToCursor();
1587
1770
  this._invalidateRenderCache();
1588
1771
  this.scheduleRender();
@@ -1594,71 +1777,88 @@ class GeoJsonEditor extends HTMLElement {
1594
1777
  /**
1595
1778
  * Find next attribute position in a line after startCol
1596
1779
  * Returns {start, end} for the key or value, or null if none found
1780
+ * Also finds standalone values (numbers in arrays, etc.)
1597
1781
  */
1598
1782
  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;
1783
+ // Collect all navigable positions
1784
+ const positions: { start: number; end: number }[] = [];
1785
+
1786
+ // Pattern for "key": value pairs
1787
+ const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1601
1788
  let match;
1602
1789
 
1603
- while ((match = re.exec(line)) !== null) {
1790
+ while ((match = keyValueRe.exec(line)) !== null) {
1604
1791
  const keyStart = match.index + 1; // Skip opening quote
1605
1792
  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
- }
1793
+ positions.push({ start: keyStart, end: keyEnd });
1611
1794
 
1612
1795
  // Check if there's a value (string, number, boolean, null)
1613
1796
  if (match[2] !== undefined) {
1614
- // String value - find its position
1797
+ // String value
1615
1798
  const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
1616
1799
  if (valueMatch) {
1617
1800
  const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1618
1801
  const valueEnd = valueStart + match[2].length;
1619
- if (valueStart > startCol) {
1620
- return { start: valueStart, end: valueEnd };
1621
- }
1802
+ positions.push({ start: valueStart, end: valueEnd });
1622
1803
  }
1623
1804
  } else if (match[3] !== undefined) {
1624
- // Number value
1805
+ // Number value after colon
1625
1806
  const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1626
1807
  if (numMatch) {
1627
1808
  const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1628
1809
  const valueEnd = valueStart + numMatch[1].length;
1629
- if (valueStart > startCol) {
1630
- return { start: valueStart, end: valueEnd };
1631
- }
1810
+ positions.push({ start: valueStart, end: valueEnd });
1632
1811
  }
1633
1812
  } else {
1634
- // Boolean or null - check after the colon
1813
+ // Boolean or null
1635
1814
  const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1636
1815
  if (boolMatch) {
1637
1816
  const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1638
1817
  const valueEnd = valueStart + boolMatch[1].length;
1639
- if (valueStart > startCol) {
1640
- return { start: valueStart, end: valueEnd };
1641
- }
1818
+ positions.push({ start: valueStart, end: valueEnd });
1642
1819
  }
1643
1820
  }
1644
1821
  }
1645
1822
 
1823
+ // Also find standalone numbers (not after a colon) - for array elements
1824
+ const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
1825
+ while ((match = standaloneNumRe.exec(line)) !== null) {
1826
+ const numStr = match[1];
1827
+ const numStart = match.index + match[0].indexOf(numStr);
1828
+ const numEnd = numStart + numStr.length;
1829
+ // Avoid duplicates (numbers already captured by key-value pattern)
1830
+ if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
1831
+ positions.push({ start: numStart, end: numEnd });
1832
+ }
1833
+ }
1834
+
1835
+ // Sort by start position and find first after startCol
1836
+ positions.sort((a, b) => a.start - b.start);
1837
+ for (const pos of positions) {
1838
+ if (pos.start > startCol) {
1839
+ return pos;
1840
+ }
1841
+ }
1842
+
1646
1843
  return null;
1647
1844
  }
1648
1845
 
1649
1846
  /**
1650
1847
  * Find previous attribute position in a line before endCol
1848
+ * Also finds standalone values (numbers in arrays, etc.)
1651
1849
  */
1652
1850
  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;
1851
+ // Collect all navigable positions
1852
+ const positions: { start: number; end: number }[] = [];
1853
+
1854
+ // Pattern for "key": value pairs
1855
+ const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1656
1856
  let match;
1657
1857
 
1658
- while ((match = re.exec(line)) !== null) {
1858
+ while ((match = keyValueRe.exec(line)) !== null) {
1659
1859
  const keyStart = match.index + 1;
1660
1860
  const keyEnd = keyStart + match[1].length;
1661
- attrs.push({ start: keyStart, end: keyEnd });
1861
+ positions.push({ start: keyStart, end: keyEnd });
1662
1862
 
1663
1863
  // Check for value
1664
1864
  if (match[2] !== undefined) {
@@ -1666,35 +1866,120 @@ class GeoJsonEditor extends HTMLElement {
1666
1866
  if (valueMatch) {
1667
1867
  const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1668
1868
  const valueEnd = valueStart + match[2].length;
1669
- attrs.push({ start: valueStart, end: valueEnd });
1869
+ positions.push({ start: valueStart, end: valueEnd });
1670
1870
  }
1671
1871
  } else if (match[3] !== undefined) {
1672
1872
  const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1673
1873
  if (numMatch) {
1674
1874
  const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1675
1875
  const valueEnd = valueStart + numMatch[1].length;
1676
- attrs.push({ start: valueStart, end: valueEnd });
1876
+ positions.push({ start: valueStart, end: valueEnd });
1677
1877
  }
1678
1878
  } else {
1679
1879
  const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1680
1880
  if (boolMatch) {
1681
1881
  const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1682
1882
  const valueEnd = valueStart + boolMatch[1].length;
1683
- attrs.push({ start: valueStart, end: valueEnd });
1883
+ positions.push({ start: valueStart, end: valueEnd });
1684
1884
  }
1685
1885
  }
1686
1886
  }
1687
1887
 
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];
1888
+ // Also find standalone numbers (not after a colon) - for array elements
1889
+ const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
1890
+ while ((match = standaloneNumRe.exec(line)) !== null) {
1891
+ const numStr = match[1];
1892
+ const numStart = match.index + match[0].indexOf(numStr);
1893
+ const numEnd = numStart + numStr.length;
1894
+ // Avoid duplicates
1895
+ if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
1896
+ positions.push({ start: numStart, end: numEnd });
1897
+ }
1898
+ }
1899
+
1900
+ // Sort by start position and find last that ends before endCol
1901
+ positions.sort((a, b) => a.start - b.start);
1902
+ for (let i = positions.length - 1; i >= 0; i--) {
1903
+ if (positions[i].end < endCol) {
1904
+ return positions[i];
1692
1905
  }
1693
1906
  }
1694
1907
 
1695
1908
  return null;
1696
1909
  }
1697
1910
 
1911
+ /**
1912
+ * Find bracket position in a line (opening bracket for collapsible nodes)
1913
+ * Looks for { or [ at end of line (for both expanded and collapsed nodes)
1914
+ * Returns position AFTER the bracket, or null if not found
1915
+ */
1916
+ private _findBracketInLine(line: string): number | null {
1917
+ // Look for { or [ at end of line (indicates a collapsible node)
1918
+ // Works for both expanded and collapsed nodes - collapsed nodes still have
1919
+ // the bracket in raw text, the "..." is only added visually via CSS
1920
+ const bracketMatch = line.match(/[\[{]\s*$/);
1921
+ if (bracketMatch && bracketMatch.index !== undefined) {
1922
+ return bracketMatch.index + 1; // Position after bracket
1923
+ }
1924
+ return null;
1925
+ }
1926
+
1927
+ /**
1928
+ * Find next attribute or bracket position in a line
1929
+ * Returns position with isBracket flag to indicate if it's a bracket
1930
+ * For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
1931
+ * Stops on ALL opening brackets to allow collapse/expand navigation
1932
+ */
1933
+ private _findNextAttributeOrBracket(line: string, startCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
1934
+ // First check for regular attributes
1935
+ const attrPos = this._findNextAttributeInLine(line, startCol);
1936
+
1937
+ // Find opening bracket position (collapsed or expanded)
1938
+ const bracketPos = this._findBracketInLine(line);
1939
+
1940
+ // Return whichever comes first after startCol
1941
+ if (attrPos !== null && bracketPos !== null) {
1942
+ if (bracketPos > startCol && (bracketPos < attrPos.start)) {
1943
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1944
+ }
1945
+ return { ...attrPos, isBracket: false };
1946
+ } else if (attrPos !== null) {
1947
+ return { ...attrPos, isBracket: false };
1948
+ } else if (bracketPos !== null && bracketPos > startCol) {
1949
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1950
+ }
1951
+
1952
+ return null;
1953
+ }
1954
+
1955
+ /**
1956
+ * Find previous attribute or bracket position in a line
1957
+ * Returns position with isBracket flag to indicate if it's a bracket
1958
+ * For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
1959
+ * Stops on ALL opening brackets to allow collapse/expand navigation
1960
+ */
1961
+ private _findPrevAttributeOrBracket(line: string, endCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
1962
+ // First check for regular attributes
1963
+ const attrPos = this._findPrevAttributeInLine(line, endCol);
1964
+
1965
+ // Find opening bracket position (collapsed or expanded)
1966
+ const bracketPos = this._findBracketInLine(line);
1967
+
1968
+ // Return whichever comes last STRICTLY BEFORE endCol (to avoid staying in place)
1969
+ if (attrPos !== null && bracketPos !== null) {
1970
+ if (bracketPos < endCol && bracketPos > attrPos.end) {
1971
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1972
+ }
1973
+ return { ...attrPos, isBracket: false };
1974
+ } else if (attrPos !== null) {
1975
+ return { ...attrPos, isBracket: false };
1976
+ } else if (bracketPos !== null && bracketPos < endCol) {
1977
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1978
+ }
1979
+
1980
+ return null;
1981
+ }
1982
+
1698
1983
  insertNewline() {
1699
1984
  this._saveToHistory('newline');
1700
1985
 
@@ -1890,26 +2175,34 @@ class GeoJsonEditor extends HTMLElement {
1890
2175
 
1891
2176
  /**
1892
2177
  * Scroll viewport to ensure cursor is visible
2178
+ * @param center - if true, center the cursor line in the viewport
1893
2179
  */
1894
- private _scrollToCursor() {
2180
+ private _scrollToCursor(center = false) {
1895
2181
  const viewport = this._viewport;
1896
2182
  if (!viewport) return;
1897
-
2183
+
1898
2184
  // Find the visible line index for the cursor
1899
2185
  const visibleIndex = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1900
2186
  if (visibleIndex === -1) return;
1901
-
2187
+
1902
2188
  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;
2189
+ const viewportHeight = viewport.clientHeight;
2190
+
2191
+ if (center) {
2192
+ // Center the cursor line in the viewport
2193
+ viewport.scrollTop = Math.max(0, cursorY - viewportHeight / 2 + this.lineHeight / 2);
2194
+ } else {
2195
+ const viewportTop = viewport.scrollTop;
2196
+ const viewportBottom = viewportTop + viewportHeight;
2197
+
2198
+ // Scroll up if cursor is above viewport
2199
+ if (cursorY < viewportTop) {
2200
+ viewport.scrollTop = cursorY;
2201
+ }
2202
+ // Scroll down if cursor is below viewport
2203
+ else if (cursorY + this.lineHeight > viewportBottom) {
2204
+ viewport.scrollTop = cursorY + this.lineHeight - viewportHeight;
2205
+ }
1913
2206
  }
1914
2207
  }
1915
2208
 
@@ -2064,18 +2357,32 @@ class GeoJsonEditor extends HTMLElement {
2064
2357
  if (isShift && !this.selectionStart) {
2065
2358
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
2066
2359
  }
2067
-
2360
+
2068
2361
  if (key === 'home') {
2069
2362
  if (onClosingLine) {
2363
+ // On closing line of collapsed node: go to start line
2070
2364
  this.cursorLine = onClosingLine.startLine;
2365
+ this.cursorColumn = 0;
2366
+ } else if (this.cursorColumn === 0) {
2367
+ // Already at start of line: go to start of document
2368
+ this.cursorLine = 0;
2369
+ this.cursorColumn = 0;
2370
+ } else {
2371
+ // Go to start of line
2372
+ this.cursorColumn = 0;
2071
2373
  }
2072
- this.cursorColumn = 0;
2073
2374
  } else {
2074
- if (this.cursorLine < this.lines.length) {
2075
- this.cursorColumn = this.lines[this.cursorLine].length;
2375
+ const lineLength = this.lines[this.cursorLine]?.length || 0;
2376
+ if (this.cursorColumn === lineLength) {
2377
+ // Already at end of line: go to end of document
2378
+ this.cursorLine = this.lines.length - 1;
2379
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
2380
+ } else {
2381
+ // Go to end of line
2382
+ this.cursorColumn = lineLength;
2076
2383
  }
2077
2384
  }
2078
-
2385
+
2079
2386
  // Update selection end if shift is pressed
2080
2387
  if (isShift) {
2081
2388
  this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
@@ -2083,7 +2390,50 @@ class GeoJsonEditor extends HTMLElement {
2083
2390
  this.selectionStart = null;
2084
2391
  this.selectionEnd = null;
2085
2392
  }
2086
-
2393
+
2394
+ this._invalidateRenderCache();
2395
+ this._scrollToCursor();
2396
+ this.scheduleRender();
2397
+ }
2398
+
2399
+ /**
2400
+ * Handle PageUp/PageDown
2401
+ */
2402
+ private _handlePageUpDown(direction: 'up' | 'down', isShift: boolean): void {
2403
+ // Start selection if shift is pressed and no selection exists
2404
+ if (isShift && !this.selectionStart) {
2405
+ this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
2406
+ }
2407
+
2408
+ const viewport = this._viewport;
2409
+ if (!viewport) return;
2410
+
2411
+ // Calculate how many lines fit in the viewport
2412
+ const linesPerPage = Math.floor(viewport.clientHeight / this.lineHeight);
2413
+
2414
+ if (direction === 'up') {
2415
+ // Find current visible index and move up by page
2416
+ const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
2417
+ const newVisibleIdx = Math.max(0, currentVisibleIdx - linesPerPage);
2418
+ this.cursorLine = this.visibleLines[newVisibleIdx]?.index || 0;
2419
+ } else {
2420
+ // Find current visible index and move down by page
2421
+ const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
2422
+ const newVisibleIdx = Math.min(this.visibleLines.length - 1, currentVisibleIdx + linesPerPage);
2423
+ this.cursorLine = this.visibleLines[newVisibleIdx]?.index || this.lines.length - 1;
2424
+ }
2425
+
2426
+ // Clamp cursor column to line length
2427
+ this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
2428
+
2429
+ // Update selection end if shift is pressed
2430
+ if (isShift) {
2431
+ this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
2432
+ } else {
2433
+ this.selectionStart = null;
2434
+ this.selectionEnd = null;
2435
+ }
2436
+
2087
2437
  this._invalidateRenderCache();
2088
2438
  this._scrollToCursor();
2089
2439
  this.scheduleRender();
@@ -2098,9 +2448,9 @@ class GeoJsonEditor extends HTMLElement {
2098
2448
  this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
2099
2449
  this.cursorLine = lastLine;
2100
2450
  this.cursorColumn = this.lines[lastLine]?.length || 0;
2101
-
2451
+
2102
2452
  this._invalidateRenderCache();
2103
- this._scrollToCursor();
2453
+ // Don't scroll - viewport should stay in place when selecting all
2104
2454
  this.scheduleRender();
2105
2455
  }
2106
2456
 
@@ -2252,15 +2602,22 @@ class GeoJsonEditor extends HTMLElement {
2252
2602
  this.insertText(text);
2253
2603
  }
2254
2604
 
2605
+ // Cancel any pending render from insertText/formatAndUpdate
2606
+ if (this.renderTimer) {
2607
+ cancelAnimationFrame(this.renderTimer);
2608
+ this.renderTimer = undefined;
2609
+ }
2610
+
2255
2611
  // Auto-collapse coordinates after pasting into empty editor
2256
2612
  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
2613
  this.autoCollapseCoordinates();
2263
2614
  }
2615
+
2616
+ // Expand any collapsed nodes that contain errors
2617
+ this._expandErrorNodes();
2618
+
2619
+ // Force immediate render (not via RAF) to ensure content displays instantly
2620
+ this.renderViewport();
2264
2621
  }
2265
2622
 
2266
2623
  handleCopy(e: ClipboardEvent): void {
@@ -2430,14 +2787,23 @@ class GeoJsonEditor extends HTMLElement {
2430
2787
  }
2431
2788
 
2432
2789
  // ========== Collapse/Expand ==========
2433
-
2790
+
2434
2791
  toggleCollapse(nodeId: string): void {
2792
+ const nodeInfo = this._nodeIdToLines.get(nodeId);
2435
2793
  if (this.collapsedNodes.has(nodeId)) {
2436
2794
  this.collapsedNodes.delete(nodeId);
2795
+ // Track that user opened this node - don't re-collapse during edits
2796
+ if (nodeInfo?.uniqueKey) {
2797
+ this._openedNodeKeys.add(nodeInfo.uniqueKey);
2798
+ }
2437
2799
  } else {
2438
2800
  this.collapsedNodes.add(nodeId);
2801
+ // User closed it - allow re-collapse
2802
+ if (nodeInfo?.uniqueKey) {
2803
+ this._openedNodeKeys.delete(nodeInfo.uniqueKey);
2804
+ }
2439
2805
  }
2440
-
2806
+
2441
2807
  // Use updateView - don't rebuild nodeId mappings since content didn't change
2442
2808
  this.updateView();
2443
2809
  this._invalidateRenderCache(); // Force re-render
@@ -2445,13 +2811,47 @@ class GeoJsonEditor extends HTMLElement {
2445
2811
  }
2446
2812
 
2447
2813
  autoCollapseCoordinates() {
2814
+ // Don't collapse if there are errors - they should remain visible
2815
+ if (this._hasErrors()) {
2816
+ return;
2817
+ }
2448
2818
  this._applyCollapsedOption(['coordinates']);
2449
2819
  }
2450
2820
 
2821
+ /**
2822
+ * Check if current content has any errors (JSON parse errors or syntax highlighting errors)
2823
+ */
2824
+ private _hasErrors(): boolean {
2825
+ // Check JSON parse errors
2826
+ try {
2827
+ const content = this.lines.join('\n');
2828
+ const wrapped = '[' + content + ']';
2829
+ JSON.parse(wrapped);
2830
+ } catch {
2831
+ return true;
2832
+ }
2833
+
2834
+ // Check for syntax highlighting errors (json-error class)
2835
+ for (const line of this.lines) {
2836
+ const highlighted = highlightSyntax(line, '', undefined);
2837
+ if (highlighted.includes('json-error')) {
2838
+ return true;
2839
+ }
2840
+ }
2841
+
2842
+ return false;
2843
+ }
2844
+
2451
2845
  /**
2452
2846
  * Helper to apply collapsed option from API methods
2847
+ * Does not collapse if there are errors (so they remain visible)
2453
2848
  */
2454
2849
  private _applyCollapsedFromOptions(options: SetOptions, features: Feature[]): void {
2850
+ // Don't collapse if there are errors - they should remain visible
2851
+ if (this._hasErrors()) {
2852
+ return;
2853
+ }
2854
+
2455
2855
  const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2456
2856
  if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2457
2857
  this._applyCollapsedOption(collapsed, features);
@@ -2615,23 +3015,223 @@ class GeoJsonEditor extends HTMLElement {
2615
3015
  }
2616
3016
 
2617
3017
  // ========== Format and Update ==========
2618
-
3018
+
3019
+ /**
3020
+ * Best-effort formatting for invalid JSON
3021
+ * Splits on structural characters and indents as much as possible
3022
+ * @param content The content to format
3023
+ * @param skipLineIndex Optional line index to skip (keep as-is)
3024
+ */
3025
+ private _bestEffortFormat(content: string, skipLineIndex?: number): string[] {
3026
+ const sourceLines = content.split('\n');
3027
+
3028
+ // If we have a line to skip, handle it specially
3029
+ if (skipLineIndex !== undefined && skipLineIndex >= 0 && skipLineIndex < sourceLines.length) {
3030
+ const skippedLine = sourceLines[skipLineIndex];
3031
+
3032
+ // Format content before the skipped line
3033
+ const beforeContent = sourceLines.slice(0, skipLineIndex).join('\n');
3034
+ const beforeLines = beforeContent.trim() ? this._formatChunk(beforeContent) : [];
3035
+
3036
+ // Keep skipped line exactly as-is (don't re-indent, user is typing on it)
3037
+ const depthBefore = this._computeDepthAtEnd(beforeLines);
3038
+
3039
+ // Compute depth after the skipped line (including its brackets)
3040
+ const depthAfterSkipped = depthBefore + this._computeBracketDelta(skippedLine);
3041
+
3042
+ // Format content after the skipped line, starting at correct depth
3043
+ const afterContent = sourceLines.slice(skipLineIndex + 1).join('\n');
3044
+ const afterLines = afterContent.trim() ? this._formatChunk(afterContent, depthAfterSkipped) : [];
3045
+
3046
+ return [...beforeLines, skippedLine, ...afterLines];
3047
+ }
3048
+
3049
+ // No line to skip - format everything
3050
+ return this._formatChunk(content);
3051
+ }
3052
+
3053
+ /**
3054
+ * Compute the net bracket delta for a line (opens - closes)
3055
+ */
3056
+ private _computeBracketDelta(line: string): number {
3057
+ let delta = 0;
3058
+ let inString = false;
3059
+ let escaped = false;
3060
+ for (const char of line) {
3061
+ if (escaped) { escaped = false; continue; }
3062
+ if (char === '\\' && inString) { escaped = true; continue; }
3063
+ if (char === '"') { inString = !inString; continue; }
3064
+ if (inString) continue;
3065
+ if (char === '{' || char === '[') delta++;
3066
+ else if (char === '}' || char === ']') delta--;
3067
+ }
3068
+ return delta;
3069
+ }
3070
+
3071
+ /**
3072
+ * Compute the bracket depth at the end of formatted lines
3073
+ * Starts at 1 to account for FeatureCollection wrapper
3074
+ */
3075
+ private _computeDepthAtEnd(lines: string[]): number {
3076
+ let depth = 1; // Start at 1 for FeatureCollection wrapper
3077
+ for (const line of lines) {
3078
+ for (const char of line) {
3079
+ if (char === '{' || char === '[') depth++;
3080
+ else if (char === '}' || char === ']') depth = Math.max(0, depth - 1);
3081
+ }
3082
+ }
3083
+ return depth;
3084
+ }
3085
+
3086
+ /**
3087
+ * Format a chunk of JSON content
3088
+ * @param content The content to format
3089
+ * @param initialDepth Starting indentation depth (default 1 for FeatureCollection wrapper)
3090
+ */
3091
+ private _formatChunk(content: string, initialDepth: number = 1): string[] {
3092
+ const result: string[] = [];
3093
+ let currentLine = '';
3094
+ let depth = initialDepth;
3095
+ let inString = false;
3096
+ let escaped = false;
3097
+
3098
+ for (let i = 0; i < content.length; i++) {
3099
+ const char = content[i];
3100
+
3101
+ // Track escape sequences inside strings
3102
+ if (escaped) {
3103
+ currentLine += char;
3104
+ escaped = false;
3105
+ continue;
3106
+ }
3107
+
3108
+ if (char === '\\' && inString) {
3109
+ currentLine += char;
3110
+ escaped = true;
3111
+ continue;
3112
+ }
3113
+
3114
+ // Track if we're inside a string
3115
+ if (char === '"') {
3116
+ inString = !inString;
3117
+ currentLine += char;
3118
+ continue;
3119
+ }
3120
+
3121
+ // Inside string - just append
3122
+ if (inString) {
3123
+ currentLine += char;
3124
+ continue;
3125
+ }
3126
+
3127
+ // Outside string - handle structural characters
3128
+ if (char === '{' || char === '[') {
3129
+ currentLine += char;
3130
+ result.push(' '.repeat(depth) + currentLine.trim());
3131
+ depth++;
3132
+ currentLine = '';
3133
+ } else if (char === '}' || char === ']') {
3134
+ if (currentLine.trim()) {
3135
+ result.push(' '.repeat(depth) + currentLine.trim());
3136
+ }
3137
+ depth = Math.max(0, depth - 1);
3138
+ currentLine = char;
3139
+ } else if (char === ',') {
3140
+ currentLine += char;
3141
+ result.push(' '.repeat(depth) + currentLine.trim());
3142
+ currentLine = '';
3143
+ } else if (char === ':') {
3144
+ currentLine += ': '; // Add space after colon for readability
3145
+ i++; // Skip if next char is space
3146
+ if (content[i] === ' ') continue;
3147
+ i--; // Not a space, go back
3148
+ } else if (char === '\n' || char === '\r') {
3149
+ // Ignore existing newlines
3150
+ continue;
3151
+ } else {
3152
+ currentLine += char;
3153
+ }
3154
+ }
3155
+
3156
+ // Don't forget last line
3157
+ if (currentLine.trim()) {
3158
+ result.push(' '.repeat(depth) + currentLine.trim());
3159
+ }
3160
+
3161
+ return result;
3162
+ }
3163
+
2619
3164
  formatAndUpdate() {
3165
+ // Save cursor position
3166
+ const oldCursorLine = this.cursorLine;
3167
+ const oldCursorColumn = this.cursorColumn;
3168
+ const oldContent = this.lines.join('\n');
3169
+
2620
3170
  try {
2621
- const content = this.lines.join('\n');
2622
- const wrapped = '[' + content + ']';
3171
+ const wrapped = '[' + oldContent + ']';
2623
3172
  const parsed = JSON.parse(wrapped);
2624
-
3173
+
2625
3174
  const formatted = JSON.stringify(parsed, null, 2);
2626
3175
  const lines = formatted.split('\n');
2627
3176
  this.lines = lines.slice(1, -1); // Remove wrapper brackets
2628
- } catch (e) {
2629
- // Invalid JSON, keep as-is
3177
+ } catch {
3178
+ // Invalid JSON - apply best-effort formatting
3179
+ if (oldContent.trim()) {
3180
+ // Skip the cursor line only for small content (typing, not paste)
3181
+ // This avoids text jumping while user is typing
3182
+ // For paste/large insertions, format everything for proper structure
3183
+ const cursorLineContent = this.lines[oldCursorLine] || '';
3184
+ // If cursor line is short, likely typing. Long lines = paste
3185
+ const isSmallEdit = cursorLineContent.length < 80;
3186
+ const skipLine = isSmallEdit ? oldCursorLine : undefined;
3187
+ this.lines = this._bestEffortFormat(oldContent, skipLine);
3188
+ }
2630
3189
  }
2631
-
3190
+
3191
+ const newContent = this.lines.join('\n');
3192
+
3193
+ // If content didn't change, keep cursor exactly where it was
3194
+ if (newContent === oldContent) {
3195
+ this.cursorLine = oldCursorLine;
3196
+ this.cursorColumn = oldCursorColumn;
3197
+ } else {
3198
+ // Content changed due to reformatting
3199
+ // The cursor position (this.cursorLine, this.cursorColumn) was set by the calling
3200
+ // operation (insertText, insertNewline, etc.) BEFORE formatAndUpdate was called.
3201
+ // We need to adjust for indentation changes while keeping the logical position.
3202
+
3203
+ // If cursor is at column 0 (e.g., after newline), keep it there
3204
+ // This preserves expected behavior for newline insertion
3205
+ if (this.cursorColumn === 0) {
3206
+ // Just keep line, column 0 - indentation will be handled by auto-indent
3207
+ } else {
3208
+ // For other cases, try to maintain position relative to content (not indentation)
3209
+ const oldLines = oldContent.split('\n');
3210
+ const oldLineContent = oldLines[oldCursorLine] || '';
3211
+ const oldLeadingSpaces = oldLineContent.length - oldLineContent.trimStart().length;
3212
+ const oldColumnInContent = Math.max(0, oldCursorColumn - oldLeadingSpaces);
3213
+
3214
+ // Apply same offset to new line's indentation
3215
+ if (this.cursorLine < this.lines.length) {
3216
+ const newLineContent = this.lines[this.cursorLine];
3217
+ const newLeadingSpaces = newLineContent.length - newLineContent.trimStart().length;
3218
+ this.cursorColumn = newLeadingSpaces + oldColumnInContent;
3219
+ }
3220
+ }
3221
+ }
3222
+
3223
+ // Clamp cursor to valid range
3224
+ this.cursorLine = Math.min(this.cursorLine, Math.max(0, this.lines.length - 1));
3225
+ this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
3226
+
2632
3227
  this.updateModel();
3228
+
3229
+ // Expand any nodes that contain errors (prevents closing edited nodes with typos)
3230
+ this._expandErrorNodes();
3231
+
2633
3232
  this.scheduleRender();
2634
3233
  this.updatePlaceholderVisibility();
3234
+ this._updateErrorDisplay();
2635
3235
  this.emitChange();
2636
3236
  }
2637
3237
 
@@ -2691,6 +3291,21 @@ class GeoJsonEditor extends HTMLElement {
2691
3291
  }
2692
3292
  }
2693
3293
 
3294
+ /**
3295
+ * Update error display (counter and navigation visibility)
3296
+ */
3297
+ private _updateErrorDisplay() {
3298
+ const errorLines = this._getErrorLines();
3299
+ const count = errorLines.length;
3300
+
3301
+ if (this._errorNav) {
3302
+ this._errorNav.classList.toggle('visible', count > 0);
3303
+ }
3304
+ if (this._errorCount) {
3305
+ this._errorCount.textContent = count > 0 ? String(count) : '';
3306
+ }
3307
+ }
3308
+
2694
3309
  updatePlaceholderContent() {
2695
3310
  if (this._placeholderLayer) {
2696
3311
  this._placeholderLayer.textContent = this.placeholder;