@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.
- package/README.md +26 -0
- package/dist/geojson-editor.js +2 -2
- package/package.json +1 -1
- package/src/geojson-editor.css +46 -1
- package/src/geojson-editor.template.ts +5 -0
- package/src/geojson-editor.ts +831 -133
- package/src/internal-types.ts +2 -12
package/src/geojson-editor.ts
CHANGED
|
@@ -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
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
if (info
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
1523
|
-
|
|
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.
|
|
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
|
-
|
|
1540
|
-
|
|
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.
|
|
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
|
-
|
|
1568
|
-
|
|
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.
|
|
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
|
-
|
|
1585
|
-
|
|
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
|
-
//
|
|
1600
|
-
const
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1630
|
-
return { start: valueStart, end: valueEnd };
|
|
1631
|
-
}
|
|
1821
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1632
1822
|
}
|
|
1633
1823
|
} else {
|
|
1634
|
-
// Boolean or null
|
|
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
|
-
|
|
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
|
|
1654
|
-
const
|
|
1655
|
-
|
|
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 =
|
|
1869
|
+
while ((match = keyValueRe.exec(line)) !== null) {
|
|
1659
1870
|
const keyStart = match.index + 1;
|
|
1660
1871
|
const keyEnd = keyStart + match[1].length;
|
|
1661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1894
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1684
1895
|
}
|
|
1685
1896
|
}
|
|
1686
1897
|
}
|
|
1687
1898
|
|
|
1688
|
-
//
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
2075
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2629
|
-
// Invalid JSON
|
|
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;
|