@softwarity/geojson-editor 1.0.14 → 1.0.16

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.
@@ -1,61 +1,377 @@
1
1
  import styles from './geojson-editor.css?inline';
2
2
  import { getTemplate } from './geojson-editor.template.js';
3
+ import type { Feature, FeatureCollection } from 'geojson';
4
+
5
+ // ========== Type Definitions ==========
6
+
7
+ /** Geometry type names */
8
+ export type GeometryType = 'Point' | 'MultiPoint' | 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon';
9
+
10
+ /** Position in the editor (line and column) */
11
+ export interface CursorPosition {
12
+ line: number;
13
+ column: number;
14
+ }
15
+
16
+ /** Options for set/add/insertAt/open methods */
17
+ export interface SetOptions {
18
+ /**
19
+ * Attributes to collapse after loading.
20
+ * - string[]: List of attribute names (e.g., ['coordinates', 'geometry'])
21
+ * - function: Dynamic function (feature, index) => string[]
22
+ * - '$root': Special keyword to collapse entire features
23
+ * - Empty array: No auto-collapse
24
+ * @default ['coordinates']
25
+ */
26
+ collapsed?: string[] | ((feature: Feature, index: number) => string[]);
27
+ }
28
+
29
+ /** Theme configuration */
30
+ export interface ThemeConfig {
31
+ bgColor?: string;
32
+ textColor?: string;
33
+ caretColor?: string;
34
+ gutterBg?: string;
35
+ gutterBorder?: string;
36
+ gutterText?: string;
37
+ jsonKey?: string;
38
+ jsonString?: string;
39
+ jsonNumber?: string;
40
+ jsonBoolean?: string;
41
+ jsonNull?: string;
42
+ jsonPunct?: string;
43
+ jsonError?: string;
44
+ controlColor?: string;
45
+ controlBg?: string;
46
+ controlBorder?: string;
47
+ geojsonKey?: string;
48
+ geojsonType?: string;
49
+ geojsonTypeInvalid?: string;
50
+ jsonKeyInvalid?: string;
51
+ }
52
+
53
+ /** Theme settings for dark and light modes */
54
+ export interface ThemeSettings {
55
+ dark?: ThemeConfig;
56
+ light?: ThemeConfig;
57
+ }
58
+
59
+ /** Color metadata for a line */
60
+ interface ColorMeta {
61
+ attributeName: string;
62
+ color: string;
63
+ }
64
+
65
+ /** Boolean metadata for a line */
66
+ interface BooleanMeta {
67
+ attributeName: string;
68
+ value: boolean;
69
+ }
70
+
71
+ /** Collapse button metadata */
72
+ interface CollapseButtonMeta {
73
+ nodeKey: string;
74
+ nodeId: string;
75
+ isCollapsed: boolean;
76
+ }
77
+
78
+ /** Visibility button metadata */
79
+ interface VisibilityButtonMeta {
80
+ featureKey: string;
81
+ isHidden: boolean;
82
+ }
83
+
84
+ /** Line metadata */
85
+ interface LineMeta {
86
+ colors: ColorMeta[];
87
+ booleans: BooleanMeta[];
88
+ collapseButton: CollapseButtonMeta | null;
89
+ visibilityButton: VisibilityButtonMeta | null;
90
+ isHidden: boolean;
91
+ isCollapsed: boolean;
92
+ featureKey: string | null;
93
+ }
94
+
95
+ /** Visible line data */
96
+ interface VisibleLine {
97
+ index: number;
98
+ content: string;
99
+ meta: LineMeta | undefined;
100
+ }
101
+
102
+ /** Feature range in the editor */
103
+ interface FeatureRange {
104
+ startLine: number;
105
+ endLine: number;
106
+ featureIndex: number;
107
+ }
108
+
109
+ /** Node range info */
110
+ interface NodeRangeInfo {
111
+ startLine: number;
112
+ endLine: number;
113
+ nodeKey?: string;
114
+ isRootFeature?: boolean;
115
+ }
116
+
117
+ /** Collapsible range info */
118
+ interface CollapsibleRange extends NodeRangeInfo {
119
+ nodeId: string;
120
+ openBracket: string;
121
+ }
122
+
123
+ /** Editor state snapshot for undo/redo */
124
+ interface EditorSnapshot {
125
+ lines: string[];
126
+ cursorLine: number;
127
+ cursorColumn: number;
128
+ timestamp: number;
129
+ }
130
+
131
+ /** Bracket count result */
132
+ interface BracketCount {
133
+ open: number;
134
+ close: number;
135
+ }
136
+
137
+ /** Context stack item */
138
+ interface ContextStackItem {
139
+ context: string;
140
+ isArray: boolean;
141
+ }
142
+
143
+ /** Input types accepted by API methods */
144
+ export type FeatureInput = Feature | Feature[] | FeatureCollection;
3
145
 
4
146
  // Version injected by Vite build from package.json
5
147
  const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'dev';
6
148
 
7
149
  // GeoJSON constants
8
- const GEOJSON_KEYS = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
9
- const GEOMETRY_TYPES = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
150
+ const GEOJSON_KEYS: string[] = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
151
+ const GEOMETRY_TYPES: GeometryType[] = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
152
+
153
+ // Pre-compiled regex patterns for performance (avoid re-creation on each call)
154
+ const RE_CONTEXT_GEOMETRY = /"geometry"\s*:/;
155
+ const RE_CONTEXT_PROPERTIES = /"properties"\s*:/;
156
+ const RE_CONTEXT_FEATURES = /"features"\s*:/;
157
+ const RE_COLLAPSED_BRACKET = /^(\s*"[^"]+"\s*:\s*)([{\[])/;
158
+ const RE_COLLAPSED_ROOT = /^(\s*)([{\[]),?\s*$/;
159
+ const RE_ESCAPE_AMP = /&/g;
160
+ const RE_ESCAPE_LT = /</g;
161
+ const RE_ESCAPE_GT = />/g;
162
+ const RE_PUNCTUATION = /([{}[\],:])/g;
163
+ const RE_JSON_KEYS = /"([^"]+)"(<span class="json-punctuation">:<\/span>)/g;
164
+ const RE_TYPE_VALUES = /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>(\s*)"([^"]*)"/g;
165
+ const RE_STRING_VALUES = /(<span class="json-punctuation">:<\/span>)(\s*)"([^"]*)"/g;
166
+ const RE_COLOR_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
167
+ const RE_NUMBERS_COLON = /(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
168
+ const RE_NUMBERS_ARRAY = /(<span class="json-punctuation">[\[,]<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
169
+ const RE_NUMBERS_START = /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim;
170
+ const RE_BOOLEANS = /(<span class="json-punctuation">:<\/span>)(\s*)(true|false)/g;
171
+ const RE_NULL = /(<span class="json-punctuation">:<\/span>)(\s*)(null)/g;
172
+ const RE_UNRECOGNIZED = /(<\/span>|^)([^<]+)(<span|$)/g;
173
+ const RE_WHITESPACE_ONLY = /^\s*$/;
174
+ const RE_WHITESPACE_SPLIT = /(\s+)/;
10
175
 
11
176
  /**
12
177
  * GeoJSON Editor Web Component
13
178
  * Monaco-like architecture with virtualized line rendering
14
179
  */
15
180
  class GeoJsonEditor extends HTMLElement {
181
+ // ========== Model (Source of Truth) ==========
182
+ lines: string[] = [];
183
+ collapsedNodes: Set<string> = new Set();
184
+ hiddenFeatures: Set<string> = new Set();
185
+
186
+ // ========== Node ID Management ==========
187
+ private _nodeIdCounter: number = 0;
188
+ private _lineToNodeId: Map<number, string> = new Map();
189
+ private _nodeIdToLines: Map<string, NodeRangeInfo> = new Map();
190
+
191
+ // ========== Derived State (computed from model) ==========
192
+ visibleLines: VisibleLine[] = [];
193
+ lineMetadata: Map<number, LineMeta> = new Map();
194
+ featureRanges: Map<string, FeatureRange> = new Map();
195
+
196
+ // ========== View State ==========
197
+ viewportHeight: number = 0;
198
+ lineHeight: number = 19.5;
199
+ bufferLines: number = 5;
200
+
201
+ // ========== Render Cache ==========
202
+ private _lastStartIndex: number = -1;
203
+ private _lastEndIndex: number = -1;
204
+ private _lastTotalLines: number = -1;
205
+ private _scrollRaf: number | null = null;
206
+
207
+ // ========== Cursor/Selection ==========
208
+ cursorLine: number = 0;
209
+ cursorColumn: number = 0;
210
+ selectionStart: CursorPosition | null = null;
211
+ selectionEnd: CursorPosition | null = null;
212
+
213
+ // ========== Debounce ==========
214
+ private renderTimer: number | null = null;
215
+ private inputTimer: number | null = null;
216
+
217
+ // ========== Theme ==========
218
+ themes: ThemeSettings = { dark: {}, light: {} };
219
+
220
+ // ========== Undo/Redo History ==========
221
+ private _undoStack: EditorSnapshot[] = [];
222
+ private _redoStack: EditorSnapshot[] = [];
223
+ private _maxHistorySize: number = 100;
224
+ private _lastActionTime: number = 0;
225
+ private _lastActionType: string | null = null;
226
+ private _groupingDelay: number = 500;
227
+
228
+ // ========== Internal State ==========
229
+ private _isSelecting: boolean = false;
230
+ private _isComposing: boolean = false;
231
+ private _blockRender: boolean = false;
232
+ private _charWidth: number | null = null;
233
+ private _contextMapCache: Map<number, string> | null = null;
234
+ private _contextMapLinesLength: number = 0;
235
+ private _contextMapFirstLine: string | undefined = undefined;
236
+ private _contextMapLastLine: string | undefined = undefined;
237
+
16
238
  constructor() {
17
239
  super();
18
240
  this.attachShadow({ mode: 'open' });
241
+ }
19
242
 
20
- // ========== Model (Source of Truth) ==========
21
- this.lines = []; // Array of line strings
22
- this.collapsedNodes = new Set(); // Set of unique node IDs that are collapsed
23
- this.hiddenFeatures = new Set(); // Set of feature keys hidden from events
24
-
25
- // ========== Node ID Management ==========
26
- this._nodeIdCounter = 0; // Counter for generating unique node IDs
27
- this._lineToNodeId = new Map(); // lineIndex -> nodeId (for collapsible lines)
28
- this._nodeIdToLines = new Map(); // nodeId -> {startLine, endLine} (range of collapsed content)
29
-
30
- // ========== Derived State (computed from model) ==========
31
- this.visibleLines = []; // Lines to render (after collapse filter)
32
- this.lineMetadata = new Map(); // lineIndex -> {colors, booleans, collapse, visibility, hidden, featureKey}
33
- this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
34
-
35
- // ========== View State ==========
36
- this.scrollTop = 0;
37
- this.viewportHeight = 0;
38
- this.lineHeight = 19.5; // CSS: line-height * font-size = 1.5 * 13px
39
- this.bufferLines = 5; // Extra lines to render above/below viewport
40
-
41
- // ========== Render Cache ==========
243
+ // ========== Render Cache ==========
244
+ _invalidateRenderCache() {
42
245
  this._lastStartIndex = -1;
43
246
  this._lastEndIndex = -1;
44
247
  this._lastTotalLines = -1;
45
- this._scrollRaf = null;
46
-
47
- // ========== Cursor/Selection ==========
48
- this.cursorLine = 0;
49
- this.cursorColumn = 0;
50
- this.selectionStart = null; // {line, column}
51
- this.selectionEnd = null; // {line, column}
52
-
53
- // ========== Debounce ==========
54
- this.renderTimer = null;
55
- this.inputTimer = null;
56
-
57
- // ========== Theme ==========
58
- this.themes = { dark: {}, light: {} };
248
+ }
249
+
250
+ // ========== Undo/Redo System ==========
251
+
252
+ /**
253
+ * Create a snapshot of current editor state
254
+ * @returns {Object} State snapshot
255
+ */
256
+ _createSnapshot() {
257
+ return {
258
+ lines: [...this.lines],
259
+ cursorLine: this.cursorLine,
260
+ cursorColumn: this.cursorColumn,
261
+ timestamp: Date.now()
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Restore editor state from snapshot
267
+ * @param {Object} snapshot - State to restore
268
+ */
269
+ _restoreSnapshot(snapshot) {
270
+ this.lines = [...snapshot.lines];
271
+ this.cursorLine = snapshot.cursorLine;
272
+ this.cursorColumn = snapshot.cursorColumn;
273
+ this.updateModel();
274
+ this._invalidateRenderCache();
275
+ this.scheduleRender();
276
+ this.updatePlaceholderVisibility();
277
+ this.emitChange();
278
+ }
279
+
280
+ /**
281
+ * Save current state to undo stack before making changes
282
+ * @param {string} actionType - Type of action (insert, delete, paste, etc.)
283
+ */
284
+ _saveToHistory(actionType = 'edit') {
285
+ const now = Date.now();
286
+ const shouldGroup = (
287
+ actionType === this._lastActionType &&
288
+ (now - this._lastActionTime) < this._groupingDelay
289
+ );
290
+
291
+ // If same action type within grouping delay, don't create new entry
292
+ if (!shouldGroup) {
293
+ const snapshot = this._createSnapshot();
294
+ this._undoStack.push(snapshot);
295
+
296
+ // Limit stack size
297
+ if (this._undoStack.length > this._maxHistorySize) {
298
+ this._undoStack.shift();
299
+ }
300
+
301
+ // Clear redo stack on new action
302
+ this._redoStack = [];
303
+ }
304
+
305
+ this._lastActionTime = now;
306
+ this._lastActionType = actionType;
307
+ }
308
+
309
+ /**
310
+ * Undo last action
311
+ * @returns {boolean} True if undo was performed
312
+ */
313
+ undo() {
314
+ if (this._undoStack.length === 0) return false;
315
+
316
+ // Save current state to redo stack
317
+ this._redoStack.push(this._createSnapshot());
318
+
319
+ // Restore previous state
320
+ const previousState = this._undoStack.pop();
321
+ this._restoreSnapshot(previousState);
322
+
323
+ // Reset action tracking
324
+ this._lastActionType = null;
325
+ this._lastActionTime = 0;
326
+
327
+ return true;
328
+ }
329
+
330
+ /**
331
+ * Redo previously undone action
332
+ * @returns {boolean} True if redo was performed
333
+ */
334
+ redo() {
335
+ if (this._redoStack.length === 0) return false;
336
+
337
+ // Save current state to undo stack
338
+ this._undoStack.push(this._createSnapshot());
339
+
340
+ // Restore next state
341
+ const nextState = this._redoStack.pop();
342
+ this._restoreSnapshot(nextState);
343
+
344
+ // Reset action tracking
345
+ this._lastActionType = null;
346
+ this._lastActionTime = 0;
347
+
348
+ return true;
349
+ }
350
+
351
+ /**
352
+ * Clear undo/redo history
353
+ */
354
+ clearHistory() {
355
+ this._undoStack = [];
356
+ this._redoStack = [];
357
+ this._lastActionType = null;
358
+ this._lastActionTime = 0;
359
+ }
360
+
361
+ /**
362
+ * Check if undo is available
363
+ * @returns {boolean}
364
+ */
365
+ canUndo() {
366
+ return this._undoStack.length > 0;
367
+ }
368
+
369
+ /**
370
+ * Check if redo is available
371
+ * @returns {boolean}
372
+ */
373
+ canRedo() {
374
+ return this._redoStack.length > 0;
59
375
  }
60
376
 
61
377
  // ========== Unique ID Generation ==========
@@ -162,14 +478,16 @@ class GeoJsonEditor extends HTMLElement {
162
478
  * @param {Object} range - The range info {startLine, endLine}
163
479
  */
164
480
  _deleteCollapsedNode(range) {
481
+ this._saveToHistory('delete');
482
+
165
483
  // Remove all lines from startLine to endLine
166
484
  const count = range.endLine - range.startLine + 1;
167
485
  this.lines.splice(range.startLine, count);
168
-
486
+
169
487
  // Position cursor at the line where the node was
170
488
  this.cursorLine = Math.min(range.startLine, this.lines.length - 1);
171
489
  this.cursorColumn = 0;
172
-
490
+
173
491
  this.formatAndUpdate();
174
492
  }
175
493
 
@@ -269,12 +587,12 @@ class GeoJsonEditor extends HTMLElement {
269
587
  this.updatePlaceholderVisibility();
270
588
  }
271
589
 
272
- disconnectedCallback() {
590
+ disconnectedCallback(): void {
273
591
  if (this.renderTimer) clearTimeout(this.renderTimer);
274
592
  if (this.inputTimer) clearTimeout(this.inputTimer);
275
-
593
+
276
594
  // Cleanup color picker
277
- const colorPicker = document.querySelector('.geojson-color-picker-input');
595
+ const colorPicker = document.querySelector('.geojson-color-picker-input') as HTMLInputElement & { _closeListener?: EventListener };
278
596
  if (colorPicker) {
279
597
  if (colorPicker._closeListener) {
280
598
  document.removeEventListener('click', colorPicker._closeListener, true);
@@ -343,9 +661,10 @@ class GeoJsonEditor extends HTMLElement {
343
661
  this.handleEditorClick(e);
344
662
  }, true);
345
663
 
346
- viewport.addEventListener('mousedown', (e) => {
664
+ viewport.addEventListener('mousedown', (e: MouseEvent) => {
665
+ const target = e.target as HTMLElement;
347
666
  // Skip if clicking on visibility pseudo-element (line-level)
348
- const lineEl = e.target.closest('.line.has-visibility');
667
+ const lineEl = target.closest('.line.has-visibility');
349
668
  if (lineEl) {
350
669
  const rect = lineEl.getBoundingClientRect();
351
670
  const clickX = e.clientX - rect.left;
@@ -357,9 +676,9 @@ class GeoJsonEditor extends HTMLElement {
357
676
  }
358
677
 
359
678
  // Skip if clicking on an inline control pseudo-element (positioned with negative left)
360
- if (e.target.classList.contains('json-color') ||
361
- e.target.classList.contains('json-boolean')) {
362
- const rect = e.target.getBoundingClientRect();
679
+ if (target.classList.contains('json-color') ||
680
+ target.classList.contains('json-boolean')) {
681
+ const rect = target.getBoundingClientRect();
363
682
  const clickX = e.clientX - rect.left;
364
683
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
365
684
  if (clickX < 0 && clickX >= -8) {
@@ -391,7 +710,7 @@ class GeoJsonEditor extends HTMLElement {
391
710
 
392
711
  // Focus textarea
393
712
  hiddenTextarea.focus();
394
- this._lastStartIndex = -1;
713
+ this._invalidateRenderCache();
395
714
  this.scheduleRender();
396
715
  });
397
716
 
@@ -417,7 +736,7 @@ class GeoJsonEditor extends HTMLElement {
417
736
  viewport.scrollTop += scrollSpeed;
418
737
  }
419
738
 
420
- this._lastStartIndex = -1;
739
+ this._invalidateRenderCache();
421
740
  this.scheduleRender();
422
741
  });
423
742
 
@@ -429,13 +748,13 @@ class GeoJsonEditor extends HTMLElement {
429
748
  // Focus/blur handling to show/hide cursor
430
749
  hiddenTextarea.addEventListener('focus', () => {
431
750
  editorWrapper.classList.add('focused');
432
- this._lastStartIndex = -1; // Force re-render to show cursor
751
+ this._invalidateRenderCache(); // Force re-render to show cursor
433
752
  this.scheduleRender();
434
753
  });
435
754
 
436
755
  hiddenTextarea.addEventListener('blur', () => {
437
756
  editorWrapper.classList.remove('focused');
438
- this._lastStartIndex = -1; // Force re-render to hide cursor
757
+ this._invalidateRenderCache(); // Force re-render to hide cursor
439
758
  this.scheduleRender();
440
759
  });
441
760
 
@@ -443,7 +762,6 @@ class GeoJsonEditor extends HTMLElement {
443
762
  let isRendering = false;
444
763
  viewport.addEventListener('scroll', () => {
445
764
  if (isRendering) return;
446
- this.scrollTop = viewport.scrollTop;
447
765
  this.syncGutterScroll();
448
766
 
449
767
  // Use requestAnimationFrame to batch scroll updates
@@ -505,7 +823,7 @@ class GeoJsonEditor extends HTMLElement {
505
823
  });
506
824
 
507
825
  // Wheel on gutter -> scroll viewport
508
- gutter.addEventListener('wheel', (e) => {
826
+ gutter.addEventListener('wheel', (e: WheelEvent) => {
509
827
  e.preventDefault();
510
828
  viewport.scrollTop += e.deltaY;
511
829
  });
@@ -524,7 +842,12 @@ class GeoJsonEditor extends HTMLElement {
524
842
  /**
525
843
  * Set the editor content from a string value
526
844
  */
527
- setValue(value) {
845
+ setValue(value, autoCollapse = true) {
846
+ // Save to history only if there's existing content
847
+ if (this.lines.length > 0) {
848
+ this._saveToHistory('setValue');
849
+ }
850
+
528
851
  if (!value || !value.trim()) {
529
852
  this.lines = [];
530
853
  } else {
@@ -541,7 +864,7 @@ class GeoJsonEditor extends HTMLElement {
541
864
  this.lines = value.split('\n');
542
865
  }
543
866
  }
544
-
867
+
545
868
  // Clear state for new content
546
869
  this.collapsedNodes.clear();
547
870
  this.hiddenFeatures.clear();
@@ -549,18 +872,18 @@ class GeoJsonEditor extends HTMLElement {
549
872
  this._nodeIdToLines.clear();
550
873
  this.cursorLine = 0;
551
874
  this.cursorColumn = 0;
552
-
875
+
553
876
  this.updateModel();
554
877
  this.scheduleRender();
555
878
  this.updatePlaceholderVisibility();
556
-
557
- // Auto-collapse coordinates
558
- if (this.lines.length > 0) {
879
+
880
+ // Auto-collapse coordinates (unless disabled)
881
+ if (autoCollapse && this.lines.length > 0) {
559
882
  requestAnimationFrame(() => {
560
883
  this.autoCollapseCoordinates();
561
884
  });
562
885
  }
563
-
886
+
564
887
  this.emitChange();
565
888
  }
566
889
 
@@ -576,9 +899,12 @@ class GeoJsonEditor extends HTMLElement {
576
899
  * Rebuilds line-to-nodeId mapping while preserving collapsed state
577
900
  */
578
901
  updateModel() {
902
+ // Invalidate context map cache since content changed
903
+ this._contextMapCache = null;
904
+
579
905
  // Rebuild lineToNodeId mapping (may shift due to edits)
580
906
  this._rebuildNodeIdMappings();
581
-
907
+
582
908
  this.computeFeatureRanges();
583
909
  this.computeLineMetadata();
584
910
  this.computeVisibleLines();
@@ -757,7 +1083,7 @@ class GeoJsonEditor extends HTMLElement {
757
1083
  }
758
1084
 
759
1085
  // Reset render cache to force re-render
760
- this._lastStartIndex = -1;
1086
+ this._invalidateRenderCache();
761
1087
  this._lastEndIndex = -1;
762
1088
  this._lastTotalLines = -1;
763
1089
  }
@@ -845,7 +1171,7 @@ class GeoJsonEditor extends HTMLElement {
845
1171
 
846
1172
  const lineEl = document.createElement('div');
847
1173
  lineEl.className = 'line';
848
- lineEl.dataset.lineIndex = lineData.index;
1174
+ lineEl.dataset.lineIndex = String(lineData.index);
849
1175
 
850
1176
  // Add visibility button on line (uses ::before pseudo-element)
851
1177
  if (lineData.meta?.visibilityButton) {
@@ -980,7 +1306,7 @@ class GeoJsonEditor extends HTMLElement {
980
1306
  // Line number first
981
1307
  const lineNum = document.createElement('span');
982
1308
  lineNum.className = 'line-number';
983
- lineNum.textContent = lineData.index + 1;
1309
+ lineNum.textContent = String(lineData.index + 1);
984
1310
  gutterLine.appendChild(lineNum);
985
1311
 
986
1312
  // Collapse column (always present for alignment)
@@ -990,7 +1316,7 @@ class GeoJsonEditor extends HTMLElement {
990
1316
  const btn = document.createElement('div');
991
1317
  btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
992
1318
  btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
993
- btn.dataset.line = lineData.index;
1319
+ btn.dataset.line = String(lineData.index);
994
1320
  btn.dataset.nodeId = meta.collapseButton.nodeId;
995
1321
  btn.title = meta.collapseButton.isCollapsed ? 'Expand' : 'Collapse';
996
1322
  collapseCol.appendChild(btn);
@@ -1014,9 +1340,9 @@ class GeoJsonEditor extends HTMLElement {
1014
1340
  }
1015
1341
 
1016
1342
  // ========== Input Handling ==========
1017
-
1018
- handleInput() {
1019
- const textarea = this.shadowRoot.getElementById('hiddenTextarea');
1343
+
1344
+ handleInput(): void {
1345
+ const textarea = this.shadowRoot!.getElementById('hiddenTextarea') as HTMLTextAreaElement;
1020
1346
  const inputValue = textarea.value;
1021
1347
 
1022
1348
  if (!inputValue) return;
@@ -1090,183 +1416,166 @@ class GeoJsonEditor extends HTMLElement {
1090
1416
  }
1091
1417
 
1092
1418
  handleKeydown(e) {
1093
- // Check if cursor is in a collapsed zone
1094
- const inCollapsedZone = this._getCollapsedRangeForLine(this.cursorLine);
1095
- const onCollapsedNode = this._getCollapsedNodeAtLine(this.cursorLine);
1096
- const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1097
-
1098
- switch (e.key) {
1099
- case 'Enter':
1100
- e.preventDefault();
1101
- // Block in collapsed zones
1102
- if (onCollapsedNode || inCollapsedZone) return;
1103
- // On closing line, before bracket -> block
1104
- if (onClosingLine) {
1105
- const line = this.lines[this.cursorLine];
1106
- const bracketPos = this._getClosingBracketPos(line);
1107
- if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
1108
- return;
1109
- }
1110
- // After bracket, allow normal enter (add new line)
1111
- }
1112
- this.insertNewline();
1113
- break;
1114
- case 'Backspace':
1115
- e.preventDefault();
1116
- // Delete selection if any
1117
- if (this._hasSelection()) {
1118
- this._deleteSelection();
1119
- this.formatAndUpdate();
1120
- return;
1121
- }
1122
- // On closing line
1123
- if (onClosingLine) {
1124
- const line = this.lines[this.cursorLine];
1125
- const bracketPos = this._getClosingBracketPos(line);
1126
- if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
1127
- // After bracket, allow delete
1128
- this.deleteBackward();
1129
- return;
1130
- } else if (this.cursorColumn === bracketPos + 1) {
1131
- // Just after bracket, delete whole node
1132
- this._deleteCollapsedNode(onClosingLine);
1133
- return;
1134
- }
1135
- // On or before bracket, delete whole node
1136
- this._deleteCollapsedNode(onClosingLine);
1137
- return;
1138
- }
1139
- // If on collapsed node opening line at position 0, delete whole node
1140
- if (onCollapsedNode && this.cursorColumn === 0) {
1141
- this._deleteCollapsedNode(onCollapsedNode);
1142
- return;
1143
- }
1144
- // Block inside collapsed zones
1145
- if (inCollapsedZone) return;
1146
- // On opening line, allow editing before and at bracket
1147
- if (onCollapsedNode) {
1148
- const line = this.lines[this.cursorLine];
1149
- const bracketPos = line.search(/[{\[]/);
1150
- if (this.cursorColumn > bracketPos + 1) {
1151
- // After bracket, delete whole node
1152
- this._deleteCollapsedNode(onCollapsedNode);
1153
- return;
1154
- }
1155
- }
1419
+ // Build context for collapsed zone detection
1420
+ const ctx = {
1421
+ inCollapsedZone: this._getCollapsedRangeForLine(this.cursorLine),
1422
+ onCollapsedNode: this._getCollapsedNodeAtLine(this.cursorLine),
1423
+ onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
1424
+ };
1425
+
1426
+ // Lookup table for key handlers
1427
+ const keyHandlers = {
1428
+ 'Enter': () => this._handleEnter(ctx),
1429
+ 'Backspace': () => this._handleBackspace(ctx),
1430
+ 'Delete': () => this._handleDelete(ctx),
1431
+ 'ArrowUp': () => this._handleArrowKey(-1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
1432
+ 'ArrowDown': () => this._handleArrowKey(1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
1433
+ 'ArrowLeft': () => this._handleArrowKey(0, -1, e.shiftKey, e.ctrlKey || e.metaKey),
1434
+ 'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1435
+ 'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1436
+ 'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1437
+ 'Tab': () => this._handleTab(e.shiftKey, ctx)
1438
+ };
1439
+
1440
+ // Modifier key handlers (Ctrl/Cmd)
1441
+ const modifierHandlers = {
1442
+ 'a': () => this._selectAll(),
1443
+ 'z': () => e.shiftKey ? this.redo() : this.undo(),
1444
+ 'y': () => this.redo(),
1445
+ 's': () => this.save(),
1446
+ 'o': () => !this.hasAttribute('readonly') && this.open()
1447
+ };
1448
+
1449
+ // Check for direct key match
1450
+ if (keyHandlers[e.key]) {
1451
+ e.preventDefault();
1452
+ keyHandlers[e.key]();
1453
+ return;
1454
+ }
1455
+
1456
+ // Check for modifier key combinations
1457
+ if ((e.ctrlKey || e.metaKey) && modifierHandlers[e.key]) {
1458
+ e.preventDefault();
1459
+ modifierHandlers[e.key]();
1460
+ }
1461
+ }
1462
+
1463
+ _handleEnter(ctx) {
1464
+ // Block in collapsed zones
1465
+ if (ctx.onCollapsedNode || ctx.inCollapsedZone) return;
1466
+ // On closing line, before bracket -> block
1467
+ if (ctx.onClosingLine) {
1468
+ const line = this.lines[this.cursorLine];
1469
+ const bracketPos = this._getClosingBracketPos(line);
1470
+ if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
1471
+ return;
1472
+ }
1473
+ }
1474
+ this.insertNewline();
1475
+ }
1476
+
1477
+ _handleBackspace(ctx) {
1478
+ // Delete selection if any
1479
+ if (this._hasSelection()) {
1480
+ this._deleteSelection();
1481
+ this.formatAndUpdate();
1482
+ return;
1483
+ }
1484
+ // On closing line
1485
+ if (ctx.onClosingLine) {
1486
+ const line = this.lines[this.cursorLine];
1487
+ const bracketPos = this._getClosingBracketPos(line);
1488
+ if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
1156
1489
  this.deleteBackward();
1157
- break;
1158
- case 'Delete':
1159
- e.preventDefault();
1160
- // Delete selection if any
1161
- if (this._hasSelection()) {
1162
- this._deleteSelection();
1163
- this.formatAndUpdate();
1164
- return;
1165
- }
1166
- // On closing line
1167
- if (onClosingLine) {
1168
- const line = this.lines[this.cursorLine];
1169
- const bracketPos = this._getClosingBracketPos(line);
1170
- if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
1171
- // After bracket, allow delete
1172
- this.deleteForward();
1173
- return;
1174
- }
1175
- // On or before bracket, delete whole node
1176
- this._deleteCollapsedNode(onClosingLine);
1177
- return;
1178
- }
1179
- // If on collapsed node opening line
1180
- if (onCollapsedNode) {
1181
- const line = this.lines[this.cursorLine];
1182
- const bracketPos = line.search(/[{\[]/);
1183
- if (this.cursorColumn > bracketPos) {
1184
- // After bracket, delete whole node
1185
- this._deleteCollapsedNode(onCollapsedNode);
1186
- return;
1187
- }
1188
- // Before bracket, allow editing key name
1189
- }
1190
- // Block inside collapsed zones
1191
- if (inCollapsedZone) return;
1490
+ return;
1491
+ }
1492
+ this._deleteCollapsedNode(ctx.onClosingLine);
1493
+ return;
1494
+ }
1495
+ // If on collapsed node opening line at position 0, delete whole node
1496
+ if (ctx.onCollapsedNode && this.cursorColumn === 0) {
1497
+ this._deleteCollapsedNode(ctx.onCollapsedNode);
1498
+ return;
1499
+ }
1500
+ // Block inside collapsed zones
1501
+ if (ctx.inCollapsedZone) return;
1502
+ // On opening line, allow editing before bracket
1503
+ if (ctx.onCollapsedNode) {
1504
+ const line = this.lines[this.cursorLine];
1505
+ const bracketPos = line.search(/[{\[]/);
1506
+ if (this.cursorColumn > bracketPos + 1) {
1507
+ this._deleteCollapsedNode(ctx.onCollapsedNode);
1508
+ return;
1509
+ }
1510
+ }
1511
+ this.deleteBackward();
1512
+ }
1513
+
1514
+ _handleDelete(ctx) {
1515
+ // Delete selection if any
1516
+ if (this._hasSelection()) {
1517
+ this._deleteSelection();
1518
+ this.formatAndUpdate();
1519
+ return;
1520
+ }
1521
+ // On closing line
1522
+ if (ctx.onClosingLine) {
1523
+ const line = this.lines[this.cursorLine];
1524
+ const bracketPos = this._getClosingBracketPos(line);
1525
+ if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
1192
1526
  this.deleteForward();
1193
- break;
1194
- case 'ArrowUp':
1195
- e.preventDefault();
1196
- this._handleArrowKey(-1, 0, e.shiftKey);
1197
- break;
1198
- case 'ArrowDown':
1199
- e.preventDefault();
1200
- this._handleArrowKey(1, 0, e.shiftKey);
1201
- break;
1202
- case 'ArrowLeft':
1203
- e.preventDefault();
1204
- this._handleArrowKey(0, -1, e.shiftKey);
1205
- break;
1206
- case 'ArrowRight':
1207
- e.preventDefault();
1208
- this._handleArrowKey(0, 1, e.shiftKey);
1209
- break;
1210
- case 'Home':
1211
- e.preventDefault();
1212
- this._handleHomeEnd('home', e.shiftKey, onClosingLine);
1213
- break;
1214
- case 'End':
1215
- e.preventDefault();
1216
- this._handleHomeEnd('end', e.shiftKey, onClosingLine);
1217
- break;
1218
- case 'a':
1219
- // Ctrl+A or Cmd+A: select all
1220
- if (e.ctrlKey || e.metaKey) {
1221
- e.preventDefault();
1222
- this._selectAll();
1223
- return;
1224
- }
1225
- break;
1226
- case 'Tab':
1227
- e.preventDefault();
1228
-
1229
- // Shift+Tab: collapse the containing expanded node
1230
- if (e.shiftKey) {
1231
- const containingNode = this._getContainingExpandedNode(this.cursorLine);
1232
- if (containingNode) {
1233
- // Find the position just after the opening bracket
1234
- const startLine = this.lines[containingNode.startLine];
1235
- const bracketPos = startLine.search(/[{\[]/);
1236
-
1237
- this.toggleCollapse(containingNode.nodeId);
1238
-
1239
- // Move cursor to just after the opening bracket
1240
- this.cursorLine = containingNode.startLine;
1241
- this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
1242
- this._clearSelection();
1243
- this._scrollToCursor();
1244
- }
1245
- return;
1246
- }
1247
-
1248
- // Tab: expand collapsed node if on one
1249
- if (onCollapsedNode) {
1250
- this.toggleCollapse(onCollapsedNode.nodeId);
1251
- return;
1252
- }
1253
- if (onClosingLine) {
1254
- this.toggleCollapse(onClosingLine.nodeId);
1255
- return;
1256
- }
1257
-
1258
- // Block in hidden collapsed zones
1259
- if (inCollapsedZone) return;
1260
- break;
1527
+ return;
1528
+ }
1529
+ this._deleteCollapsedNode(ctx.onClosingLine);
1530
+ return;
1531
+ }
1532
+ // If on collapsed node opening line
1533
+ if (ctx.onCollapsedNode) {
1534
+ const line = this.lines[this.cursorLine];
1535
+ const bracketPos = line.search(/[{\[]/);
1536
+ if (this.cursorColumn > bracketPos) {
1537
+ this._deleteCollapsedNode(ctx.onCollapsedNode);
1538
+ return;
1539
+ }
1540
+ }
1541
+ // Block inside collapsed zones
1542
+ if (ctx.inCollapsedZone) return;
1543
+ this.deleteForward();
1544
+ }
1545
+
1546
+ _handleTab(isShiftKey, ctx) {
1547
+ // Shift+Tab: collapse the containing expanded node
1548
+ if (isShiftKey) {
1549
+ const containingNode = this._getContainingExpandedNode(this.cursorLine);
1550
+ if (containingNode) {
1551
+ const startLine = this.lines[containingNode.startLine];
1552
+ const bracketPos = startLine.search(/[{\[]/);
1553
+ this.toggleCollapse(containingNode.nodeId);
1554
+ this.cursorLine = containingNode.startLine;
1555
+ this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
1556
+ this._clearSelection();
1557
+ this._scrollToCursor();
1558
+ }
1559
+ return;
1560
+ }
1561
+ // Tab: expand collapsed node if on one
1562
+ if (ctx.onCollapsedNode) {
1563
+ this.toggleCollapse(ctx.onCollapsedNode.nodeId);
1564
+ return;
1565
+ }
1566
+ if (ctx.onClosingLine) {
1567
+ this.toggleCollapse(ctx.onClosingLine.nodeId);
1261
1568
  }
1262
1569
  }
1263
1570
 
1264
1571
  insertNewline() {
1572
+ this._saveToHistory('newline');
1573
+
1265
1574
  if (this.cursorLine < this.lines.length) {
1266
1575
  const line = this.lines[this.cursorLine];
1267
1576
  const before = line.substring(0, this.cursorColumn);
1268
1577
  const after = line.substring(this.cursorColumn);
1269
-
1578
+
1270
1579
  this.lines[this.cursorLine] = before;
1271
1580
  this.lines.splice(this.cursorLine + 1, 0, after);
1272
1581
  this.cursorLine++;
@@ -1276,11 +1585,13 @@ class GeoJsonEditor extends HTMLElement {
1276
1585
  this.cursorLine = this.lines.length - 1;
1277
1586
  this.cursorColumn = 0;
1278
1587
  }
1279
-
1588
+
1280
1589
  this.formatAndUpdate();
1281
1590
  }
1282
1591
 
1283
1592
  deleteBackward() {
1593
+ this._saveToHistory('delete');
1594
+
1284
1595
  if (this.cursorColumn > 0) {
1285
1596
  const line = this.lines[this.cursorLine];
1286
1597
  this.lines[this.cursorLine] = line.substring(0, this.cursorColumn - 1) + line.substring(this.cursorColumn);
@@ -1294,11 +1605,13 @@ class GeoJsonEditor extends HTMLElement {
1294
1605
  this.lines.splice(this.cursorLine, 1);
1295
1606
  this.cursorLine--;
1296
1607
  }
1297
-
1608
+
1298
1609
  this.formatAndUpdate();
1299
1610
  }
1300
1611
 
1301
1612
  deleteForward() {
1613
+ this._saveToHistory('delete');
1614
+
1302
1615
  if (this.cursorLine < this.lines.length) {
1303
1616
  const line = this.lines[this.cursorLine];
1304
1617
  if (this.cursorColumn < line.length) {
@@ -1309,7 +1622,7 @@ class GeoJsonEditor extends HTMLElement {
1309
1622
  this.lines.splice(this.cursorLine + 1, 1);
1310
1623
  }
1311
1624
  }
1312
-
1625
+
1313
1626
  this.formatAndUpdate();
1314
1627
  }
1315
1628
 
@@ -1339,7 +1652,7 @@ class GeoJsonEditor extends HTMLElement {
1339
1652
  const maxCol = this.lines[this.cursorLine]?.length || 0;
1340
1653
  this.cursorColumn = Math.min(this.cursorColumn, maxCol);
1341
1654
 
1342
- this._lastStartIndex = -1;
1655
+ this._invalidateRenderCache();
1343
1656
  this._scrollToCursor();
1344
1657
  this.scheduleRender();
1345
1658
  }
@@ -1348,124 +1661,103 @@ class GeoJsonEditor extends HTMLElement {
1348
1661
  * Move cursor horizontally with smart navigation around collapsed nodes
1349
1662
  */
1350
1663
  moveCursorHorizontal(delta) {
1664
+ if (delta > 0) {
1665
+ this._moveCursorRight();
1666
+ } else {
1667
+ this._moveCursorLeft();
1668
+ }
1669
+ this._invalidateRenderCache();
1670
+ this._scrollToCursor();
1671
+ this.scheduleRender();
1672
+ }
1673
+
1674
+ _moveCursorRight() {
1351
1675
  const line = this.lines[this.cursorLine];
1352
1676
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1353
1677
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1354
-
1355
- if (delta > 0) {
1356
- // Moving right
1357
- if (onClosingLine) {
1358
- const bracketPos = this._getClosingBracketPos(line);
1359
- if (this.cursorColumn < bracketPos) {
1360
- // Before bracket, jump to bracket
1361
- this.cursorColumn = bracketPos;
1362
- } else if (this.cursorColumn >= line.length) {
1363
- // At end, go to next line
1364
- if (this.cursorLine < this.lines.length - 1) {
1365
- this.cursorLine++;
1366
- this.cursorColumn = 0;
1367
- }
1368
- } else {
1369
- // On or after bracket, move normally
1370
- this.cursorColumn++;
1371
- }
1372
- } else if (onCollapsed) {
1373
- const bracketPos = line.search(/[{\[]/);
1374
- if (this.cursorColumn < bracketPos) {
1375
- // Before bracket, move normally
1376
- this.cursorColumn++;
1377
- } else if (this.cursorColumn === bracketPos) {
1378
- // On bracket, go to after bracket
1379
- this.cursorColumn = bracketPos + 1;
1380
- } else {
1381
- // After bracket, jump to closing line at bracket
1382
- this.cursorLine = onCollapsed.endLine;
1383
- const closingLine = this.lines[this.cursorLine];
1384
- this.cursorColumn = this._getClosingBracketPos(closingLine);
1385
- }
1678
+
1679
+ if (onClosingLine) {
1680
+ const bracketPos = this._getClosingBracketPos(line);
1681
+ if (this.cursorColumn < bracketPos) {
1682
+ this.cursorColumn = bracketPos;
1386
1683
  } else if (this.cursorColumn >= line.length) {
1387
- // Move to next line
1388
1684
  if (this.cursorLine < this.lines.length - 1) {
1389
1685
  this.cursorLine++;
1390
1686
  this.cursorColumn = 0;
1391
- // Skip hidden collapsed zones
1392
- const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
1393
- if (collapsed) {
1394
- this.cursorLine = collapsed.endLine;
1395
- this.cursorColumn = 0;
1396
- }
1397
1687
  }
1398
1688
  } else {
1399
1689
  this.cursorColumn++;
1400
1690
  }
1401
- } else {
1402
- // Moving left
1403
- if (onClosingLine) {
1404
- const bracketPos = this._getClosingBracketPos(line);
1405
- if (this.cursorColumn > bracketPos + 1) {
1406
- // After bracket, move normally
1407
- this.cursorColumn--;
1408
- } else if (this.cursorColumn === bracketPos + 1) {
1409
- // Just after bracket, jump to opening line after bracket
1410
- this.cursorLine = onClosingLine.startLine;
1411
- const openLine = this.lines[this.cursorLine];
1412
- const openBracketPos = openLine.search(/[{\[]/);
1413
- this.cursorColumn = openBracketPos + 1;
1414
- } else {
1415
- // On bracket, jump to opening line after bracket
1416
- this.cursorLine = onClosingLine.startLine;
1417
- const openLine = this.lines[this.cursorLine];
1418
- const openBracketPos = openLine.search(/[{\[]/);
1419
- this.cursorColumn = openBracketPos + 1;
1420
- }
1421
- } else if (onCollapsed) {
1422
- const bracketPos = line.search(/[{\[]/);
1423
- if (this.cursorColumn > bracketPos + 1) {
1424
- // After bracket, go to just after bracket
1425
- this.cursorColumn = bracketPos + 1;
1426
- } else if (this.cursorColumn === bracketPos + 1) {
1427
- // Just after bracket, go to bracket
1428
- this.cursorColumn = bracketPos;
1429
- } else if (this.cursorColumn > 0) {
1430
- // Before bracket, move normally
1431
- this.cursorColumn--;
1432
- } else {
1433
- // At start, go to previous line
1434
- if (this.cursorLine > 0) {
1435
- this.cursorLine--;
1436
- this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1437
- }
1691
+ } else if (onCollapsed) {
1692
+ const bracketPos = line.search(/[{\[]/);
1693
+ if (this.cursorColumn < bracketPos) {
1694
+ this.cursorColumn++;
1695
+ } else if (this.cursorColumn === bracketPos) {
1696
+ this.cursorColumn = bracketPos + 1;
1697
+ } else {
1698
+ this.cursorLine = onCollapsed.endLine;
1699
+ this.cursorColumn = this._getClosingBracketPos(this.lines[this.cursorLine]);
1700
+ }
1701
+ } else if (this.cursorColumn >= line.length) {
1702
+ if (this.cursorLine < this.lines.length - 1) {
1703
+ this.cursorLine++;
1704
+ this.cursorColumn = 0;
1705
+ const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
1706
+ if (collapsed) {
1707
+ this.cursorLine = collapsed.endLine;
1708
+ this.cursorColumn = 0;
1438
1709
  }
1710
+ }
1711
+ } else {
1712
+ this.cursorColumn++;
1713
+ }
1714
+ }
1715
+
1716
+ _moveCursorLeft() {
1717
+ const line = this.lines[this.cursorLine];
1718
+ const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1719
+ const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1720
+
1721
+ if (onClosingLine) {
1722
+ const bracketPos = this._getClosingBracketPos(line);
1723
+ if (this.cursorColumn > bracketPos + 1) {
1724
+ this.cursorColumn--;
1725
+ } else {
1726
+ // Jump to opening line after bracket
1727
+ this.cursorLine = onClosingLine.startLine;
1728
+ const openLine = this.lines[this.cursorLine];
1729
+ this.cursorColumn = openLine.search(/[{\[]/) + 1;
1730
+ }
1731
+ } else if (onCollapsed) {
1732
+ const bracketPos = line.search(/[{\[]/);
1733
+ if (this.cursorColumn > bracketPos + 1) {
1734
+ this.cursorColumn = bracketPos + 1;
1735
+ } else if (this.cursorColumn === bracketPos + 1) {
1736
+ this.cursorColumn = bracketPos;
1439
1737
  } else if (this.cursorColumn > 0) {
1440
1738
  this.cursorColumn--;
1441
1739
  } else if (this.cursorLine > 0) {
1442
- // Move to previous line
1443
1740
  this.cursorLine--;
1444
-
1445
- // Check if previous line is closing line of collapsed
1446
- const closing = this._getCollapsedClosingLine(this.cursorLine);
1447
- if (closing) {
1448
- // Go to end of closing line
1449
- this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1741
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1742
+ }
1743
+ } else if (this.cursorColumn > 0) {
1744
+ this.cursorColumn--;
1745
+ } else if (this.cursorLine > 0) {
1746
+ this.cursorLine--;
1747
+ const closing = this._getCollapsedClosingLine(this.cursorLine);
1748
+ if (closing) {
1749
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1750
+ } else {
1751
+ const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
1752
+ if (collapsed) {
1753
+ this.cursorLine = collapsed.startLine;
1754
+ const openLine = this.lines[this.cursorLine];
1755
+ this.cursorColumn = openLine.search(/[{\[]/) + 1;
1450
1756
  } else {
1451
- // Check if previous line is inside collapsed zone
1452
- const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
1453
- if (collapsed) {
1454
- // Jump to opening line after bracket
1455
- this.cursorLine = collapsed.startLine;
1456
- const openLine = this.lines[this.cursorLine];
1457
- const bracketPos = openLine.search(/[{\[]/);
1458
- this.cursorColumn = bracketPos + 1;
1459
- } else {
1460
- this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1461
- }
1757
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1462
1758
  }
1463
1759
  }
1464
1760
  }
1465
-
1466
- this._lastStartIndex = -1;
1467
- this._scrollToCursor();
1468
- this.scheduleRender();
1469
1761
  }
1470
1762
 
1471
1763
  /**
@@ -1494,32 +1786,26 @@ class GeoJsonEditor extends HTMLElement {
1494
1786
  }
1495
1787
 
1496
1788
  /**
1497
- * Legacy moveCursor for compatibility
1789
+ * Handle arrow key with optional selection and word jump
1498
1790
  */
1499
- moveCursor(deltaLine, deltaCol) {
1500
- if (deltaLine !== 0) {
1501
- this.moveCursorSkipCollapsed(deltaLine);
1502
- } else if (deltaCol !== 0) {
1503
- this.moveCursorHorizontal(deltaCol);
1504
- }
1505
- }
1506
-
1507
- /**
1508
- * Handle arrow key with optional selection
1509
- */
1510
- _handleArrowKey(deltaLine, deltaCol, isShift) {
1791
+ _handleArrowKey(deltaLine, deltaCol, isShift, isCtrl = false) {
1511
1792
  // Start selection if shift is pressed and no selection exists
1512
1793
  if (isShift && !this.selectionStart) {
1513
1794
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
1514
1795
  }
1515
-
1796
+
1516
1797
  // Move cursor
1517
1798
  if (deltaLine !== 0) {
1518
1799
  this.moveCursorSkipCollapsed(deltaLine);
1519
1800
  } else if (deltaCol !== 0) {
1520
- this.moveCursorHorizontal(deltaCol);
1801
+ if (isCtrl) {
1802
+ // Word-by-word movement
1803
+ this._moveCursorByWord(deltaCol);
1804
+ } else {
1805
+ this.moveCursorHorizontal(deltaCol);
1806
+ }
1521
1807
  }
1522
-
1808
+
1523
1809
  // Update selection end if shift is pressed
1524
1810
  if (isShift) {
1525
1811
  this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
@@ -1530,6 +1816,73 @@ class GeoJsonEditor extends HTMLElement {
1530
1816
  }
1531
1817
  }
1532
1818
 
1819
+ /**
1820
+ * Move cursor by word (Ctrl+Arrow)
1821
+ * Behavior matches VSCode/Monaco:
1822
+ * - Ctrl+Right: move to end of current word, or start of next word
1823
+ * - Ctrl+Left: move to start of current word, or start of previous word
1824
+ */
1825
+ _moveCursorByWord(direction) {
1826
+ const line = this.lines[this.cursorLine] || '';
1827
+ // Word character: alphanumeric, underscore, or hyphen (for kebab-case identifiers)
1828
+ const isWordChar = (ch) => /[\w-]/.test(ch);
1829
+
1830
+ if (direction > 0) {
1831
+ // Move right
1832
+ let pos = this.cursorColumn;
1833
+
1834
+ if (pos >= line.length) {
1835
+ // At end of line, move to start of next line
1836
+ if (this.cursorLine < this.lines.length - 1) {
1837
+ this.cursorLine++;
1838
+ this.cursorColumn = 0;
1839
+ }
1840
+ } else if (isWordChar(line[pos])) {
1841
+ // Inside a word: move to end of word
1842
+ while (pos < line.length && isWordChar(line[pos])) {
1843
+ pos++;
1844
+ }
1845
+ this.cursorColumn = pos;
1846
+ } else {
1847
+ // On non-word char: skip non-word chars only (stop at start of next word)
1848
+ while (pos < line.length && !isWordChar(line[pos])) {
1849
+ pos++;
1850
+ }
1851
+ this.cursorColumn = pos;
1852
+ }
1853
+ } else {
1854
+ // Move left
1855
+ let pos = this.cursorColumn;
1856
+
1857
+ if (pos === 0) {
1858
+ // At start of line, move to end of previous line
1859
+ if (this.cursorLine > 0) {
1860
+ this.cursorLine--;
1861
+ this.cursorColumn = this.lines[this.cursorLine].length;
1862
+ }
1863
+ } else if (pos > 0 && isWordChar(line[pos - 1])) {
1864
+ // Just after a word char: move to start of word
1865
+ while (pos > 0 && isWordChar(line[pos - 1])) {
1866
+ pos--;
1867
+ }
1868
+ this.cursorColumn = pos;
1869
+ } else {
1870
+ // On or after non-word char: skip non-word chars, then skip word
1871
+ while (pos > 0 && !isWordChar(line[pos - 1])) {
1872
+ pos--;
1873
+ }
1874
+ while (pos > 0 && isWordChar(line[pos - 1])) {
1875
+ pos--;
1876
+ }
1877
+ this.cursorColumn = pos;
1878
+ }
1879
+ }
1880
+
1881
+ this._invalidateRenderCache();
1882
+ this._scrollToCursor();
1883
+ this.scheduleRender();
1884
+ }
1885
+
1533
1886
  /**
1534
1887
  * Handle Home/End with optional selection
1535
1888
  */
@@ -1558,7 +1911,7 @@ class GeoJsonEditor extends HTMLElement {
1558
1911
  this.selectionEnd = null;
1559
1912
  }
1560
1913
 
1561
- this._lastStartIndex = -1;
1914
+ this._invalidateRenderCache();
1562
1915
  this._scrollToCursor();
1563
1916
  this.scheduleRender();
1564
1917
  }
@@ -1573,7 +1926,7 @@ class GeoJsonEditor extends HTMLElement {
1573
1926
  this.cursorLine = lastLine;
1574
1927
  this.cursorColumn = this.lines[lastLine]?.length || 0;
1575
1928
 
1576
- this._lastStartIndex = -1;
1929
+ this._invalidateRenderCache();
1577
1930
  this._scrollToCursor();
1578
1931
  this.scheduleRender();
1579
1932
  }
@@ -1640,9 +1993,11 @@ class GeoJsonEditor extends HTMLElement {
1640
1993
  */
1641
1994
  _deleteSelection() {
1642
1995
  if (!this._hasSelection()) return false;
1643
-
1996
+
1997
+ this._saveToHistory('delete');
1998
+
1644
1999
  const { start, end } = this._normalizeSelection();
1645
-
2000
+
1646
2001
  if (start.line === end.line) {
1647
2002
  // Single line selection
1648
2003
  const line = this.lines[start.line];
@@ -1654,12 +2009,12 @@ class GeoJsonEditor extends HTMLElement {
1654
2009
  this.lines[start.line] = startLine + endLine;
1655
2010
  this.lines.splice(start.line + 1, end.line - start.line);
1656
2011
  }
1657
-
2012
+
1658
2013
  this.cursorLine = start.line;
1659
2014
  this.cursorColumn = start.column;
1660
2015
  this.selectionStart = null;
1661
2016
  this.selectionEnd = null;
1662
-
2017
+
1663
2018
  return true;
1664
2019
  }
1665
2020
 
@@ -1668,10 +2023,10 @@ class GeoJsonEditor extends HTMLElement {
1668
2023
  if (this._hasSelection()) {
1669
2024
  this._deleteSelection();
1670
2025
  }
1671
-
2026
+
1672
2027
  // Block insertion in hidden collapsed zones
1673
2028
  if (this._getCollapsedRangeForLine(this.cursorLine)) return;
1674
-
2029
+
1675
2030
  // On closing line, only allow after bracket
1676
2031
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1677
2032
  if (onClosingLine) {
@@ -1679,7 +2034,7 @@ class GeoJsonEditor extends HTMLElement {
1679
2034
  const bracketPos = this._getClosingBracketPos(line);
1680
2035
  if (this.cursorColumn <= bracketPos) return;
1681
2036
  }
1682
-
2037
+
1683
2038
  // On collapsed opening line, only allow before bracket
1684
2039
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1685
2040
  if (onCollapsed) {
@@ -1687,7 +2042,10 @@ class GeoJsonEditor extends HTMLElement {
1687
2042
  const bracketPos = line.search(/[{\[]/);
1688
2043
  if (this.cursorColumn > bracketPos) return;
1689
2044
  }
1690
-
2045
+
2046
+ // Save to history before making changes
2047
+ this._saveToHistory('insert');
2048
+
1691
2049
  // Handle empty editor case
1692
2050
  if (this.lines.length === 0) {
1693
2051
  // Split text by newlines to properly handle multi-line paste
@@ -1706,18 +2064,30 @@ class GeoJsonEditor extends HTMLElement {
1706
2064
  handlePaste(e) {
1707
2065
  e.preventDefault();
1708
2066
  const text = e.clipboardData.getData('text/plain');
1709
- if (text) {
1710
- const wasEmpty = this.lines.length === 0;
2067
+ if (!text) return;
2068
+
2069
+ const wasEmpty = this.lines.length === 0;
2070
+
2071
+ // Try to parse as GeoJSON and normalize
2072
+ try {
2073
+ const parsed = JSON.parse(text);
2074
+ const features = this._normalizeToFeatures(parsed);
2075
+ // Valid GeoJSON - insert formatted features
2076
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2077
+ this.insertText(formatted);
2078
+ } catch {
2079
+ // Invalid GeoJSON - fallback to raw text insertion
1711
2080
  this.insertText(text);
1712
- // Auto-collapse coordinates after pasting into empty editor
1713
- if (wasEmpty && this.lines.length > 0) {
1714
- // Cancel pending render, collapse first, then render once
1715
- if (this.renderTimer) {
1716
- cancelAnimationFrame(this.renderTimer);
1717
- this.renderTimer = null;
1718
- }
1719
- this.autoCollapseCoordinates();
2081
+ }
2082
+
2083
+ // Auto-collapse coordinates after pasting into empty editor
2084
+ if (wasEmpty && this.lines.length > 0) {
2085
+ // Cancel pending render, collapse first, then render once
2086
+ if (this.renderTimer) {
2087
+ cancelAnimationFrame(this.renderTimer);
2088
+ this.renderTimer = null;
1720
2089
  }
2090
+ this.autoCollapseCoordinates();
1721
2091
  }
1722
2092
  }
1723
2093
 
@@ -1735,11 +2105,13 @@ class GeoJsonEditor extends HTMLElement {
1735
2105
  e.preventDefault();
1736
2106
  if (this._hasSelection()) {
1737
2107
  e.clipboardData.setData('text/plain', this._getSelectedText());
2108
+ this._saveToHistory('cut');
1738
2109
  this._deleteSelection();
1739
2110
  this.formatAndUpdate();
1740
2111
  } else {
1741
2112
  // Cut all content
1742
2113
  e.clipboardData.setData('text/plain', this.getContent());
2114
+ this._saveToHistory('cut');
1743
2115
  this.lines = [];
1744
2116
  this.cursorLine = 0;
1745
2117
  this.cursorColumn = 0;
@@ -1885,21 +2257,70 @@ class GeoJsonEditor extends HTMLElement {
1885
2257
 
1886
2258
  // Use updateView - don't rebuild nodeId mappings since content didn't change
1887
2259
  this.updateView();
1888
- this._lastStartIndex = -1; // Force re-render
2260
+ this._invalidateRenderCache(); // Force re-render
1889
2261
  this.scheduleRender();
1890
2262
  }
1891
2263
 
1892
2264
  autoCollapseCoordinates() {
2265
+ this._applyCollapsedOption(['coordinates']);
2266
+ }
2267
+
2268
+ /**
2269
+ * Helper to apply collapsed option from API methods
2270
+ * @param {object} options - Options object with optional collapsed property
2271
+ * @param {array} features - Features array for function mode
2272
+ */
2273
+ _applyCollapsedFromOptions(options, features) {
2274
+ const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2275
+ if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2276
+ this._applyCollapsedOption(collapsed, features);
2277
+ }
2278
+ }
2279
+
2280
+ /**
2281
+ * Apply collapsed option to nodes
2282
+ * @param {string[]|function} collapsed - Attributes to collapse or function returning them
2283
+ * @param {array} features - Features array for function mode (optional)
2284
+ */
2285
+ _applyCollapsedOption(collapsed, features = null) {
1893
2286
  const ranges = this._findCollapsibleRanges();
1894
2287
 
2288
+ // Group ranges by feature (root nodes)
2289
+ const featureRanges = ranges.filter(r => r.isRootFeature);
2290
+
2291
+ // Determine which attributes to collapse per feature
1895
2292
  for (const range of ranges) {
1896
- if (range.nodeKey === 'coordinates') {
2293
+ let shouldCollapse = false;
2294
+
2295
+ if (typeof collapsed === 'function') {
2296
+ // Find which feature this range belongs to
2297
+ const featureIndex = featureRanges.findIndex(fr =>
2298
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
2299
+ );
2300
+ const feature = features?.[featureIndex] || null;
2301
+ const collapsedAttrs = collapsed(feature, featureIndex);
2302
+
2303
+ // Check if this range should be collapsed
2304
+ if (range.isRootFeature) {
2305
+ shouldCollapse = collapsedAttrs.includes('$root');
2306
+ } else {
2307
+ shouldCollapse = collapsedAttrs.includes(range.nodeKey);
2308
+ }
2309
+ } else if (Array.isArray(collapsed)) {
2310
+ // Static list
2311
+ if (range.isRootFeature) {
2312
+ shouldCollapse = collapsed.includes('$root');
2313
+ } else {
2314
+ shouldCollapse = collapsed.includes(range.nodeKey);
2315
+ }
2316
+ }
2317
+
2318
+ if (shouldCollapse) {
1897
2319
  this.collapsedNodes.add(range.nodeId);
1898
2320
  }
1899
2321
  }
1900
2322
 
1901
2323
  // Rebuild everything to ensure consistent state after collapse changes
1902
- // This is especially important after paste into empty editor
1903
2324
  this.updateModel();
1904
2325
  this.scheduleRender();
1905
2326
  }
@@ -1943,11 +2364,11 @@ class GeoJsonEditor extends HTMLElement {
1943
2364
  `;
1944
2365
  document.body.appendChild(anchor);
1945
2366
 
1946
- const colorInput = document.createElement('input');
2367
+ const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
1947
2368
  colorInput.type = 'color';
1948
2369
  colorInput.value = currentColor;
1949
2370
  colorInput.className = 'geojson-color-picker-input';
1950
-
2371
+
1951
2372
  // Position the color input inside the anchor
1952
2373
  colorInput.style.cssText = `
1953
2374
  position: absolute;
@@ -1961,18 +2382,18 @@ class GeoJsonEditor extends HTMLElement {
1961
2382
  cursor: pointer;
1962
2383
  `;
1963
2384
  anchor.appendChild(colorInput);
1964
-
1965
- colorInput.addEventListener('input', (e) => {
1966
- this.updateColorValue(line, e.target.value, attributeName);
2385
+
2386
+ colorInput.addEventListener('input', (e: Event) => {
2387
+ this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
1967
2388
  });
1968
-
1969
- const closeOnClickOutside = (e) => {
2389
+
2390
+ const closeOnClickOutside = (e: Event) => {
1970
2391
  if (e.target !== colorInput) {
1971
2392
  document.removeEventListener('click', closeOnClickOutside, true);
1972
2393
  anchor.remove(); // Remove anchor (which contains the input)
1973
2394
  }
1974
2395
  };
1975
-
2396
+
1976
2397
  colorInput._closeListener = closeOnClickOutside;
1977
2398
 
1978
2399
  setTimeout(() => {
@@ -2070,10 +2491,10 @@ class GeoJsonEditor extends HTMLElement {
2070
2491
 
2071
2492
  updateReadonly() {
2072
2493
  const textarea = this.shadowRoot.getElementById('hiddenTextarea');
2073
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
2074
-
2494
+ const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
2495
+
2075
2496
  // Use readOnly instead of disabled to allow text selection for copying
2076
- if (textarea) textarea.readOnly = this.readonly;
2497
+ if (textarea) (textarea as HTMLTextAreaElement).readOnly = this.readonly;
2077
2498
  if (clearBtn) clearBtn.hidden = this.readonly;
2078
2499
  }
2079
2500
 
@@ -2159,13 +2580,13 @@ class GeoJsonEditor extends HTMLElement {
2159
2580
  return `:host-context(${selector})`;
2160
2581
  }
2161
2582
 
2162
- setTheme(theme) {
2583
+ setTheme(theme: ThemeSettings): void {
2163
2584
  if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2164
2585
  if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
2165
2586
  this.updateThemeCSS();
2166
2587
  }
2167
2588
 
2168
- resetTheme() {
2589
+ resetTheme(): void {
2169
2590
  this.themes = { dark: {}, light: {} };
2170
2591
  this.updateThemeCSS();
2171
2592
  }
@@ -2266,178 +2687,164 @@ class GeoJsonEditor extends HTMLElement {
2266
2687
  }
2267
2688
 
2268
2689
  _buildContextMap() {
2690
+ // Memoization: return cached result if content hasn't changed
2691
+ const linesLength = this.lines.length;
2692
+ if (this._contextMapCache &&
2693
+ this._contextMapLinesLength === linesLength &&
2694
+ this._contextMapFirstLine === this.lines[0] &&
2695
+ this._contextMapLastLine === this.lines[linesLength - 1]) {
2696
+ return this._contextMapCache;
2697
+ }
2698
+
2269
2699
  const contextMap = new Map();
2270
2700
  const contextStack = [];
2271
2701
  let pendingContext = null;
2272
-
2273
- for (let i = 0; i < this.lines.length; i++) {
2702
+
2703
+ for (let i = 0; i < linesLength; i++) {
2274
2704
  const line = this.lines[i];
2275
2705
  const currentContext = contextStack[contextStack.length - 1]?.context || 'Feature';
2276
2706
  contextMap.set(i, currentContext);
2277
-
2707
+
2278
2708
  // Check for context-changing keys
2279
- if (/"geometry"\s*:/.test(line)) pendingContext = 'geometry';
2280
- else if (/"properties"\s*:/.test(line)) pendingContext = 'properties';
2281
- else if (/"features"\s*:/.test(line)) pendingContext = 'Feature';
2282
-
2709
+ if (RE_CONTEXT_GEOMETRY.test(line)) pendingContext = 'geometry';
2710
+ else if (RE_CONTEXT_PROPERTIES.test(line)) pendingContext = 'properties';
2711
+ else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
2712
+
2283
2713
  // Track brackets
2284
2714
  const openBraces = (line.match(/\{/g) || []).length;
2285
2715
  const closeBraces = (line.match(/\}/g) || []).length;
2286
2716
  const openBrackets = (line.match(/\[/g) || []).length;
2287
2717
  const closeBrackets = (line.match(/\]/g) || []).length;
2288
-
2718
+
2289
2719
  for (let j = 0; j < openBraces + openBrackets; j++) {
2290
2720
  contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
2291
2721
  pendingContext = null;
2292
2722
  }
2293
-
2723
+
2294
2724
  for (let j = 0; j < closeBraces + closeBrackets && contextStack.length > 0; j++) {
2295
2725
  contextStack.pop();
2296
2726
  }
2297
2727
  }
2298
-
2728
+
2729
+ // Cache the result
2730
+ this._contextMapCache = contextMap;
2731
+ this._contextMapLinesLength = linesLength;
2732
+ this._contextMapFirstLine = this.lines[0];
2733
+ this._contextMapLastLine = this.lines[linesLength - 1];
2734
+
2299
2735
  return contextMap;
2300
2736
  }
2301
2737
 
2302
2738
  _highlightSyntax(text, context, meta) {
2303
2739
  if (!text) return '';
2304
-
2740
+
2305
2741
  // For collapsed nodes, truncate the text at the opening bracket
2306
2742
  let displayText = text;
2307
2743
  let collapsedBracket = null;
2308
-
2744
+
2309
2745
  if (meta?.collapseButton?.isCollapsed) {
2310
2746
  // Match "key": { or "key": [
2311
- const bracketMatch = text.match(/^(\s*"[^"]+"\s*:\s*)([{\[])/);
2747
+ const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
2312
2748
  // Also match standalone { or [ (root Feature objects)
2313
- const rootMatch = !bracketMatch && text.match(/^(\s*)([{\[]),?\s*$/);
2314
-
2749
+ const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
2750
+
2315
2751
  if (bracketMatch) {
2316
- // Keep only the part up to and including the opening bracket
2317
2752
  displayText = bracketMatch[1] + bracketMatch[2];
2318
2753
  collapsedBracket = bracketMatch[2];
2319
2754
  } else if (rootMatch) {
2320
- // Root object - just keep the bracket
2321
2755
  displayText = rootMatch[1] + rootMatch[2];
2322
2756
  collapsedBracket = rootMatch[2];
2323
2757
  }
2324
2758
  }
2325
-
2759
+
2326
2760
  // Escape HTML first
2327
2761
  let result = displayText
2328
- .replace(/&/g, '&amp;')
2329
- .replace(/</g, '&lt;')
2330
- .replace(/>/g, '&gt;');
2331
-
2762
+ .replace(RE_ESCAPE_AMP, '&amp;')
2763
+ .replace(RE_ESCAPE_LT, '&lt;')
2764
+ .replace(RE_ESCAPE_GT, '&gt;');
2765
+
2332
2766
  // Punctuation FIRST (before other replacements can interfere)
2333
- result = result.replace(/([{}[\],:])/g, '<span class="json-punctuation">$1</span>');
2334
-
2767
+ result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
2768
+
2335
2769
  // JSON keys - match "key" followed by :
2336
2770
  // In properties context, all keys are treated as regular JSON keys
2337
- result = result.replace(/"([^"]+)"(<span class="json-punctuation">:<\/span>)/g, (match, key, colon) => {
2771
+ RE_JSON_KEYS.lastIndex = 0;
2772
+ result = result.replace(RE_JSON_KEYS, (match, key, colon) => {
2338
2773
  if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
2339
2774
  return `<span class="geojson-key">"${key}"</span>${colon}`;
2340
2775
  }
2341
2776
  return `<span class="json-key">"${key}"</span>${colon}`;
2342
2777
  });
2343
-
2778
+
2344
2779
  // Type values - "type": "Value" - but NOT inside properties context
2345
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2346
2780
  if (context !== 'properties') {
2347
- result = result.replace(
2348
- /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>(\s*)"([^"]*)"/g,
2349
- (match, space, type) => {
2350
- const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
2351
- const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
2352
- return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
2353
- }
2354
- );
2781
+ RE_TYPE_VALUES.lastIndex = 0;
2782
+ result = result.replace(RE_TYPE_VALUES, (match, space, type) => {
2783
+ const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
2784
+ const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
2785
+ return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
2786
+ });
2355
2787
  }
2356
2788
 
2357
2789
  // String values (not already wrapped in spans)
2358
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2359
- result = result.replace(
2360
- /(<span class="json-punctuation">:<\/span>)(\s*)"([^"]*)"/g,
2361
- (match, colon, space, val) => {
2362
- // Don't double-wrap if already has a span after colon
2363
- if (match.includes('geojson-type') || match.includes('json-string')) return match;
2364
-
2365
- // Check if it's a color value (hex) - use ::before for swatch via CSS class
2366
- if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(val)) {
2367
- return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
2368
- }
2369
-
2370
- return `${colon}${space}<span class="json-string">"${val}"</span>`;
2790
+ RE_STRING_VALUES.lastIndex = 0;
2791
+ result = result.replace(RE_STRING_VALUES, (match, colon, space, val) => {
2792
+ if (match.includes('geojson-type') || match.includes('json-string')) return match;
2793
+ if (RE_COLOR_HEX.test(val)) {
2794
+ return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
2371
2795
  }
2372
- );
2796
+ return `${colon}${space}<span class="json-string">"${val}"</span>`;
2797
+ });
2373
2798
 
2374
2799
  // Numbers after colon
2375
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2376
- result = result.replace(
2377
- /(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2378
- '$1$2<span class="json-number">$3</span>'
2379
- );
2800
+ RE_NUMBERS_COLON.lastIndex = 0;
2801
+ result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
2380
2802
 
2381
2803
  // Numbers in arrays (after [ or ,)
2382
- result = result.replace(
2383
- /(<span class="json-punctuation">[\[,]<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2384
- '$1$2<span class="json-number">$3</span>'
2385
- );
2804
+ RE_NUMBERS_ARRAY.lastIndex = 0;
2805
+ result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
2386
2806
 
2387
2807
  // Standalone numbers at start of line (coordinates arrays)
2388
- result = result.replace(
2389
- /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim,
2390
- '$1<span class="json-number">$2</span>'
2391
- );
2808
+ RE_NUMBERS_START.lastIndex = 0;
2809
+ result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
2392
2810
 
2393
2811
  // Booleans - use ::before for checkbox via CSS class
2394
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2395
- result = result.replace(
2396
- /(<span class="json-punctuation">:<\/span>)(\s*)(true|false)/g,
2397
- (match, colon, space, val) => {
2398
- const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
2399
- return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
2400
- }
2401
- );
2812
+ RE_BOOLEANS.lastIndex = 0;
2813
+ result = result.replace(RE_BOOLEANS, (match, colon, space, val) => {
2814
+ const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
2815
+ return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
2816
+ });
2402
2817
 
2403
2818
  // Null
2404
- // IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
2405
- result = result.replace(
2406
- /(<span class="json-punctuation">:<\/span>)(\s*)(null)/g,
2407
- '$1$2<span class="json-null">$3</span>'
2408
- );
2409
-
2410
- // Collapsed bracket indicator - just add the class, CSS ::after adds the "...]" or "...}"
2819
+ RE_NULL.lastIndex = 0;
2820
+ result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
2821
+
2822
+ // Collapsed bracket indicator
2411
2823
  if (collapsedBracket) {
2412
2824
  const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
2413
- // Replace the last punctuation span (the opening bracket) with collapsed style class
2414
2825
  result = result.replace(
2415
2826
  new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
2416
2827
  `<span class="${bracketClass}">${collapsedBracket}</span>`
2417
2828
  );
2418
2829
  }
2419
-
2420
- // Mark unrecognized text as error - text that's not inside a span and not just whitespace
2421
- // This catches invalid JSON like unquoted strings, malformed values, etc.
2422
- result = result.replace(
2423
- /(<\/span>|^)([^<]+)(<span|$)/g,
2424
- (match, before, text, after) => {
2425
- // Skip if text is only whitespace or empty
2426
- if (!text || /^\s*$/.test(text)) return match;
2427
- // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
2428
- // Keep whitespace as-is, wrap any non-whitespace unrecognized token
2429
- const parts = text.split(/(\s+)/);
2430
- let hasError = false;
2431
- const processed = parts.map(part => {
2432
- // If it's whitespace, keep it
2433
- if (/^\s*$/.test(part)) return part;
2434
- // Mark as error
2435
- hasError = true;
2436
- return `<span class="json-error">${part}</span>`;
2437
- }).join('');
2438
- return hasError ? before + processed + after : match;
2439
- }
2440
- );
2830
+
2831
+ // Mark unrecognized text as error
2832
+ RE_UNRECOGNIZED.lastIndex = 0;
2833
+ result = result.replace(RE_UNRECOGNIZED, (match, before, text, after) => {
2834
+ if (!text || RE_WHITESPACE_ONLY.test(text)) return match;
2835
+ // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
2836
+ // Keep whitespace as-is, wrap any non-whitespace unrecognized token
2837
+ const parts = text.split(RE_WHITESPACE_SPLIT);
2838
+ let hasError = false;
2839
+ const processed = parts.map(part => {
2840
+ // If it's whitespace, keep it
2841
+ if (RE_WHITESPACE_ONLY.test(part)) return part;
2842
+ // Mark as error
2843
+ hasError = true;
2844
+ return `<span class="json-error">${part}</span>`;
2845
+ }).join('');
2846
+ return hasError ? before + processed + after : match;
2847
+ });
2441
2848
 
2442
2849
  // Note: visibility is now handled at line level (has-visibility class on .line element)
2443
2850
 
@@ -2463,28 +2870,135 @@ class GeoJsonEditor extends HTMLElement {
2463
2870
  return errors;
2464
2871
  }
2465
2872
 
2873
+ /**
2874
+ * Validate a single feature object
2875
+ * @param {object} feature - The feature to validate
2876
+ * @throws {Error} If the feature is invalid
2877
+ */
2878
+ _validateFeature(feature) {
2879
+ if (!feature || typeof feature !== 'object') {
2880
+ throw new Error('Feature must be an object');
2881
+ }
2882
+ if (feature.type !== 'Feature') {
2883
+ throw new Error('Feature type must be "Feature"');
2884
+ }
2885
+ if (!('geometry' in feature)) {
2886
+ throw new Error('Feature must have a geometry property');
2887
+ }
2888
+ if (!('properties' in feature)) {
2889
+ throw new Error('Feature must have a properties property');
2890
+ }
2891
+ if (feature.geometry !== null) {
2892
+ if (!feature.geometry || typeof feature.geometry !== 'object') {
2893
+ throw new Error('Feature geometry must be an object or null');
2894
+ }
2895
+ if (!feature.geometry.type) {
2896
+ throw new Error('Feature geometry must have a type');
2897
+ }
2898
+ if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2899
+ throw new Error(`Invalid geometry type: "${feature.geometry.type}"`);
2900
+ }
2901
+ if (!('coordinates' in feature.geometry)) {
2902
+ throw new Error('Feature geometry must have coordinates');
2903
+ }
2904
+ }
2905
+ if (feature.properties !== null && typeof feature.properties !== 'object') {
2906
+ throw new Error('Feature properties must be an object or null');
2907
+ }
2908
+ }
2909
+
2910
+ /**
2911
+ * Normalize input to an array of features
2912
+ * Accepts: FeatureCollection, Feature[], or single Feature
2913
+ * @param {object|array} input - Input to normalize
2914
+ * @returns {array} Array of features
2915
+ * @throws {Error} If input is invalid
2916
+ */
2917
+ _normalizeToFeatures(input) {
2918
+ let features = [];
2919
+
2920
+ if (Array.isArray(input)) {
2921
+ // Array of features
2922
+ features = input;
2923
+ } else if (input && typeof input === 'object') {
2924
+ if (input.type === 'FeatureCollection' && Array.isArray(input.features)) {
2925
+ // FeatureCollection
2926
+ features = input.features;
2927
+ } else if (input.type === 'Feature') {
2928
+ // Single Feature
2929
+ features = [input];
2930
+ } else {
2931
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2932
+ }
2933
+ } else {
2934
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2935
+ }
2936
+
2937
+ // Validate each feature
2938
+ for (const feature of features) {
2939
+ this._validateFeature(feature);
2940
+ }
2941
+
2942
+ return features;
2943
+ }
2944
+
2466
2945
  // ========== Public API ==========
2467
-
2468
- set(features) {
2469
- if (!Array.isArray(features)) throw new Error('set() expects an array');
2946
+
2947
+ /**
2948
+ * Replace all features in the editor
2949
+ * Accepts: FeatureCollection, Feature[], or single Feature
2950
+ * @param {object|array} input - Features to set
2951
+ * @param {object} options - Optional settings
2952
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2953
+ * - string[]: List of attributes to collapse (e.g., ['coordinates', 'geometry'])
2954
+ * - function(feature, index): Returns string[] of attributes to collapse per feature
2955
+ * - Use '$root' to collapse the entire feature
2956
+ * @throws {Error} If input is invalid
2957
+ */
2958
+ set(input: FeatureInput, options: SetOptions = {}): void {
2959
+ const features = this._normalizeToFeatures(input);
2470
2960
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2471
- this.setValue(formatted);
2961
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
2962
+ this._applyCollapsedFromOptions(options, features);
2472
2963
  }
2473
2964
 
2474
- add(feature) {
2475
- const features = this._parseFeatures();
2476
- features.push(feature);
2477
- this.set(features);
2965
+ /**
2966
+ * Add features to the end of the editor
2967
+ * Accepts: FeatureCollection, Feature[], or single Feature
2968
+ * @param {object|array} input - Features to add
2969
+ * @param {object} options - Optional settings
2970
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2971
+ * @throws {Error} If input is invalid
2972
+ */
2973
+ add(input: FeatureInput, options: SetOptions = {}): void {
2974
+ const newFeatures = this._normalizeToFeatures(input);
2975
+ const existingFeatures = this._parseFeatures();
2976
+ const allFeatures = [...existingFeatures, ...newFeatures];
2977
+ const formatted = allFeatures.map(f => JSON.stringify(f, null, 2)).join(',\n');
2978
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
2979
+ this._applyCollapsedFromOptions(options, allFeatures);
2478
2980
  }
2479
2981
 
2480
- insertAt(feature, index) {
2982
+ /**
2983
+ * Insert features at a specific index
2984
+ * Accepts: FeatureCollection, Feature[], or single Feature
2985
+ * @param {object|array} input - Features to insert
2986
+ * @param {number} index - Index to insert at (negative = from end)
2987
+ * @param {object} options - Optional settings
2988
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2989
+ * @throws {Error} If input is invalid
2990
+ */
2991
+ insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
2992
+ const newFeatures = this._normalizeToFeatures(input);
2481
2993
  const features = this._parseFeatures();
2482
2994
  const idx = index < 0 ? features.length + index : index;
2483
- features.splice(Math.max(0, Math.min(idx, features.length)), 0, feature);
2484
- this.set(features);
2995
+ features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
2996
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2997
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
2998
+ this._applyCollapsedFromOptions(options, features);
2485
2999
  }
2486
3000
 
2487
- removeAt(index) {
3001
+ removeAt(index: number): Feature | undefined {
2488
3002
  const features = this._parseFeatures();
2489
3003
  const idx = index < 0 ? features.length + index : index;
2490
3004
  if (idx >= 0 && idx < features.length) {
@@ -2495,7 +3009,10 @@ class GeoJsonEditor extends HTMLElement {
2495
3009
  return undefined;
2496
3010
  }
2497
3011
 
2498
- removeAll() {
3012
+ removeAll(): Feature[] {
3013
+ if (this.lines.length > 0) {
3014
+ this._saveToHistory('removeAll');
3015
+ }
2499
3016
  const removed = this._parseFeatures();
2500
3017
  this.lines = [];
2501
3018
  this.collapsedNodes.clear();
@@ -2507,20 +3024,110 @@ class GeoJsonEditor extends HTMLElement {
2507
3024
  return removed;
2508
3025
  }
2509
3026
 
2510
- get(index) {
3027
+ get(index: number): Feature | undefined {
2511
3028
  const features = this._parseFeatures();
2512
3029
  const idx = index < 0 ? features.length + index : index;
2513
3030
  return features[idx];
2514
3031
  }
2515
3032
 
2516
- getAll() {
3033
+ getAll(): Feature[] {
2517
3034
  return this._parseFeatures();
2518
3035
  }
2519
3036
 
2520
- emit() {
3037
+ emit(): void {
2521
3038
  this.emitChange();
2522
3039
  }
2523
3040
 
3041
+ /**
3042
+ * Save GeoJSON to a file (triggers download)
3043
+ */
3044
+ save(filename: string = 'features.geojson'): boolean {
3045
+ try {
3046
+ const features = this._parseFeatures();
3047
+ const geojson = {
3048
+ type: 'FeatureCollection',
3049
+ features: features
3050
+ };
3051
+ const json = JSON.stringify(geojson, null, 2);
3052
+ const blob = new Blob([json], { type: 'application/geo+json' });
3053
+ const url = URL.createObjectURL(blob);
3054
+
3055
+ const a = document.createElement('a');
3056
+ a.href = url;
3057
+ a.download = filename;
3058
+ document.body.appendChild(a);
3059
+ a.click();
3060
+ document.body.removeChild(a);
3061
+ URL.revokeObjectURL(url);
3062
+
3063
+ return true;
3064
+ } catch (e) {
3065
+ return false;
3066
+ }
3067
+ }
3068
+
3069
+ /**
3070
+ * Open a GeoJSON file from the client filesystem
3071
+ * Note: Available even in readonly mode via API (only Ctrl+O shortcut is blocked)
3072
+ * @param {object} options - Optional settings
3073
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
3074
+ * @returns {Promise<boolean>} Promise that resolves to true if file was loaded successfully
3075
+ */
3076
+ open(options: SetOptions = {}): Promise<boolean> {
3077
+ return new Promise((resolve) => {
3078
+ const input = document.createElement('input');
3079
+ input.type = 'file';
3080
+ input.accept = '.geojson,.json,application/geo+json,application/json';
3081
+ input.style.display = 'none';
3082
+
3083
+ input.addEventListener('change', (e: Event) => {
3084
+ const file = (e.target as HTMLInputElement).files?.[0];
3085
+ if (!file) {
3086
+ document.body.removeChild(input);
3087
+ resolve(false);
3088
+ return;
3089
+ }
3090
+
3091
+ const reader = new FileReader();
3092
+ reader.onload = (event: ProgressEvent<FileReader>) => {
3093
+ try {
3094
+ const content = event.target?.result as string;
3095
+ const parsed = JSON.parse(content);
3096
+
3097
+ // Normalize and validate features
3098
+ const features = this._normalizeToFeatures(parsed);
3099
+
3100
+ // Load features into editor
3101
+ this._saveToHistory('open');
3102
+ this.set(features, options);
3103
+ this.clearHistory(); // Clear history after opening new file
3104
+ document.body.removeChild(input);
3105
+ resolve(true);
3106
+ } catch (err) {
3107
+ document.body.removeChild(input);
3108
+ resolve(false);
3109
+ }
3110
+ };
3111
+
3112
+ reader.onerror = () => {
3113
+ document.body.removeChild(input);
3114
+ resolve(false);
3115
+ };
3116
+
3117
+ reader.readAsText(file);
3118
+ });
3119
+
3120
+ // Handle cancel (no file selected)
3121
+ input.addEventListener('cancel', () => {
3122
+ document.body.removeChild(input);
3123
+ resolve(false);
3124
+ });
3125
+
3126
+ document.body.appendChild(input);
3127
+ input.click();
3128
+ });
3129
+ }
3130
+
2524
3131
  _parseFeatures() {
2525
3132
  try {
2526
3133
  const content = this.lines.join('\n');