@softwarity/geojson-editor 1.0.16 → 1.0.18

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,177 +1,56 @@
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;
145
-
146
- // Version injected by Vite build from package.json
147
- const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'dev';
148
-
149
- // GeoJSON constants
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+)/;
3
+ import type { Feature } from 'geojson';
4
+
5
+ // ========== Imports from extracted modules ==========
6
+ import type {
7
+ SetOptions,
8
+ ThemeSettings
9
+ } from './types.js';
10
+
11
+ import type {
12
+ CursorPosition,
13
+ FeatureInput,
14
+ LineMeta,
15
+ VisibleLine,
16
+ FeatureRange,
17
+ NodeRangeInfo,
18
+ EditorSnapshot,
19
+ CollapsedZoneContext,
20
+ CollapsedNodeInfo
21
+ } from './internal-types.js';
22
+
23
+ import {
24
+ VERSION,
25
+ RE_CONTEXT_GEOMETRY,
26
+ RE_CONTEXT_PROPERTIES,
27
+ RE_CONTEXT_FEATURES,
28
+ RE_ATTR_VALUE,
29
+ RE_ATTR_VALUE_SINGLE,
30
+ RE_NORMALIZE_COLOR,
31
+ RE_COLOR_HEX,
32
+ RE_IS_FEATURE,
33
+ RE_KV_MATCH,
34
+ RE_ROOT_MATCH,
35
+ RE_BRACKET_POS,
36
+ RE_IS_WORD_CHAR,
37
+ RE_ATTR_AND_BOOL_VALUE,
38
+ RE_TO_KEBAB,
39
+ RE_OPEN_BRACES,
40
+ RE_CLOSE_BRACES,
41
+ RE_OPEN_BRACKETS,
42
+ RE_CLOSE_BRACKET
43
+ } from './constants.js';
44
+
45
+ import { createElement, getFeatureKey, countBrackets, parseSelectorToHostRule } from './utils.js';
46
+ import { validateGeoJSON, normalizeToFeatures } from './validation.js';
47
+ import { highlightSyntax, namedColorToHex, isNamedColor } from './syntax-highlighter.js';
48
+
49
+ // Re-export public types
50
+ export type { SetOptions, ThemeConfig, ThemeSettings } from './types.js';
51
+
52
+ // Alias for minification
53
+ const _ce = createElement;
175
54
 
176
55
  /**
177
56
  * GeoJSON Editor Web Component
@@ -211,8 +90,8 @@ class GeoJsonEditor extends HTMLElement {
211
90
  selectionEnd: CursorPosition | null = null;
212
91
 
213
92
  // ========== Debounce ==========
214
- private renderTimer: number | null = null;
215
- private inputTimer: number | null = null;
93
+ private renderTimer: ReturnType<typeof setTimeout> | undefined = undefined;
94
+ private inputTimer: ReturnType<typeof setTimeout> | undefined = undefined;
216
95
 
217
96
  // ========== Theme ==========
218
97
  themes: ThemeSettings = { dark: {}, light: {} };
@@ -229,19 +108,38 @@ class GeoJsonEditor extends HTMLElement {
229
108
  private _isSelecting: boolean = false;
230
109
  private _isComposing: boolean = false;
231
110
  private _blockRender: boolean = false;
111
+ private _insertMode: boolean = true; // true = insert, false = overwrite
232
112
  private _charWidth: number | null = null;
233
113
  private _contextMapCache: Map<number, string> | null = null;
234
114
  private _contextMapLinesLength: number = 0;
235
115
  private _contextMapFirstLine: string | undefined = undefined;
236
116
  private _contextMapLastLine: string | undefined = undefined;
237
117
 
118
+ // ========== Cached DOM Elements ==========
119
+ private _viewport: HTMLElement | null = null;
120
+ private _linesContainer: HTMLElement | null = null;
121
+ private _scrollContent: HTMLElement | null = null;
122
+ private _hiddenTextarea: HTMLTextAreaElement | null = null;
123
+ private _gutterContent: HTMLElement | null = null;
124
+ private _gutterScrollContent: HTMLElement | null = null;
125
+ private _gutterScroll: HTMLElement | null = null;
126
+ private _gutter: HTMLElement | null = null;
127
+ private _clearBtn: HTMLButtonElement | null = null;
128
+ private _editorWrapper: HTMLElement | null = null;
129
+ private _placeholderLayer: HTMLElement | null = null;
130
+ private _editorPrefix: HTMLElement | null = null;
131
+ private _editorSuffix: HTMLElement | null = null;
132
+
238
133
  constructor() {
239
134
  super();
240
135
  this.attachShadow({ mode: 'open' });
241
136
  }
242
137
 
138
+ // Alias for shadowRoot.getElementById (minification)
139
+ private _id(id: string) { return this.shadowRoot!.getElementById(id); }
140
+
243
141
  // ========== Render Cache ==========
244
- _invalidateRenderCache() {
142
+ private _invalidateRenderCache() {
245
143
  this._lastStartIndex = -1;
246
144
  this._lastEndIndex = -1;
247
145
  this._lastTotalLines = -1;
@@ -253,7 +151,7 @@ class GeoJsonEditor extends HTMLElement {
253
151
  * Create a snapshot of current editor state
254
152
  * @returns {Object} State snapshot
255
153
  */
256
- _createSnapshot() {
154
+ private _createSnapshot() {
257
155
  return {
258
156
  lines: [...this.lines],
259
157
  cursorLine: this.cursorLine,
@@ -264,9 +162,8 @@ class GeoJsonEditor extends HTMLElement {
264
162
 
265
163
  /**
266
164
  * Restore editor state from snapshot
267
- * @param {Object} snapshot - State to restore
268
165
  */
269
- _restoreSnapshot(snapshot) {
166
+ private _restoreSnapshot(snapshot: EditorSnapshot): void {
270
167
  this.lines = [...snapshot.lines];
271
168
  this.cursorLine = snapshot.cursorLine;
272
169
  this.cursorColumn = snapshot.cursorColumn;
@@ -281,7 +178,7 @@ class GeoJsonEditor extends HTMLElement {
281
178
  * Save current state to undo stack before making changes
282
179
  * @param {string} actionType - Type of action (insert, delete, paste, etc.)
283
180
  */
284
- _saveToHistory(actionType = 'edit') {
181
+ private _saveToHistory(actionType = 'edit') {
285
182
  const now = Date.now();
286
183
  const shouldGroup = (
287
184
  actionType === this._lastActionType &&
@@ -310,7 +207,7 @@ class GeoJsonEditor extends HTMLElement {
310
207
  * Undo last action
311
208
  * @returns {boolean} True if undo was performed
312
209
  */
313
- undo() {
210
+ undo(): boolean {
314
211
  if (this._undoStack.length === 0) return false;
315
212
 
316
213
  // Save current state to redo stack
@@ -318,7 +215,7 @@ class GeoJsonEditor extends HTMLElement {
318
215
 
319
216
  // Restore previous state
320
217
  const previousState = this._undoStack.pop();
321
- this._restoreSnapshot(previousState);
218
+ if (previousState) this._restoreSnapshot(previousState);
322
219
 
323
220
  // Reset action tracking
324
221
  this._lastActionType = null;
@@ -331,7 +228,7 @@ class GeoJsonEditor extends HTMLElement {
331
228
  * Redo previously undone action
332
229
  * @returns {boolean} True if redo was performed
333
230
  */
334
- redo() {
231
+ redo(): boolean {
335
232
  if (this._redoStack.length === 0) return false;
336
233
 
337
234
  // Save current state to undo stack
@@ -339,7 +236,7 @@ class GeoJsonEditor extends HTMLElement {
339
236
 
340
237
  // Restore next state
341
238
  const nextState = this._redoStack.pop();
342
- this._restoreSnapshot(nextState);
239
+ if (nextState) this._restoreSnapshot(nextState);
343
240
 
344
241
  // Reset action tracking
345
242
  this._lastActionType = null;
@@ -351,7 +248,7 @@ class GeoJsonEditor extends HTMLElement {
351
248
  /**
352
249
  * Clear undo/redo history
353
250
  */
354
- clearHistory() {
251
+ clearHistory(): void {
355
252
  this._undoStack = [];
356
253
  this._redoStack = [];
357
254
  this._lastActionType = null;
@@ -362,7 +259,7 @@ class GeoJsonEditor extends HTMLElement {
362
259
  * Check if undo is available
363
260
  * @returns {boolean}
364
261
  */
365
- canUndo() {
262
+ canUndo(): boolean {
366
263
  return this._undoStack.length > 0;
367
264
  }
368
265
 
@@ -370,21 +267,19 @@ class GeoJsonEditor extends HTMLElement {
370
267
  * Check if redo is available
371
268
  * @returns {boolean}
372
269
  */
373
- canRedo() {
270
+ canRedo(): boolean {
374
271
  return this._redoStack.length > 0;
375
272
  }
376
273
 
377
274
  // ========== Unique ID Generation ==========
378
- _generateNodeId() {
275
+ private _generateNodeId() {
379
276
  return `node_${++this._nodeIdCounter}`;
380
277
  }
381
278
 
382
279
  /**
383
280
  * Check if a line is inside a collapsed node (hidden lines between opening and closing)
384
- * @param {number} lineIndex - The line index to check
385
- * @returns {Object|null} - The collapsed range info or null
386
281
  */
387
- _getCollapsedRangeForLine(lineIndex) {
282
+ private _getCollapsedRangeForLine(lineIndex: number): CollapsedNodeInfo | null {
388
283
  for (const [nodeId, info] of this._nodeIdToLines) {
389
284
  // Lines strictly between opening and closing are hidden
390
285
  if (this.collapsedNodes.has(nodeId) && lineIndex > info.startLine && lineIndex < info.endLine) {
@@ -396,10 +291,8 @@ class GeoJsonEditor extends HTMLElement {
396
291
 
397
292
  /**
398
293
  * Check if cursor is on the closing line of a collapsed node
399
- * @param {number} lineIndex - The line index to check
400
- * @returns {Object|null} - The collapsed range info or null
401
294
  */
402
- _getCollapsedClosingLine(lineIndex) {
295
+ private _getCollapsedClosingLine(lineIndex: number): CollapsedNodeInfo | null {
403
296
  for (const [nodeId, info] of this._nodeIdToLines) {
404
297
  if (this.collapsedNodes.has(nodeId) && lineIndex === info.endLine) {
405
298
  return { nodeId, ...info };
@@ -410,10 +303,8 @@ class GeoJsonEditor extends HTMLElement {
410
303
 
411
304
  /**
412
305
  * Get the position of the closing bracket on a line
413
- * @param {string} line - The line content
414
- * @returns {number} - Position of bracket or -1
415
306
  */
416
- _getClosingBracketPos(line) {
307
+ private _getClosingBracketPos(line: string): number {
417
308
  // Find the last ] or } on the line
418
309
  const lastBracket = Math.max(line.lastIndexOf(']'), line.lastIndexOf('}'));
419
310
  return lastBracket;
@@ -421,29 +312,12 @@ class GeoJsonEditor extends HTMLElement {
421
312
 
422
313
  /**
423
314
  * Check if cursor is on the opening line of a collapsed node
424
- * @param {number} lineIndex - The line index to check
425
- * @returns {Object|null} - The collapsed range info or null
426
315
  */
427
- _getCollapsedNodeAtLine(lineIndex) {
316
+ private _getCollapsedNodeAtLine(lineIndex: number): CollapsedNodeInfo | null {
428
317
  const nodeId = this._lineToNodeId.get(lineIndex);
429
318
  if (nodeId && this.collapsedNodes.has(nodeId)) {
430
319
  const info = this._nodeIdToLines.get(nodeId);
431
- return { nodeId, ...info };
432
- }
433
- return null;
434
- }
435
-
436
- /**
437
- * Check if cursor is on a line that has a collapsible node (expanded or collapsed)
438
- * @param {number} lineIndex - The line index to check
439
- * @returns {Object|null} - The node info with isCollapsed flag or null
440
- */
441
- _getCollapsibleNodeAtLine(lineIndex) {
442
- const nodeId = this._lineToNodeId.get(lineIndex);
443
- if (nodeId) {
444
- const info = this._nodeIdToLines.get(nodeId);
445
- const isCollapsed = this.collapsedNodes.has(nodeId);
446
- return { nodeId, isCollapsed, ...info };
320
+ if (info) return { nodeId, ...info };
447
321
  }
448
322
  return null;
449
323
  }
@@ -451,16 +325,14 @@ class GeoJsonEditor extends HTMLElement {
451
325
  /**
452
326
  * Find the innermost expanded node that contains the given line
453
327
  * Used for Shift+Tab to collapse the parent node from anywhere inside it
454
- * @param {number} lineIndex - The line index to check
455
- * @returns {Object|null} - The containing node info or null
456
328
  */
457
- _getContainingExpandedNode(lineIndex) {
458
- let bestMatch = null;
459
-
329
+ private _getContainingExpandedNode(lineIndex: number): CollapsedNodeInfo | null {
330
+ let bestMatch: CollapsedNodeInfo | null = null;
331
+
460
332
  for (const [nodeId, info] of this._nodeIdToLines) {
461
333
  // Skip collapsed nodes
462
334
  if (this.collapsedNodes.has(nodeId)) continue;
463
-
335
+
464
336
  // Check if line is within this node's range
465
337
  if (lineIndex >= info.startLine && lineIndex <= info.endLine) {
466
338
  // Prefer the innermost (smallest) containing node
@@ -469,15 +341,14 @@ class GeoJsonEditor extends HTMLElement {
469
341
  }
470
342
  }
471
343
  }
472
-
344
+
473
345
  return bestMatch;
474
346
  }
475
347
 
476
348
  /**
477
349
  * Delete an entire collapsed node (opening line to closing line)
478
- * @param {Object} range - The range info {startLine, endLine}
479
350
  */
480
- _deleteCollapsedNode(range) {
351
+ private _deleteCollapsedNode(range: CollapsedNodeInfo): void {
481
352
  this._saveToHistory('delete');
482
353
 
483
354
  // Remove all lines from startLine to endLine
@@ -495,7 +366,7 @@ class GeoJsonEditor extends HTMLElement {
495
366
  * Rebuild nodeId mappings after content changes
496
367
  * Preserves collapsed state by matching nodeKey + sequential occurrence
497
368
  */
498
- _rebuildNodeIdMappings() {
369
+ private _rebuildNodeIdMappings() {
499
370
  // Save old state to try to preserve collapsed nodes
500
371
  const oldCollapsed = new Set(this.collapsedNodes);
501
372
  const oldNodeKeyMap = new Map(); // nodeId -> nodeKey
@@ -524,26 +395,29 @@ class GeoJsonEditor extends HTMLElement {
524
395
  const line = this.lines[i];
525
396
 
526
397
  // Match "key": { or "key": [
527
- const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
398
+ const kvMatch = line.match(RE_KV_MATCH);
528
399
  // Also match standalone { or {, (root Feature objects)
529
- const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
400
+ const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
530
401
 
531
402
  if (!kvMatch && !rootMatch) continue;
532
-
533
- let nodeKey, openBracket;
534
-
403
+
404
+ let nodeKey: string;
405
+ let openBracket: string;
406
+
535
407
  if (kvMatch) {
536
408
  nodeKey = kvMatch[1];
537
409
  openBracket = kvMatch[2];
538
- } else {
410
+ } else if (rootMatch) {
539
411
  // Root object - use special key based on line number and bracket type
540
412
  openBracket = rootMatch[1];
541
413
  nodeKey = `__root_${openBracket}_${i}`;
414
+ } else {
415
+ continue;
542
416
  }
543
417
 
544
418
  // Check if closes on same line
545
419
  const rest = line.substring(line.indexOf(openBracket) + 1);
546
- const counts = this._countBrackets(rest, openBracket);
420
+ const counts = countBrackets(rest, openBracket);
547
421
  if (counts.close > counts.open) continue;
548
422
 
549
423
  const endLine = this._findClosingLine(i, openBracket);
@@ -577,10 +451,11 @@ class GeoJsonEditor extends HTMLElement {
577
451
  // ========== Lifecycle ==========
578
452
  connectedCallback() {
579
453
  this.render();
454
+ this._cacheElements();
580
455
  this.setupEventListeners();
581
456
  this.updatePrefixSuffix();
582
457
  this.updateThemeCSS();
583
-
458
+
584
459
  if (this.value) {
585
460
  this.setValue(this.value);
586
461
  }
@@ -601,7 +476,7 @@ class GeoJsonEditor extends HTMLElement {
601
476
  }
602
477
  }
603
478
 
604
- attributeChangedCallback(name, oldValue, newValue) {
479
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
605
480
  if (oldValue === newValue) return;
606
481
 
607
482
  switch (name) {
@@ -629,27 +504,48 @@ class GeoJsonEditor extends HTMLElement {
629
504
 
630
505
  // ========== Initial Render ==========
631
506
  render() {
632
- const styleEl = document.createElement('style');
507
+ const shadowRoot = this.shadowRoot!;
508
+ const styleEl = _ce('style');
633
509
  styleEl.textContent = styles;
634
-
635
- const template = document.createElement('div');
510
+
511
+ const template = _ce('div');
636
512
  template.innerHTML = getTemplate(this.placeholder, VERSION);
637
-
638
- this.shadowRoot.innerHTML = '';
639
- this.shadowRoot.appendChild(styleEl);
513
+
514
+ shadowRoot.innerHTML = '';
515
+ shadowRoot.appendChild(styleEl);
640
516
  while (template.firstChild) {
641
- this.shadowRoot.appendChild(template.firstChild);
517
+ shadowRoot.appendChild(template.firstChild);
642
518
  }
643
519
  }
644
520
 
521
+ // ========== DOM Element Cache ==========
522
+ private _cacheElements() {
523
+ this._viewport = this._id('viewport');
524
+ this._linesContainer = this._id('linesContainer');
525
+ this._scrollContent = this._id('scrollContent');
526
+ this._hiddenTextarea = this._id('hiddenTextarea') as HTMLTextAreaElement;
527
+ this._gutterContent = this._id('gutterContent');
528
+ this._gutterScrollContent = this._id('gutterScrollContent');
529
+ this._gutterScroll = this._id('gutterScroll');
530
+ this._gutter = this.shadowRoot!.querySelector('.gutter');
531
+ this._clearBtn = this._id('clearBtn') as HTMLButtonElement;
532
+ this._editorWrapper = this.shadowRoot!.querySelector('.editor-wrapper');
533
+ this._placeholderLayer = this._id('placeholderLayer');
534
+ this._editorPrefix = this._id('editorPrefix');
535
+ this._editorSuffix = this._id('editorSuffix');
536
+ }
537
+
645
538
  // ========== Event Listeners ==========
646
539
  setupEventListeners() {
647
- const hiddenTextarea = this.shadowRoot.getElementById('hiddenTextarea');
648
- const viewport = this.shadowRoot.getElementById('viewport');
649
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
650
- const gutter = this.shadowRoot.querySelector('.gutter');
651
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
652
- const editorWrapper = this.shadowRoot.querySelector('.editor-wrapper');
540
+ const hiddenTextarea = this._hiddenTextarea;
541
+ const viewport = this._viewport;
542
+ const gutterContent = this._gutterContent;
543
+ const gutter = this._gutter;
544
+ const clearBtn = this._clearBtn;
545
+ const editorWrapper = this._editorWrapper;
546
+
547
+ // Guard: all elements must exist
548
+ if (!hiddenTextarea || !viewport || !gutterContent || !gutter || !clearBtn || !editorWrapper) return;
653
549
 
654
550
  // Mouse selection state
655
551
  this._isSelecting = false;
@@ -658,7 +554,7 @@ class GeoJsonEditor extends HTMLElement {
658
554
  // Editor inline control clicks (color swatches, checkboxes, visibility icons)
659
555
  // Use capture phase to intercept before mousedown
660
556
  viewport.addEventListener('click', (e) => {
661
- this.handleEditorClick(e);
557
+ this.handleEditorClick(e as MouseEvent);
662
558
  }, true);
663
559
 
664
560
  viewport.addEventListener('mousedown', (e: MouseEvent) => {
@@ -687,13 +583,13 @@ class GeoJsonEditor extends HTMLElement {
687
583
  return;
688
584
  }
689
585
  }
690
-
586
+
691
587
  // Prevent default to avoid losing focus after click
692
588
  e.preventDefault();
693
-
589
+
694
590
  // Calculate click position
695
591
  const pos = this._getPositionFromClick(e);
696
-
592
+
697
593
  if (e.shiftKey && this.selectionStart) {
698
594
  // Shift+click: extend selection
699
595
  this.selectionEnd = pos;
@@ -707,7 +603,7 @@ class GeoJsonEditor extends HTMLElement {
707
603
  this.selectionEnd = null;
708
604
  this._isSelecting = true;
709
605
  }
710
-
606
+
711
607
  // Focus textarea
712
608
  hiddenTextarea.focus();
713
609
  this._invalidateRenderCache();
@@ -715,19 +611,18 @@ class GeoJsonEditor extends HTMLElement {
715
611
  });
716
612
 
717
613
  // Mouse move for drag selection
718
- viewport.addEventListener('mousemove', (e) => {
614
+ viewport.addEventListener('mousemove', (e: MouseEvent) => {
719
615
  if (!this._isSelecting) return;
720
-
721
616
  const pos = this._getPositionFromClick(e);
722
617
  this.selectionEnd = pos;
723
618
  this.cursorLine = pos.line;
724
619
  this.cursorColumn = pos.column;
725
-
620
+
726
621
  // Auto-scroll when near edges
727
622
  const rect = viewport.getBoundingClientRect();
728
623
  const scrollMargin = 30; // pixels from edge to start scrolling
729
624
  const scrollSpeed = 20; // pixels to scroll per frame
730
-
625
+
731
626
  if (e.clientY < rect.top + scrollMargin) {
732
627
  // Near top edge, scroll up
733
628
  viewport.scrollTop -= scrollSpeed;
@@ -735,7 +630,7 @@ class GeoJsonEditor extends HTMLElement {
735
630
  // Near bottom edge, scroll down
736
631
  viewport.scrollTop += scrollSpeed;
737
632
  }
738
-
633
+
739
634
  this._invalidateRenderCache();
740
635
  this.scheduleRender();
741
636
  });
@@ -763,7 +658,7 @@ class GeoJsonEditor extends HTMLElement {
763
658
  viewport.addEventListener('scroll', () => {
764
659
  if (isRendering) return;
765
660
  this.syncGutterScroll();
766
-
661
+
767
662
  // Use requestAnimationFrame to batch scroll updates
768
663
  if (!this._scrollRaf) {
769
664
  this._scrollRaf = requestAnimationFrame(() => {
@@ -794,38 +689,38 @@ class GeoJsonEditor extends HTMLElement {
794
689
  });
795
690
 
796
691
  hiddenTextarea.addEventListener('keydown', (e) => {
797
- this.handleKeydown(e);
692
+ this.handleKeydown(e as KeyboardEvent);
798
693
  });
799
694
 
800
695
  // Paste handling
801
696
  hiddenTextarea.addEventListener('paste', (e) => {
802
- this.handlePaste(e);
697
+ this.handlePaste(e as ClipboardEvent);
803
698
  });
804
699
 
805
700
  // Copy handling
806
701
  hiddenTextarea.addEventListener('copy', (e) => {
807
- this.handleCopy(e);
702
+ this.handleCopy(e as ClipboardEvent);
808
703
  });
809
704
 
810
705
  // Cut handling
811
706
  hiddenTextarea.addEventListener('cut', (e) => {
812
- this.handleCut(e);
707
+ this.handleCut(e as ClipboardEvent);
813
708
  });
814
709
 
815
710
  // Gutter interactions
816
711
  gutterContent.addEventListener('click', (e) => {
817
- this.handleGutterClick(e);
712
+ this.handleGutterClick(e as MouseEvent);
818
713
  });
819
-
714
+
820
715
  // Prevent gutter from stealing focus
821
716
  gutter.addEventListener('mousedown', (e) => {
822
717
  e.preventDefault();
823
718
  });
824
719
 
825
720
  // Wheel on gutter -> scroll viewport
826
- gutter.addEventListener('wheel', (e: WheelEvent) => {
721
+ gutter.addEventListener('wheel', (e) => {
827
722
  e.preventDefault();
828
- viewport.scrollTop += e.deltaY;
723
+ viewport.scrollTop += (e as WheelEvent).deltaY;
829
724
  });
830
725
 
831
726
  // Clear button
@@ -842,7 +737,7 @@ class GeoJsonEditor extends HTMLElement {
842
737
  /**
843
738
  * Set the editor content from a string value
844
739
  */
845
- setValue(value, autoCollapse = true) {
740
+ setValue(value: string | null, autoCollapse = true): void {
846
741
  // Save to history only if there's existing content
847
742
  if (this.lines.length > 0) {
848
743
  this._saveToHistory('setValue');
@@ -941,7 +836,7 @@ class GeoJsonEditor extends HTMLElement {
941
836
  for (let i = 0; i < this.lines.length; i++) {
942
837
  const line = this.lines[i];
943
838
 
944
- if (!inFeature && /"type"\s*:\s*"Feature"/.test(line)) {
839
+ if (!inFeature && RE_IS_FEATURE.test(line)) {
945
840
  // Find opening brace
946
841
  let startLine = i;
947
842
  for (let j = i; j >= 0; j--) {
@@ -957,7 +852,7 @@ class GeoJsonEditor extends HTMLElement {
957
852
 
958
853
  // Count braces from start to current line
959
854
  for (let k = startLine; k <= i; k++) {
960
- const counts = this._countBrackets(this.lines[k], '{');
855
+ const counts = countBrackets(this.lines[k], '{');
961
856
  if (k === startLine) {
962
857
  braceDepth += (counts.open - 1) - counts.close;
963
858
  } else {
@@ -966,10 +861,10 @@ class GeoJsonEditor extends HTMLElement {
966
861
  }
967
862
 
968
863
  if (featureIndex < parsed.features.length) {
969
- currentFeatureKey = this._getFeatureKey(parsed.features[featureIndex]);
864
+ currentFeatureKey = getFeatureKey(parsed.features[featureIndex]);
970
865
  }
971
866
  } else if (inFeature) {
972
- const counts = this._countBrackets(line, '{');
867
+ const counts = countBrackets(line, '{');
973
868
  braceDepth += counts.open - counts.close;
974
869
 
975
870
  if (braceDepth <= 0) {
@@ -1001,7 +896,7 @@ class GeoJsonEditor extends HTMLElement {
1001
896
 
1002
897
  for (let i = 0; i < this.lines.length; i++) {
1003
898
  const line = this.lines[i];
1004
- const meta = {
899
+ const meta: LineMeta = {
1005
900
  colors: [],
1006
901
  booleans: [],
1007
902
  collapseButton: null,
@@ -1011,18 +906,24 @@ class GeoJsonEditor extends HTMLElement {
1011
906
  featureKey: null
1012
907
  };
1013
908
 
1014
- // Detect colors
1015
- const colorRegex = /"([\w-]+)"\s*:\s*"(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))"/g;
1016
- let colorMatch;
1017
- while ((colorMatch = colorRegex.exec(line)) !== null) {
1018
- meta.colors.push({ attributeName: colorMatch[1], color: colorMatch[2] });
1019
- }
1020
-
1021
- // Detect booleans
1022
- const boolRegex = /"([\w-]+)"\s*:\s*(true|false)/g;
1023
- let boolMatch;
1024
- while ((boolMatch = boolRegex.exec(line)) !== null) {
1025
- meta.booleans.push({ attributeName: boolMatch[1], value: boolMatch[2] === 'true' });
909
+ // Detect colors and booleans in a single pass
910
+ RE_ATTR_VALUE.lastIndex = 0;
911
+ let match: RegExpExecArray | null;
912
+ while ((match = RE_ATTR_VALUE.exec(line)) !== null) {
913
+ const [, attributeName, strValue, boolValue] = match;
914
+ if (boolValue) {
915
+ // Boolean value
916
+ meta.booleans.push({ attributeName, value: boolValue === 'true' });
917
+ } else if (strValue) {
918
+ // String value - check if it's a color
919
+ if (RE_COLOR_HEX.test(strValue)) {
920
+ // Hex color (#fff or #ffffff)
921
+ meta.colors.push({ attributeName, color: strValue });
922
+ } else if (isNamedColor(strValue)) {
923
+ // Named CSS color (red, blue, etc.) - validated via browser
924
+ meta.colors.push({ attributeName, color: strValue });
925
+ }
926
+ }
1026
927
  }
1027
928
 
1028
929
  // Check if line starts a collapsible node
@@ -1084,8 +985,6 @@ class GeoJsonEditor extends HTMLElement {
1084
985
 
1085
986
  // Reset render cache to force re-render
1086
987
  this._invalidateRenderCache();
1087
- this._lastEndIndex = -1;
1088
- this._lastTotalLines = -1;
1089
988
  }
1090
989
 
1091
990
  // ========== Rendering ==========
@@ -1093,7 +992,7 @@ class GeoJsonEditor extends HTMLElement {
1093
992
  scheduleRender() {
1094
993
  if (this.renderTimer) return;
1095
994
  this.renderTimer = requestAnimationFrame(() => {
1096
- this.renderTimer = null;
995
+ this.renderTimer = undefined;
1097
996
  this.renderViewport();
1098
997
  });
1099
998
  }
@@ -1103,10 +1002,9 @@ class GeoJsonEditor extends HTMLElement {
1103
1002
  if (this._blockRender) {
1104
1003
  return;
1105
1004
  }
1106
- const viewport = this.shadowRoot.getElementById('viewport');
1107
- const linesContainer = this.shadowRoot.getElementById('linesContainer');
1108
- const scrollContent = this.shadowRoot.getElementById('scrollContent');
1109
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
1005
+ const viewport = this._viewport;
1006
+ const linesContainer = this._linesContainer;
1007
+ const scrollContent = this._scrollContent;
1110
1008
 
1111
1009
  if (!viewport || !linesContainer) return;
1112
1010
 
@@ -1142,17 +1040,16 @@ class GeoJsonEditor extends HTMLElement {
1142
1040
 
1143
1041
  // Build context map for syntax highlighting
1144
1042
  const contextMap = this._buildContextMap();
1145
-
1043
+
1146
1044
  // Check if editor is focused (for cursor display)
1147
- const editorWrapper = this.shadowRoot.querySelector('.editor-wrapper');
1148
- const isFocused = editorWrapper?.classList.contains('focused');
1045
+ const isFocused = this._editorWrapper?.classList.contains('focused');
1149
1046
 
1150
1047
  // Render visible lines
1151
1048
  const fragment = document.createDocumentFragment();
1152
1049
 
1153
1050
  // Handle empty editor: render an empty line with cursor
1154
1051
  if (totalLines === 0) {
1155
- const lineEl = document.createElement('div');
1052
+ const lineEl = _ce('div');
1156
1053
  lineEl.className = 'line empty-line';
1157
1054
  lineEl.dataset.lineIndex = '0';
1158
1055
  if (isFocused) {
@@ -1169,7 +1066,7 @@ class GeoJsonEditor extends HTMLElement {
1169
1066
  const lineData = this.visibleLines[i];
1170
1067
  if (!lineData) continue;
1171
1068
 
1172
- const lineEl = document.createElement('div');
1069
+ const lineEl = _ce('div');
1173
1070
  lineEl.className = 'line';
1174
1071
  lineEl.dataset.lineIndex = String(lineData.index);
1175
1072
 
@@ -1188,8 +1085,8 @@ class GeoJsonEditor extends HTMLElement {
1188
1085
  }
1189
1086
 
1190
1087
  // Highlight syntax and add cursor if this is the cursor line and editor is focused
1191
- const context = contextMap.get(lineData.index);
1192
- let html = this._highlightSyntax(lineData.content, context, lineData.meta);
1088
+ const context = contextMap.get(lineData.index) || 'Feature';
1089
+ let html = highlightSyntax(lineData.content, context, lineData.meta);
1193
1090
 
1194
1091
  // Add selection highlight if line is in selection
1195
1092
  if (isFocused && this._hasSelection()) {
@@ -1216,20 +1113,27 @@ class GeoJsonEditor extends HTMLElement {
1216
1113
  /**
1217
1114
  * Insert cursor element at the specified column position
1218
1115
  * Uses absolute positioning to avoid affecting text layout
1116
+ * In overwrite mode, cursor is a block covering the next character
1219
1117
  */
1220
- _insertCursor(column) {
1221
- // Calculate cursor position in pixels using character width
1118
+ private _insertCursor(column: number): string {
1222
1119
  const charWidth = this._getCharWidth();
1223
1120
  const left = column * charWidth;
1224
- return `<span class="cursor" style="left: ${left}px"></span>`;
1121
+ if (this._insertMode) {
1122
+ // Insert mode: thin line cursor
1123
+ return `<span class="cursor" style="left: ${left}px"></span>`;
1124
+ } else {
1125
+ // Overwrite mode: block cursor covering the character
1126
+ return `<span class="cursor cursor-block" style="left: ${left}px; width: ${charWidth}px"></span>`;
1127
+ }
1225
1128
  }
1226
1129
 
1227
1130
  /**
1228
1131
  * Add selection highlight to a line
1229
1132
  */
1230
- _addSelectionHighlight(html, lineIndex, content) {
1231
- const { start, end } = this._normalizeSelection();
1232
- if (!start || !end) return html;
1133
+ private _addSelectionHighlight(html: string, lineIndex: number, content: string): string {
1134
+ const sel = this._normalizeSelection();
1135
+ if (!sel) return html;
1136
+ const { start, end } = sel;
1233
1137
 
1234
1138
  // Check if this line is in the selection
1235
1139
  if (lineIndex < start.line || lineIndex > end.line) return html;
@@ -1266,20 +1170,25 @@ class GeoJsonEditor extends HTMLElement {
1266
1170
  /**
1267
1171
  * Get character width for monospace font
1268
1172
  */
1269
- _getCharWidth() {
1173
+ private _getCharWidth(): number {
1270
1174
  if (!this._charWidth) {
1271
- const canvas = document.createElement('canvas');
1175
+ const canvas = _ce('canvas') as HTMLCanvasElement;
1272
1176
  const ctx = canvas.getContext('2d');
1273
- // Use exact same font as CSS: 'Courier New', Courier, monospace at 13px
1274
- ctx.font = "13px 'Courier New', Courier, monospace";
1275
- this._charWidth = ctx.measureText('M').width;
1177
+ if (ctx) {
1178
+ // Use exact same font as CSS: 'Courier New', Courier, monospace at 13px
1179
+ ctx.font = "13px 'Courier New', Courier, monospace";
1180
+ this._charWidth = ctx.measureText('M').width;
1181
+ } else {
1182
+ // Fallback to approximate monospace character width
1183
+ this._charWidth = 7.8;
1184
+ }
1276
1185
  }
1277
1186
  return this._charWidth;
1278
1187
  }
1279
1188
 
1280
- renderGutter(startIndex, endIndex) {
1281
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
1282
- const gutterScrollContent = this.shadowRoot.getElementById('gutterScrollContent');
1189
+ renderGutter(startIndex: number, endIndex: number): void {
1190
+ const gutterContent = this._gutterContent;
1191
+ const gutterScrollContent = this._gutterScrollContent;
1283
1192
  if (!gutterContent) return;
1284
1193
 
1285
1194
  // Set total height for gutter scroll
@@ -1298,22 +1207,22 @@ class GeoJsonEditor extends HTMLElement {
1298
1207
  const lineData = this.visibleLines[i];
1299
1208
  if (!lineData) continue;
1300
1209
 
1301
- const gutterLine = document.createElement('div');
1210
+ const gutterLine = _ce('div');
1302
1211
  gutterLine.className = 'gutter-line';
1303
1212
 
1304
1213
  const meta = lineData.meta;
1305
1214
 
1306
1215
  // Line number first
1307
- const lineNum = document.createElement('span');
1216
+ const lineNum = _ce('span');
1308
1217
  lineNum.className = 'line-number';
1309
1218
  lineNum.textContent = String(lineData.index + 1);
1310
1219
  gutterLine.appendChild(lineNum);
1311
1220
 
1312
1221
  // Collapse column (always present for alignment)
1313
- const collapseCol = document.createElement('div');
1222
+ const collapseCol = _ce('div');
1314
1223
  collapseCol.className = 'collapse-column';
1315
1224
  if (meta?.collapseButton) {
1316
- const btn = document.createElement('div');
1225
+ const btn = _ce('div');
1317
1226
  btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
1318
1227
  btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
1319
1228
  btn.dataset.line = String(lineData.index);
@@ -1331,28 +1240,31 @@ class GeoJsonEditor extends HTMLElement {
1331
1240
  }
1332
1241
 
1333
1242
  syncGutterScroll() {
1334
- const gutterScroll = this.shadowRoot.getElementById('gutterScroll');
1335
- const viewport = this.shadowRoot.getElementById('viewport');
1336
- if (gutterScroll && viewport) {
1243
+ if (this._gutterScroll && this._viewport) {
1337
1244
  // Sync gutter scroll position with viewport
1338
- gutterScroll.scrollTop = viewport.scrollTop;
1245
+ this._gutterScroll.scrollTop = this._viewport.scrollTop;
1339
1246
  }
1340
1247
  }
1341
1248
 
1342
1249
  // ========== Input Handling ==========
1343
1250
 
1344
1251
  handleInput(): void {
1345
- const textarea = this.shadowRoot!.getElementById('hiddenTextarea') as HTMLTextAreaElement;
1346
- const inputValue = textarea.value;
1347
-
1252
+ const textarea = this._hiddenTextarea;
1253
+ const inputValue = textarea?.value;
1254
+
1348
1255
  if (!inputValue) return;
1349
-
1256
+
1257
+ // Delete selection first if any (replace selection with input)
1258
+ if (this._hasSelection()) {
1259
+ this._deleteSelection();
1260
+ }
1261
+
1350
1262
  // Block input in hidden collapsed zones
1351
1263
  if (this._getCollapsedRangeForLine(this.cursorLine)) {
1352
1264
  textarea.value = '';
1353
1265
  return;
1354
1266
  }
1355
-
1267
+
1356
1268
  // On closing line, only allow after bracket
1357
1269
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1358
1270
  if (onClosingLine) {
@@ -1363,31 +1275,40 @@ class GeoJsonEditor extends HTMLElement {
1363
1275
  return;
1364
1276
  }
1365
1277
  }
1366
-
1278
+
1367
1279
  // On collapsed opening line, only allow before bracket
1368
1280
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1369
1281
  if (onCollapsed) {
1370
1282
  const line = this.lines[this.cursorLine];
1371
- const bracketPos = line.search(/[{\[]/);
1283
+ const bracketPos = line.search(RE_BRACKET_POS);
1372
1284
  if (this.cursorColumn > bracketPos) {
1373
1285
  textarea.value = '';
1374
1286
  return;
1375
1287
  }
1376
1288
  }
1377
-
1378
- // Insert the input at cursor position
1289
+
1290
+ // Insert or overwrite the input at cursor position
1379
1291
  if (this.cursorLine < this.lines.length) {
1380
1292
  const line = this.lines[this.cursorLine];
1381
1293
  const before = line.substring(0, this.cursorColumn);
1382
- const after = line.substring(this.cursorColumn);
1383
-
1294
+
1384
1295
  // Handle newlines in input
1385
1296
  const inputLines = inputValue.split('\n');
1386
1297
  if (inputLines.length === 1) {
1387
- this.lines[this.cursorLine] = before + inputValue + after;
1298
+ // Single line input: insert or overwrite mode
1299
+ if (this._insertMode) {
1300
+ // Insert mode: keep text after cursor
1301
+ const after = line.substring(this.cursorColumn);
1302
+ this.lines[this.cursorLine] = before + inputValue + after;
1303
+ } else {
1304
+ // Overwrite mode: replace characters after cursor
1305
+ const after = line.substring(this.cursorColumn + inputValue.length);
1306
+ this.lines[this.cursorLine] = before + inputValue + after;
1307
+ }
1388
1308
  this.cursorColumn += inputValue.length;
1389
1309
  } else {
1390
- // Multi-line input
1310
+ // Multi-line input: always insert mode
1311
+ const after = line.substring(this.cursorColumn);
1391
1312
  this.lines[this.cursorLine] = before + inputLines[0];
1392
1313
  for (let i = 1; i < inputLines.length - 1; i++) {
1393
1314
  this.lines.splice(this.cursorLine + i, 0, inputLines[i]);
@@ -1415,17 +1336,17 @@ class GeoJsonEditor extends HTMLElement {
1415
1336
  }, 150);
1416
1337
  }
1417
1338
 
1418
- handleKeydown(e) {
1339
+ handleKeydown(e: KeyboardEvent): void {
1419
1340
  // Build context for collapsed zone detection
1420
- const ctx = {
1341
+ const ctx: CollapsedZoneContext = {
1421
1342
  inCollapsedZone: this._getCollapsedRangeForLine(this.cursorLine),
1422
1343
  onCollapsedNode: this._getCollapsedNodeAtLine(this.cursorLine),
1423
1344
  onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
1424
1345
  };
1425
1346
 
1426
1347
  // Lookup table for key handlers
1427
- const keyHandlers = {
1428
- 'Enter': () => this._handleEnter(ctx),
1348
+ const keyHandlers: Record<string, () => void> = {
1349
+ 'Enter': () => this._handleEnter(e.shiftKey, ctx),
1429
1350
  'Backspace': () => this._handleBackspace(ctx),
1430
1351
  'Delete': () => this._handleDelete(ctx),
1431
1352
  'ArrowUp': () => this._handleArrowKey(-1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
@@ -1434,11 +1355,12 @@ class GeoJsonEditor extends HTMLElement {
1434
1355
  'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1435
1356
  'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1436
1357
  'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1437
- 'Tab': () => this._handleTab(e.shiftKey, ctx)
1358
+ 'Tab': () => this._handleTab(e.shiftKey, ctx),
1359
+ 'Insert': () => { this._insertMode = !this._insertMode; this.scheduleRender(); }
1438
1360
  };
1439
1361
 
1440
1362
  // Modifier key handlers (Ctrl/Cmd)
1441
- const modifierHandlers = {
1363
+ const modifierHandlers: Record<string, () => void | boolean | Promise<boolean>> = {
1442
1364
  'a': () => this._selectAll(),
1443
1365
  'z': () => e.shiftKey ? this.redo() : this.undo(),
1444
1366
  'y': () => this.redo(),
@@ -1460,21 +1382,47 @@ class GeoJsonEditor extends HTMLElement {
1460
1382
  }
1461
1383
  }
1462
1384
 
1463
- _handleEnter(ctx) {
1464
- // Block in collapsed zones
1465
- if (ctx.onCollapsedNode || ctx.inCollapsedZone) return;
1466
- // On closing line, before bracket -> block
1385
+ private _handleEnter(isShiftKey: boolean, ctx: CollapsedZoneContext): void {
1386
+ // Shift+Enter: collapse the containing expanded node
1387
+ if (isShiftKey) {
1388
+ const containingNode = this._getContainingExpandedNode(this.cursorLine);
1389
+ if (containingNode) {
1390
+ const startLine = this.lines[containingNode.startLine];
1391
+ const bracketPos = startLine.search(RE_BRACKET_POS);
1392
+ this.toggleCollapse(containingNode.nodeId);
1393
+ this.cursorLine = containingNode.startLine;
1394
+ this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
1395
+ this._clearSelection();
1396
+ this._scrollToCursor();
1397
+ }
1398
+ return;
1399
+ }
1400
+
1401
+ // Enter on collapsed node: expand it
1402
+ if (ctx.onCollapsedNode) {
1403
+ this.toggleCollapse(ctx.onCollapsedNode.nodeId);
1404
+ return;
1405
+ }
1406
+
1407
+ // Enter on closing line of collapsed node: expand it
1467
1408
  if (ctx.onClosingLine) {
1468
1409
  const line = this.lines[this.cursorLine];
1469
1410
  const bracketPos = this._getClosingBracketPos(line);
1411
+ // If cursor is before or on bracket, expand
1470
1412
  if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
1413
+ this.toggleCollapse(ctx.onClosingLine.nodeId);
1471
1414
  return;
1472
1415
  }
1473
1416
  }
1417
+
1418
+ // Block in collapsed zones
1419
+ if (ctx.inCollapsedZone) return;
1420
+
1421
+ // Normal Enter: insert newline
1474
1422
  this.insertNewline();
1475
1423
  }
1476
1424
 
1477
- _handleBackspace(ctx) {
1425
+ private _handleBackspace(ctx: CollapsedZoneContext): void {
1478
1426
  // Delete selection if any
1479
1427
  if (this._hasSelection()) {
1480
1428
  this._deleteSelection();
@@ -1502,7 +1450,7 @@ class GeoJsonEditor extends HTMLElement {
1502
1450
  // On opening line, allow editing before bracket
1503
1451
  if (ctx.onCollapsedNode) {
1504
1452
  const line = this.lines[this.cursorLine];
1505
- const bracketPos = line.search(/[{\[]/);
1453
+ const bracketPos = line.search(RE_BRACKET_POS);
1506
1454
  if (this.cursorColumn > bracketPos + 1) {
1507
1455
  this._deleteCollapsedNode(ctx.onCollapsedNode);
1508
1456
  return;
@@ -1511,7 +1459,7 @@ class GeoJsonEditor extends HTMLElement {
1511
1459
  this.deleteBackward();
1512
1460
  }
1513
1461
 
1514
- _handleDelete(ctx) {
1462
+ private _handleDelete(ctx: CollapsedZoneContext): void {
1515
1463
  // Delete selection if any
1516
1464
  if (this._hasSelection()) {
1517
1465
  this._deleteSelection();
@@ -1532,7 +1480,7 @@ class GeoJsonEditor extends HTMLElement {
1532
1480
  // If on collapsed node opening line
1533
1481
  if (ctx.onCollapsedNode) {
1534
1482
  const line = this.lines[this.cursorLine];
1535
- const bracketPos = line.search(/[{\[]/);
1483
+ const bracketPos = line.search(RE_BRACKET_POS);
1536
1484
  if (this.cursorColumn > bracketPos) {
1537
1485
  this._deleteCollapsedNode(ctx.onCollapsedNode);
1538
1486
  return;
@@ -1543,29 +1491,208 @@ class GeoJsonEditor extends HTMLElement {
1543
1491
  this.deleteForward();
1544
1492
  }
1545
1493
 
1546
- _handleTab(isShiftKey, ctx) {
1547
- // Shift+Tab: collapse the containing expanded node
1494
+ private _handleTab(isShiftKey: boolean, _ctx: CollapsedZoneContext): void {
1495
+ // Tab/Shift+Tab: navigate between attributes (key and value)
1548
1496
  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();
1497
+ this._navigateToPrevAttribute();
1498
+ } else {
1499
+ this._navigateToNextAttribute();
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * Navigate to the next attribute (key or value) in the JSON
1505
+ */
1506
+ private _navigateToNextAttribute(): void {
1507
+ const totalLines = this.visibleLines.length;
1508
+ let currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1509
+ if (currentVisibleIdx < 0) currentVisibleIdx = 0;
1510
+
1511
+ // Search from current position forward
1512
+ for (let i = currentVisibleIdx; i < totalLines; i++) {
1513
+ const vl = this.visibleLines[i];
1514
+ const line = this.lines[vl.index];
1515
+ const startCol = (i === currentVisibleIdx) ? this.cursorColumn : 0;
1516
+
1517
+ const pos = this._findNextAttributeInLine(line, startCol);
1518
+ if (pos !== null) {
1519
+ this.cursorLine = vl.index;
1520
+ this.cursorColumn = pos.start;
1521
+ // Select the attribute key or value
1522
+ this.selectionStart = { line: vl.index, column: pos.start };
1523
+ this.selectionEnd = { line: vl.index, column: pos.end };
1557
1524
  this._scrollToCursor();
1525
+ this._invalidateRenderCache();
1526
+ this.scheduleRender();
1527
+ return;
1558
1528
  }
1559
- return;
1560
1529
  }
1561
- // Tab: expand collapsed node if on one
1562
- if (ctx.onCollapsedNode) {
1563
- this.toggleCollapse(ctx.onCollapsedNode.nodeId);
1564
- return;
1530
+
1531
+ // Wrap to beginning
1532
+ for (let i = 0; i < currentVisibleIdx; i++) {
1533
+ const vl = this.visibleLines[i];
1534
+ const line = this.lines[vl.index];
1535
+ const pos = this._findNextAttributeInLine(line, 0);
1536
+ if (pos !== null) {
1537
+ this.cursorLine = vl.index;
1538
+ this.cursorColumn = pos.start;
1539
+ this.selectionStart = { line: vl.index, column: pos.start };
1540
+ this.selectionEnd = { line: vl.index, column: pos.end };
1541
+ this._scrollToCursor();
1542
+ this._invalidateRenderCache();
1543
+ this.scheduleRender();
1544
+ return;
1545
+ }
1565
1546
  }
1566
- if (ctx.onClosingLine) {
1567
- this.toggleCollapse(ctx.onClosingLine.nodeId);
1547
+ }
1548
+
1549
+ /**
1550
+ * Navigate to the previous attribute (key or value) in the JSON
1551
+ */
1552
+ private _navigateToPrevAttribute(): void {
1553
+ const totalLines = this.visibleLines.length;
1554
+ let currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1555
+ if (currentVisibleIdx < 0) currentVisibleIdx = totalLines - 1;
1556
+
1557
+ // Search from current position backward
1558
+ for (let i = currentVisibleIdx; i >= 0; i--) {
1559
+ const vl = this.visibleLines[i];
1560
+ const line = this.lines[vl.index];
1561
+ const endCol = (i === currentVisibleIdx) ? this.cursorColumn : line.length;
1562
+
1563
+ const pos = this._findPrevAttributeInLine(line, endCol);
1564
+ if (pos !== null) {
1565
+ this.cursorLine = vl.index;
1566
+ this.cursorColumn = pos.start;
1567
+ this.selectionStart = { line: vl.index, column: pos.start };
1568
+ this.selectionEnd = { line: vl.index, column: pos.end };
1569
+ this._scrollToCursor();
1570
+ this._invalidateRenderCache();
1571
+ this.scheduleRender();
1572
+ return;
1573
+ }
1568
1574
  }
1575
+
1576
+ // Wrap to end
1577
+ for (let i = totalLines - 1; i > currentVisibleIdx; i--) {
1578
+ const vl = this.visibleLines[i];
1579
+ const line = this.lines[vl.index];
1580
+ const pos = this._findPrevAttributeInLine(line, line.length);
1581
+ if (pos !== null) {
1582
+ this.cursorLine = vl.index;
1583
+ this.cursorColumn = pos.start;
1584
+ this.selectionStart = { line: vl.index, column: pos.start };
1585
+ this.selectionEnd = { line: vl.index, column: pos.end };
1586
+ this._scrollToCursor();
1587
+ this._invalidateRenderCache();
1588
+ this.scheduleRender();
1589
+ return;
1590
+ }
1591
+ }
1592
+ }
1593
+
1594
+ /**
1595
+ * Find next attribute position in a line after startCol
1596
+ * Returns {start, end} for the key or value, or null if none found
1597
+ */
1598
+ private _findNextAttributeInLine(line: string, startCol: number): { start: number; end: number } | null {
1599
+ // Pattern: "key": value where value can be "string", number, true, false, null
1600
+ const re = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1601
+ let match;
1602
+
1603
+ while ((match = re.exec(line)) !== null) {
1604
+ const keyStart = match.index + 1; // Skip opening quote
1605
+ 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
+ }
1611
+
1612
+ // Check if there's a value (string, number, boolean, null)
1613
+ if (match[2] !== undefined) {
1614
+ // String value - find its position
1615
+ const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
1616
+ if (valueMatch) {
1617
+ const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1618
+ const valueEnd = valueStart + match[2].length;
1619
+ if (valueStart > startCol) {
1620
+ return { start: valueStart, end: valueEnd };
1621
+ }
1622
+ }
1623
+ } else if (match[3] !== undefined) {
1624
+ // Number value
1625
+ const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1626
+ if (numMatch) {
1627
+ const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1628
+ const valueEnd = valueStart + numMatch[1].length;
1629
+ if (valueStart > startCol) {
1630
+ return { start: valueStart, end: valueEnd };
1631
+ }
1632
+ }
1633
+ } else {
1634
+ // Boolean or null - check after the colon
1635
+ const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1636
+ if (boolMatch) {
1637
+ const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1638
+ const valueEnd = valueStart + boolMatch[1].length;
1639
+ if (valueStart > startCol) {
1640
+ return { start: valueStart, end: valueEnd };
1641
+ }
1642
+ }
1643
+ }
1644
+ }
1645
+
1646
+ return null;
1647
+ }
1648
+
1649
+ /**
1650
+ * Find previous attribute position in a line before endCol
1651
+ */
1652
+ private _findPrevAttributeInLine(line: string, endCol: number): { start: number; end: number } | null {
1653
+ // Collect all attributes in the line
1654
+ const attrs: { start: number; end: number }[] = [];
1655
+ const re = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1656
+ let match;
1657
+
1658
+ while ((match = re.exec(line)) !== null) {
1659
+ const keyStart = match.index + 1;
1660
+ const keyEnd = keyStart + match[1].length;
1661
+ attrs.push({ start: keyStart, end: keyEnd });
1662
+
1663
+ // Check for value
1664
+ if (match[2] !== undefined) {
1665
+ const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
1666
+ if (valueMatch) {
1667
+ const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1668
+ const valueEnd = valueStart + match[2].length;
1669
+ attrs.push({ start: valueStart, end: valueEnd });
1670
+ }
1671
+ } else if (match[3] !== undefined) {
1672
+ const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1673
+ if (numMatch) {
1674
+ const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1675
+ const valueEnd = valueStart + numMatch[1].length;
1676
+ attrs.push({ start: valueStart, end: valueEnd });
1677
+ }
1678
+ } else {
1679
+ const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1680
+ if (boolMatch) {
1681
+ const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1682
+ const valueEnd = valueStart + boolMatch[1].length;
1683
+ attrs.push({ start: valueStart, end: valueEnd });
1684
+ }
1685
+ }
1686
+ }
1687
+
1688
+ // Find the last attribute that ends before endCol
1689
+ for (let i = attrs.length - 1; i >= 0; i--) {
1690
+ if (attrs[i].end < endCol) {
1691
+ return attrs[i];
1692
+ }
1693
+ }
1694
+
1695
+ return null;
1569
1696
  }
1570
1697
 
1571
1698
  insertNewline() {
@@ -1629,7 +1756,7 @@ class GeoJsonEditor extends HTMLElement {
1629
1756
  /**
1630
1757
  * Move cursor vertically, skipping hidden collapsed lines only
1631
1758
  */
1632
- moveCursorSkipCollapsed(deltaLine) {
1759
+ moveCursorSkipCollapsed(deltaLine: number): void {
1633
1760
  let targetLine = this.cursorLine + deltaLine;
1634
1761
 
1635
1762
  // Skip over hidden collapsed zones only (not opening/closing lines)
@@ -1642,8 +1769,9 @@ class GeoJsonEditor extends HTMLElement {
1642
1769
  } else {
1643
1770
  targetLine = collapsed.startLine; // Jump to opening line
1644
1771
  }
1772
+ } else {
1773
+ break; // Not in a collapsed zone, stop
1645
1774
  }
1646
- break;
1647
1775
  }
1648
1776
 
1649
1777
  this.cursorLine = Math.max(0, Math.min(this.lines.length - 1, targetLine));
@@ -1660,7 +1788,7 @@ class GeoJsonEditor extends HTMLElement {
1660
1788
  /**
1661
1789
  * Move cursor horizontally with smart navigation around collapsed nodes
1662
1790
  */
1663
- moveCursorHorizontal(delta) {
1791
+ moveCursorHorizontal(delta: number): void {
1664
1792
  if (delta > 0) {
1665
1793
  this._moveCursorRight();
1666
1794
  } else {
@@ -1671,7 +1799,7 @@ class GeoJsonEditor extends HTMLElement {
1671
1799
  this.scheduleRender();
1672
1800
  }
1673
1801
 
1674
- _moveCursorRight() {
1802
+ private _moveCursorRight() {
1675
1803
  const line = this.lines[this.cursorLine];
1676
1804
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1677
1805
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
@@ -1689,7 +1817,7 @@ class GeoJsonEditor extends HTMLElement {
1689
1817
  this.cursorColumn++;
1690
1818
  }
1691
1819
  } else if (onCollapsed) {
1692
- const bracketPos = line.search(/[{\[]/);
1820
+ const bracketPos = line.search(RE_BRACKET_POS);
1693
1821
  if (this.cursorColumn < bracketPos) {
1694
1822
  this.cursorColumn++;
1695
1823
  } else if (this.cursorColumn === bracketPos) {
@@ -1713,7 +1841,7 @@ class GeoJsonEditor extends HTMLElement {
1713
1841
  }
1714
1842
  }
1715
1843
 
1716
- _moveCursorLeft() {
1844
+ private _moveCursorLeft() {
1717
1845
  const line = this.lines[this.cursorLine];
1718
1846
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1719
1847
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
@@ -1726,10 +1854,10 @@ class GeoJsonEditor extends HTMLElement {
1726
1854
  // Jump to opening line after bracket
1727
1855
  this.cursorLine = onClosingLine.startLine;
1728
1856
  const openLine = this.lines[this.cursorLine];
1729
- this.cursorColumn = openLine.search(/[{\[]/) + 1;
1857
+ this.cursorColumn = openLine.search(RE_BRACKET_POS) + 1;
1730
1858
  }
1731
1859
  } else if (onCollapsed) {
1732
- const bracketPos = line.search(/[{\[]/);
1860
+ const bracketPos = line.search(RE_BRACKET_POS);
1733
1861
  if (this.cursorColumn > bracketPos + 1) {
1734
1862
  this.cursorColumn = bracketPos + 1;
1735
1863
  } else if (this.cursorColumn === bracketPos + 1) {
@@ -1752,7 +1880,7 @@ class GeoJsonEditor extends HTMLElement {
1752
1880
  if (collapsed) {
1753
1881
  this.cursorLine = collapsed.startLine;
1754
1882
  const openLine = this.lines[this.cursorLine];
1755
- this.cursorColumn = openLine.search(/[{\[]/) + 1;
1883
+ this.cursorColumn = openLine.search(RE_BRACKET_POS) + 1;
1756
1884
  } else {
1757
1885
  this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1758
1886
  }
@@ -1763,8 +1891,8 @@ class GeoJsonEditor extends HTMLElement {
1763
1891
  /**
1764
1892
  * Scroll viewport to ensure cursor is visible
1765
1893
  */
1766
- _scrollToCursor() {
1767
- const viewport = this.shadowRoot.getElementById('viewport');
1894
+ private _scrollToCursor() {
1895
+ const viewport = this._viewport;
1768
1896
  if (!viewport) return;
1769
1897
 
1770
1898
  // Find the visible line index for the cursor
@@ -1788,7 +1916,7 @@ class GeoJsonEditor extends HTMLElement {
1788
1916
  /**
1789
1917
  * Handle arrow key with optional selection and word jump
1790
1918
  */
1791
- _handleArrowKey(deltaLine, deltaCol, isShift, isCtrl = false) {
1919
+ private _handleArrowKey(deltaLine: number, deltaCol: number, isShift: boolean, isCtrl = false): void {
1792
1920
  // Start selection if shift is pressed and no selection exists
1793
1921
  if (isShift && !this.selectionStart) {
1794
1922
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
@@ -1822,19 +1950,41 @@ class GeoJsonEditor extends HTMLElement {
1822
1950
  * - Ctrl+Right: move to end of current word, or start of next word
1823
1951
  * - Ctrl+Left: move to start of current word, or start of previous word
1824
1952
  */
1825
- _moveCursorByWord(direction) {
1953
+ private _moveCursorByWord(direction: number): void {
1826
1954
  const line = this.lines[this.cursorLine] || '';
1827
1955
  // Word character: alphanumeric, underscore, or hyphen (for kebab-case identifiers)
1828
- const isWordChar = (ch) => /[\w-]/.test(ch);
1956
+ const isWordChar = (ch: string) => RE_IS_WORD_CHAR.test(ch);
1957
+
1958
+ // Check if we're on a collapsed node's opening line
1959
+ const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1829
1960
 
1830
1961
  if (direction > 0) {
1831
1962
  // Move right
1832
1963
  let pos = this.cursorColumn;
1833
1964
 
1965
+ // If on collapsed node opening line and cursor is at/after the bracket, jump to closing line
1966
+ if (onCollapsed) {
1967
+ const bracketPos = line.search(RE_BRACKET_POS);
1968
+ if (bracketPos >= 0 && pos >= bracketPos) {
1969
+ this.cursorLine = onCollapsed.endLine;
1970
+ this.cursorColumn = (this.lines[this.cursorLine] || '').length;
1971
+ this._invalidateRenderCache();
1972
+ this._scrollToCursor();
1973
+ this.scheduleRender();
1974
+ return;
1975
+ }
1976
+ }
1977
+
1834
1978
  if (pos >= line.length) {
1835
- // At end of line, move to start of next line
1979
+ // At end of line, move to start of next visible line
1836
1980
  if (this.cursorLine < this.lines.length - 1) {
1837
- this.cursorLine++;
1981
+ let nextLine = this.cursorLine + 1;
1982
+ // Skip collapsed zones
1983
+ const collapsed = this._getCollapsedRangeForLine(nextLine);
1984
+ if (collapsed) {
1985
+ nextLine = collapsed.endLine;
1986
+ }
1987
+ this.cursorLine = Math.min(nextLine, this.lines.length - 1);
1838
1988
  this.cursorColumn = 0;
1839
1989
  }
1840
1990
  } else if (isWordChar(line[pos])) {
@@ -1854,10 +2004,33 @@ class GeoJsonEditor extends HTMLElement {
1854
2004
  // Move left
1855
2005
  let pos = this.cursorColumn;
1856
2006
 
2007
+ // Check if we're on closing line of a collapsed node
2008
+ const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
2009
+ if (onClosingLine) {
2010
+ const bracketPos = this._getClosingBracketPos(line);
2011
+ if (bracketPos >= 0 && pos <= bracketPos + 1) {
2012
+ // Jump to opening line, after the bracket
2013
+ this.cursorLine = onClosingLine.startLine;
2014
+ const openLine = this.lines[this.cursorLine] || '';
2015
+ const openBracketPos = openLine.search(RE_BRACKET_POS);
2016
+ this.cursorColumn = openBracketPos >= 0 ? openBracketPos : 0;
2017
+ this._invalidateRenderCache();
2018
+ this._scrollToCursor();
2019
+ this.scheduleRender();
2020
+ return;
2021
+ }
2022
+ }
2023
+
1857
2024
  if (pos === 0) {
1858
- // At start of line, move to end of previous line
2025
+ // At start of line, move to end of previous visible line
1859
2026
  if (this.cursorLine > 0) {
1860
- this.cursorLine--;
2027
+ let prevLine = this.cursorLine - 1;
2028
+ // Skip collapsed zones
2029
+ const collapsed = this._getCollapsedRangeForLine(prevLine);
2030
+ if (collapsed) {
2031
+ prevLine = collapsed.startLine;
2032
+ }
2033
+ this.cursorLine = Math.max(prevLine, 0);
1861
2034
  this.cursorColumn = this.lines[this.cursorLine].length;
1862
2035
  }
1863
2036
  } else if (pos > 0 && isWordChar(line[pos - 1])) {
@@ -1886,7 +2059,7 @@ class GeoJsonEditor extends HTMLElement {
1886
2059
  /**
1887
2060
  * Handle Home/End with optional selection
1888
2061
  */
1889
- _handleHomeEnd(key, isShift, onClosingLine) {
2062
+ private _handleHomeEnd(key: string, isShift: boolean, onClosingLine: CollapsedNodeInfo | null): void {
1890
2063
  // Start selection if shift is pressed and no selection exists
1891
2064
  if (isShift && !this.selectionStart) {
1892
2065
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
@@ -1919,7 +2092,7 @@ class GeoJsonEditor extends HTMLElement {
1919
2092
  /**
1920
2093
  * Select all content
1921
2094
  */
1922
- _selectAll() {
2095
+ private _selectAll() {
1923
2096
  this.selectionStart = { line: 0, column: 0 };
1924
2097
  const lastLine = this.lines.length - 1;
1925
2098
  this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
@@ -1934,11 +2107,10 @@ class GeoJsonEditor extends HTMLElement {
1934
2107
  /**
1935
2108
  * Get selected text
1936
2109
  */
1937
- _getSelectedText() {
1938
- if (!this.selectionStart || !this.selectionEnd) return '';
1939
-
1940
- const { start, end } = this._normalizeSelection();
1941
- if (!start || !end) return '';
2110
+ private _getSelectedText(): string {
2111
+ const sel = this._normalizeSelection();
2112
+ if (!sel) return '';
2113
+ const { start, end } = sel;
1942
2114
 
1943
2115
  if (start.line === end.line) {
1944
2116
  return this.lines[start.line].substring(start.column, end.column);
@@ -1956,14 +2128,14 @@ class GeoJsonEditor extends HTMLElement {
1956
2128
  /**
1957
2129
  * Normalize selection so start is before end
1958
2130
  */
1959
- _normalizeSelection() {
2131
+ private _normalizeSelection(): { start: CursorPosition; end: CursorPosition } | null {
1960
2132
  if (!this.selectionStart || !this.selectionEnd) {
1961
- return { start: null, end: null };
2133
+ return null;
1962
2134
  }
1963
-
2135
+
1964
2136
  const s = this.selectionStart;
1965
2137
  const e = this.selectionEnd;
1966
-
2138
+
1967
2139
  if (s.line < e.line || (s.line === e.line && s.column <= e.column)) {
1968
2140
  return { start: s, end: e };
1969
2141
  } else {
@@ -1974,7 +2146,7 @@ class GeoJsonEditor extends HTMLElement {
1974
2146
  /**
1975
2147
  * Check if there is an active selection
1976
2148
  */
1977
- _hasSelection() {
2149
+ private _hasSelection() {
1978
2150
  if (!this.selectionStart || !this.selectionEnd) return false;
1979
2151
  return this.selectionStart.line !== this.selectionEnd.line ||
1980
2152
  this.selectionStart.column !== this.selectionEnd.column;
@@ -1983,7 +2155,7 @@ class GeoJsonEditor extends HTMLElement {
1983
2155
  /**
1984
2156
  * Clear the current selection
1985
2157
  */
1986
- _clearSelection() {
2158
+ private _clearSelection() {
1987
2159
  this.selectionStart = null;
1988
2160
  this.selectionEnd = null;
1989
2161
  }
@@ -1991,13 +2163,13 @@ class GeoJsonEditor extends HTMLElement {
1991
2163
  /**
1992
2164
  * Delete selected text
1993
2165
  */
1994
- _deleteSelection() {
1995
- if (!this._hasSelection()) return false;
2166
+ private _deleteSelection(): boolean {
2167
+ const sel = this._normalizeSelection();
2168
+ if (!sel) return false;
2169
+ const { start, end } = sel;
1996
2170
 
1997
2171
  this._saveToHistory('delete');
1998
2172
 
1999
- const { start, end } = this._normalizeSelection();
2000
-
2001
2173
  if (start.line === end.line) {
2002
2174
  // Single line selection
2003
2175
  const line = this.lines[start.line];
@@ -2018,7 +2190,7 @@ class GeoJsonEditor extends HTMLElement {
2018
2190
  return true;
2019
2191
  }
2020
2192
 
2021
- insertText(text) {
2193
+ insertText(text: string): void {
2022
2194
  // Delete selection first if any
2023
2195
  if (this._hasSelection()) {
2024
2196
  this._deleteSelection();
@@ -2039,7 +2211,7 @@ class GeoJsonEditor extends HTMLElement {
2039
2211
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
2040
2212
  if (onCollapsed) {
2041
2213
  const line = this.lines[this.cursorLine];
2042
- const bracketPos = line.search(/[{\[]/);
2214
+ const bracketPos = line.search(RE_BRACKET_POS);
2043
2215
  if (this.cursorColumn > bracketPos) return;
2044
2216
  }
2045
2217
 
@@ -2061,9 +2233,9 @@ class GeoJsonEditor extends HTMLElement {
2061
2233
  this.formatAndUpdate();
2062
2234
  }
2063
2235
 
2064
- handlePaste(e) {
2236
+ handlePaste(e: ClipboardEvent): void {
2065
2237
  e.preventDefault();
2066
- const text = e.clipboardData.getData('text/plain');
2238
+ const text = e.clipboardData?.getData('text/plain');
2067
2239
  if (!text) return;
2068
2240
 
2069
2241
  const wasEmpty = this.lines.length === 0;
@@ -2071,7 +2243,7 @@ class GeoJsonEditor extends HTMLElement {
2071
2243
  // Try to parse as GeoJSON and normalize
2072
2244
  try {
2073
2245
  const parsed = JSON.parse(text);
2074
- const features = this._normalizeToFeatures(parsed);
2246
+ const features = normalizeToFeatures(parsed);
2075
2247
  // Valid GeoJSON - insert formatted features
2076
2248
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2077
2249
  this.insertText(formatted);
@@ -2085,14 +2257,15 @@ class GeoJsonEditor extends HTMLElement {
2085
2257
  // Cancel pending render, collapse first, then render once
2086
2258
  if (this.renderTimer) {
2087
2259
  cancelAnimationFrame(this.renderTimer);
2088
- this.renderTimer = null;
2260
+ this.renderTimer = undefined;
2089
2261
  }
2090
2262
  this.autoCollapseCoordinates();
2091
2263
  }
2092
2264
  }
2093
2265
 
2094
- handleCopy(e) {
2266
+ handleCopy(e: ClipboardEvent): void {
2095
2267
  e.preventDefault();
2268
+ if (!e.clipboardData) return;
2096
2269
  // Copy selected text if there's a selection, otherwise copy all
2097
2270
  if (this._hasSelection()) {
2098
2271
  e.clipboardData.setData('text/plain', this._getSelectedText());
@@ -2101,8 +2274,9 @@ class GeoJsonEditor extends HTMLElement {
2101
2274
  }
2102
2275
  }
2103
2276
 
2104
- handleCut(e) {
2277
+ handleCut(e: ClipboardEvent): void {
2105
2278
  e.preventDefault();
2279
+ if (!e.clipboardData) return;
2106
2280
  if (this._hasSelection()) {
2107
2281
  e.clipboardData.setData('text/plain', this._getSelectedText());
2108
2282
  this._saveToHistory('cut');
@@ -2122,9 +2296,10 @@ class GeoJsonEditor extends HTMLElement {
2122
2296
  /**
2123
2297
  * Get line/column position from mouse event
2124
2298
  */
2125
- _getPositionFromClick(e) {
2126
- const viewport = this.shadowRoot.getElementById('viewport');
2127
- const linesContainer = this.shadowRoot.getElementById('linesContainer');
2299
+ private _getPositionFromClick(e: MouseEvent): { line: number; column: number } {
2300
+ const viewport = this._viewport;
2301
+ const linesContainer = this._linesContainer;
2302
+ if (!viewport) return { line: 0, column: 0 };
2128
2303
  const rect = viewport.getBoundingClientRect();
2129
2304
 
2130
2305
  const paddingTop = 8;
@@ -2165,28 +2340,34 @@ class GeoJsonEditor extends HTMLElement {
2165
2340
 
2166
2341
  // ========== Gutter Interactions ==========
2167
2342
 
2168
- handleGutterClick(e) {
2343
+ handleGutterClick(e: MouseEvent): void {
2344
+ const target = e.target as HTMLElement;
2345
+ if (!target) return;
2346
+
2169
2347
  // Visibility button in gutter
2170
- const visBtn = e.target.closest('.visibility-button');
2348
+ const visBtn = target.closest('.visibility-button') as HTMLElement | null;
2171
2349
  if (visBtn) {
2172
2350
  this.toggleFeatureVisibility(visBtn.dataset.featureKey);
2173
2351
  return;
2174
2352
  }
2175
-
2353
+
2176
2354
  // Collapse button in gutter
2177
- if (e.target.classList.contains('collapse-button')) {
2178
- const nodeId = e.target.dataset.nodeId;
2179
- this.toggleCollapse(nodeId);
2355
+ if (target.classList.contains('collapse-button')) {
2356
+ const nodeId = target.dataset.nodeId;
2357
+ if (nodeId) this.toggleCollapse(nodeId);
2180
2358
  return;
2181
2359
  }
2182
2360
  }
2183
2361
 
2184
- handleEditorClick(e) {
2362
+ handleEditorClick(e: MouseEvent): void {
2363
+ const target = e.target as HTMLElement;
2364
+ if (!target) return;
2365
+
2185
2366
  // Unblock render now that click is being processed
2186
2367
  this._blockRender = false;
2187
2368
 
2188
2369
  // Line-level visibility button (pseudo-element ::before on .line.has-visibility)
2189
- const lineEl = e.target.closest('.line.has-visibility');
2370
+ const lineEl = target.closest('.line.has-visibility') as HTMLElement | null;
2190
2371
  if (lineEl) {
2191
2372
  const rect = lineEl.getBoundingClientRect();
2192
2373
  const clickX = e.clientX - rect.left;
@@ -2200,42 +2381,44 @@ class GeoJsonEditor extends HTMLElement {
2200
2381
  return;
2201
2382
  }
2202
2383
  }
2203
-
2384
+
2204
2385
  // Inline color swatch (pseudo-element positioned with left: -8px)
2205
- if (e.target.classList.contains('json-color')) {
2206
- const rect = e.target.getBoundingClientRect();
2386
+ if (target.classList.contains('json-color')) {
2387
+ const rect = target.getBoundingClientRect();
2207
2388
  const clickX = e.clientX - rect.left;
2208
2389
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
2209
2390
  if (clickX < 0 && clickX >= -8) {
2210
2391
  e.preventDefault();
2211
2392
  e.stopPropagation();
2212
- const color = e.target.dataset.color;
2213
- const targetLineEl = e.target.closest('.line');
2393
+ const color = target.dataset.color;
2394
+ const targetLineEl = target.closest('.line') as HTMLElement | null;
2214
2395
  if (targetLineEl) {
2215
- const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
2396
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
2216
2397
  const line = this.lines[lineIndex];
2217
- const match = line.match(/"([\w-]+)"\s*:\s*"#/);
2218
- if (match) {
2219
- this.showColorPicker(e.target, lineIndex, color, match[1]);
2398
+ // Match any string attribute (hex or named color)
2399
+ // RE_ATTR_VALUE_SINGLE captures: [1] attributeName, [2] stringValue
2400
+ const match = line.match(RE_ATTR_VALUE_SINGLE);
2401
+ if (match && match[1] && color) {
2402
+ this.showColorPicker(target, lineIndex, color, match[1]);
2220
2403
  }
2221
2404
  }
2222
2405
  return;
2223
2406
  }
2224
2407
  }
2225
-
2408
+
2226
2409
  // Inline boolean checkbox (pseudo-element positioned with left: -8px)
2227
- if (e.target.classList.contains('json-boolean')) {
2228
- const rect = e.target.getBoundingClientRect();
2410
+ if (target.classList.contains('json-boolean')) {
2411
+ const rect = target.getBoundingClientRect();
2229
2412
  const clickX = e.clientX - rect.left;
2230
2413
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
2231
2414
  if (clickX < 0 && clickX >= -8) {
2232
2415
  e.preventDefault();
2233
2416
  e.stopPropagation();
2234
- const targetLineEl = e.target.closest('.line');
2417
+ const targetLineEl = target.closest('.line') as HTMLElement | null;
2235
2418
  if (targetLineEl) {
2236
- const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
2419
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
2237
2420
  const line = this.lines[lineIndex];
2238
- const match = line.match(/"([\w-]+)"\s*:\s*(true|false)/);
2421
+ const match = line.match(RE_ATTR_AND_BOOL_VALUE);
2239
2422
  if (match) {
2240
2423
  const currentValue = match[2] === 'true';
2241
2424
  this.updateBooleanValue(lineIndex, !currentValue, match[1]);
@@ -2248,7 +2431,7 @@ class GeoJsonEditor extends HTMLElement {
2248
2431
 
2249
2432
  // ========== Collapse/Expand ==========
2250
2433
 
2251
- toggleCollapse(nodeId) {
2434
+ toggleCollapse(nodeId: string): void {
2252
2435
  if (this.collapsedNodes.has(nodeId)) {
2253
2436
  this.collapsedNodes.delete(nodeId);
2254
2437
  } else {
@@ -2267,10 +2450,8 @@ class GeoJsonEditor extends HTMLElement {
2267
2450
 
2268
2451
  /**
2269
2452
  * 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
2453
  */
2273
- _applyCollapsedFromOptions(options, features) {
2454
+ private _applyCollapsedFromOptions(options: SetOptions, features: Feature[]): void {
2274
2455
  const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2275
2456
  if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2276
2457
  this._applyCollapsedOption(collapsed, features);
@@ -2279,10 +2460,8 @@ class GeoJsonEditor extends HTMLElement {
2279
2460
 
2280
2461
  /**
2281
2462
  * 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
2463
  */
2285
- _applyCollapsedOption(collapsed, features = null) {
2464
+ private _applyCollapsedOption(collapsed: string[] | ((feature: Feature | null, index: number) => string[]), features: Feature[] | null = null): void {
2286
2465
  const ranges = this._findCollapsibleRanges();
2287
2466
 
2288
2467
  // Group ranges by feature (root nodes)
@@ -2327,7 +2506,8 @@ class GeoJsonEditor extends HTMLElement {
2327
2506
 
2328
2507
  // ========== Feature Visibility ==========
2329
2508
 
2330
- toggleFeatureVisibility(featureKey) {
2509
+ toggleFeatureVisibility(featureKey: string | undefined): void {
2510
+ if (!featureKey) return;
2331
2511
  if (this.hiddenFeatures.has(featureKey)) {
2332
2512
  this.hiddenFeatures.delete(featureKey);
2333
2513
  } else {
@@ -2341,17 +2521,17 @@ class GeoJsonEditor extends HTMLElement {
2341
2521
  }
2342
2522
 
2343
2523
  // ========== Color Picker ==========
2344
-
2345
- showColorPicker(indicator, line, currentColor, attributeName) {
2524
+
2525
+ showColorPicker(indicator: HTMLElement, line: number, currentColor: string, attributeName: string) {
2346
2526
  // Remove existing picker and anchor
2347
2527
  const existing = document.querySelector('.geojson-color-picker-anchor');
2348
2528
  if (existing) {
2349
2529
  existing.remove();
2350
2530
  }
2351
-
2531
+
2352
2532
  // Create an anchor element at the pseudo-element position
2353
2533
  // The browser will position the color picker popup relative to this
2354
- const anchor = document.createElement('div');
2534
+ const anchor = _ce('div');
2355
2535
  anchor.className = 'geojson-color-picker-anchor';
2356
2536
  const rect = indicator.getBoundingClientRect();
2357
2537
  anchor.style.cssText = `
@@ -2363,10 +2543,19 @@ class GeoJsonEditor extends HTMLElement {
2363
2543
  z-index: 9998;
2364
2544
  `;
2365
2545
  document.body.appendChild(anchor);
2366
-
2367
- const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
2546
+
2547
+ const colorInput = _ce('input') as HTMLInputElement & { _closeListener?: EventListener };
2368
2548
  colorInput.type = 'color';
2369
- colorInput.value = currentColor;
2549
+ // Convert color to hex format for the color picker
2550
+ let hexColor = currentColor;
2551
+ if (!currentColor.startsWith('#')) {
2552
+ // Named color - convert to hex
2553
+ hexColor = namedColorToHex(currentColor) || '#000000';
2554
+ } else {
2555
+ // Expand 3-char hex to 6-char (#abc -> #aabbcc)
2556
+ hexColor = currentColor.replace(RE_NORMALIZE_COLOR, '#$1$1$2$2$3$3');
2557
+ }
2558
+ colorInput.value = hexColor;
2370
2559
  colorInput.className = 'geojson-color-picker-input';
2371
2560
 
2372
2561
  // Position the color input inside the anchor
@@ -2383,7 +2572,7 @@ class GeoJsonEditor extends HTMLElement {
2383
2572
  `;
2384
2573
  anchor.appendChild(colorInput);
2385
2574
 
2386
- colorInput.addEventListener('input', (e: Event) => {
2575
+ colorInput.addEventListener('input', (e) => {
2387
2576
  this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
2388
2577
  });
2389
2578
 
@@ -2395,7 +2584,7 @@ class GeoJsonEditor extends HTMLElement {
2395
2584
  };
2396
2585
 
2397
2586
  colorInput._closeListener = closeOnClickOutside;
2398
-
2587
+
2399
2588
  setTimeout(() => {
2400
2589
  document.addEventListener('click', closeOnClickOutside, true);
2401
2590
  }, 100);
@@ -2404,17 +2593,18 @@ class GeoJsonEditor extends HTMLElement {
2404
2593
  colorInput.click();
2405
2594
  }
2406
2595
 
2407
- updateColorValue(line, newColor, attributeName) {
2408
- const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"`);
2596
+ updateColorValue(line: number, newColor: string, attributeName: string) {
2597
+ // Match both hex colors (#xxx, #xxxxxx) and named colors (red, blue, etc.)
2598
+ const regex = new RegExp(`"${attributeName}"\\s*:\\s*"(?:#[0-9a-fA-F]{3,6}|[a-zA-Z]+)"`);
2409
2599
  this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
2410
-
2600
+
2411
2601
  // Use updateView to preserve collapsed state (line count didn't change)
2412
2602
  this.updateView();
2413
2603
  this.scheduleRender();
2414
2604
  this.emitChange();
2415
2605
  }
2416
2606
 
2417
- updateBooleanValue(line, newValue, attributeName) {
2607
+ updateBooleanValue(line: number, newValue: boolean, attributeName: string): void {
2418
2608
  const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
2419
2609
  this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": ${newValue}`);
2420
2610
 
@@ -2456,14 +2646,14 @@ class GeoJsonEditor extends HTMLElement {
2456
2646
 
2457
2647
  // Filter hidden features
2458
2648
  if (this.hiddenFeatures.size > 0) {
2459
- parsed.features = parsed.features.filter((feature) => {
2460
- const key = this._getFeatureKey(feature);
2461
- return !this.hiddenFeatures.has(key);
2649
+ parsed.features = parsed.features.filter((feature: Feature) => {
2650
+ const key = getFeatureKey(feature);
2651
+ return key ? !this.hiddenFeatures.has(key) : true;
2462
2652
  });
2463
2653
  }
2464
2654
 
2465
2655
  // Validate
2466
- const errors = this._validateGeoJSON(parsed);
2656
+ const errors = validateGeoJSON(parsed);
2467
2657
 
2468
2658
  if (errors.length > 0) {
2469
2659
  this.dispatchEvent(new CustomEvent('error', {
@@ -2480,7 +2670,7 @@ class GeoJsonEditor extends HTMLElement {
2480
2670
  }
2481
2671
  } catch (e) {
2482
2672
  this.dispatchEvent(new CustomEvent('error', {
2483
- detail: { error: e.message, content },
2673
+ detail: { error: e instanceof Error ? e.message : 'Unknown error', content },
2484
2674
  bubbles: true,
2485
2675
  composed: true
2486
2676
  }));
@@ -2488,56 +2678,48 @@ class GeoJsonEditor extends HTMLElement {
2488
2678
  }
2489
2679
 
2490
2680
  // ========== UI Updates ==========
2491
-
2492
- updateReadonly() {
2493
- const textarea = this.shadowRoot.getElementById('hiddenTextarea');
2494
- const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
2495
2681
 
2682
+ updateReadonly() {
2496
2683
  // Use readOnly instead of disabled to allow text selection for copying
2497
- if (textarea) (textarea as HTMLTextAreaElement).readOnly = this.readonly;
2498
- if (clearBtn) clearBtn.hidden = this.readonly;
2684
+ if (this._hiddenTextarea) this._hiddenTextarea.readOnly = this.readonly;
2685
+ if (this._clearBtn) this._clearBtn.hidden = this.readonly;
2499
2686
  }
2500
2687
 
2501
2688
  updatePlaceholderVisibility() {
2502
- const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2503
- if (placeholder) {
2504
- placeholder.style.display = this.lines.length > 0 ? 'none' : 'block';
2689
+ if (this._placeholderLayer) {
2690
+ this._placeholderLayer.style.display = this.lines.length > 0 ? 'none' : 'block';
2505
2691
  }
2506
2692
  }
2507
2693
 
2508
2694
  updatePlaceholderContent() {
2509
- const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2510
- if (placeholder) {
2511
- placeholder.textContent = this.placeholder;
2695
+ if (this._placeholderLayer) {
2696
+ this._placeholderLayer.textContent = this.placeholder;
2512
2697
  }
2513
2698
  this.updatePlaceholderVisibility();
2514
2699
  }
2515
2700
 
2516
2701
  updatePrefixSuffix() {
2517
- const prefix = this.shadowRoot.getElementById('editorPrefix');
2518
- const suffix = this.shadowRoot.getElementById('editorSuffix');
2519
-
2520
- if (prefix) prefix.textContent = this.prefix;
2521
- if (suffix) suffix.textContent = this.suffix;
2702
+ if (this._editorPrefix) this._editorPrefix.textContent = this.prefix;
2703
+ if (this._editorSuffix) this._editorSuffix.textContent = this.suffix;
2522
2704
  }
2523
2705
 
2524
2706
  // ========== Theme ==========
2525
2707
 
2526
2708
  updateThemeCSS() {
2527
2709
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2528
- const darkRule = this._parseSelectorToHostRule(darkSelector);
2529
-
2530
- let themeStyle = this.shadowRoot.getElementById('theme-styles');
2710
+ const darkRule = parseSelectorToHostRule(darkSelector);
2711
+
2712
+ let themeStyle = this._id('theme-styles') as HTMLStyleElement;
2531
2713
  if (!themeStyle) {
2532
- themeStyle = document.createElement('style');
2714
+ themeStyle = _ce('style') as HTMLStyleElement;
2533
2715
  themeStyle.id = 'theme-styles';
2534
- this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
2716
+ this.shadowRoot!.insertBefore(themeStyle, this.shadowRoot!.firstChild);
2535
2717
  }
2536
2718
 
2537
2719
  const darkDefaults = {
2538
2720
  bgColor: '#2b2b2b',
2539
2721
  textColor: '#a9b7c6',
2540
- caretColor: '#bbbbbb',
2722
+ caretColor: '#bbb',
2541
2723
  gutterBg: '#313335',
2542
2724
  gutterBorder: '#3c3f41',
2543
2725
  gutterText: '#606366',
@@ -2557,14 +2739,16 @@ class GeoJsonEditor extends HTMLElement {
2557
2739
  jsonKeyInvalid: '#ff6b68'
2558
2740
  };
2559
2741
 
2560
- const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
2561
- const generateVars = (obj) => Object.entries(obj)
2742
+ RE_TO_KEBAB.lastIndex = 0;
2743
+ const toKebab = (str: string) => str.replace(RE_TO_KEBAB, '-$1').toLowerCase();
2744
+ const generateVars = (obj: Record<string, string | undefined>) => Object.entries(obj)
2745
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
2562
2746
  .map(([k, v]) => `--${toKebab(k)}: ${v};`)
2563
2747
  .join('\n ');
2564
2748
 
2565
- const lightVars = generateVars(this.themes.light || {});
2749
+ const lightVars = generateVars(this.themes.light as Record<string, string | undefined> || {});
2566
2750
  const darkTheme = { ...darkDefaults, ...this.themes.dark };
2567
- const darkVars = generateVars(darkTheme);
2751
+ const darkVars = generateVars(darkTheme as Record<string, string | undefined>);
2568
2752
 
2569
2753
  let css = lightVars ? `:host {\n ${lightVars}\n }\n` : '';
2570
2754
  css += `${darkRule} {\n ${darkVars}\n }`;
@@ -2572,14 +2756,6 @@ class GeoJsonEditor extends HTMLElement {
2572
2756
  themeStyle.textContent = css;
2573
2757
  }
2574
2758
 
2575
- _parseSelectorToHostRule(selector) {
2576
- if (!selector) return ':host([data-color-scheme="dark"])';
2577
- if (selector.startsWith('.') && !selector.includes(' ')) {
2578
- return `:host(${selector})`;
2579
- }
2580
- return `:host-context(${selector})`;
2581
- }
2582
-
2583
2759
  setTheme(theme: ThemeSettings): void {
2584
2760
  if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2585
2761
  if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
@@ -2591,45 +2767,15 @@ class GeoJsonEditor extends HTMLElement {
2591
2767
  this.updateThemeCSS();
2592
2768
  }
2593
2769
 
2594
- // ========== Helper Methods ==========
2595
-
2596
- _getFeatureKey(feature) {
2597
- if (!feature) return null;
2598
- if (feature.id !== undefined) return `id:${feature.id}`;
2599
- if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
2600
-
2601
- const geomType = feature.geometry?.type || 'null';
2602
- const coords = JSON.stringify(feature.geometry?.coordinates || []);
2603
- let hash = 0;
2604
- for (let i = 0; i < coords.length; i++) {
2605
- hash = ((hash << 5) - hash) + coords.charCodeAt(i);
2606
- hash = hash & hash;
2607
- }
2608
- return `hash:${geomType}:${hash.toString(36)}`;
2609
- }
2610
-
2611
- _countBrackets(line, openBracket) {
2612
- const closeBracket = openBracket === '{' ? '}' : ']';
2613
- let open = 0, close = 0, inString = false, escape = false;
2614
-
2615
- for (const char of line) {
2616
- if (escape) { escape = false; continue; }
2617
- if (char === '\\' && inString) { escape = true; continue; }
2618
- if (char === '"') { inString = !inString; continue; }
2619
- if (!inString) {
2620
- if (char === openBracket) open++;
2621
- if (char === closeBracket) close++;
2622
- }
2623
- }
2624
-
2625
- return { open, close };
2770
+ getTheme(): ThemeSettings {
2771
+ return { ...this.themes };
2626
2772
  }
2627
2773
 
2628
2774
  /**
2629
2775
  * Find all collapsible ranges using the mappings built by _rebuildNodeIdMappings
2630
2776
  * This method only READS the existing mappings, it doesn't create new IDs
2631
2777
  */
2632
- _findCollapsibleRanges() {
2778
+ private _findCollapsibleRanges() {
2633
2779
  const ranges = [];
2634
2780
 
2635
2781
  // Simply iterate through the existing mappings
@@ -2641,13 +2787,13 @@ class GeoJsonEditor extends HTMLElement {
2641
2787
  if (!line) continue;
2642
2788
 
2643
2789
  // Match "key": { or "key": [
2644
- const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
2790
+ const kvMatch = line.match(RE_KV_MATCH);
2645
2791
  // Also match standalone { or [ (root Feature objects)
2646
- const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
2792
+ const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
2647
2793
 
2648
2794
  if (!kvMatch && !rootMatch) continue;
2649
-
2650
- const openBracket = kvMatch ? kvMatch[2] : rootMatch[1];
2795
+
2796
+ const openBracket = kvMatch ? kvMatch[2] : (rootMatch ? rootMatch[1] : '{');
2651
2797
 
2652
2798
  ranges.push({
2653
2799
  startLine: rangeInfo.startLine,
@@ -2665,20 +2811,20 @@ class GeoJsonEditor extends HTMLElement {
2665
2811
  return ranges;
2666
2812
  }
2667
2813
 
2668
- _findClosingLine(startLine, openBracket) {
2814
+ private _findClosingLine(startLine: number, openBracket: string): number {
2669
2815
  let depth = 1;
2670
2816
  const line = this.lines[startLine];
2671
2817
  const bracketPos = line.indexOf(openBracket);
2672
2818
 
2673
2819
  if (bracketPos !== -1) {
2674
2820
  const rest = line.substring(bracketPos + 1);
2675
- const counts = this._countBrackets(rest, openBracket);
2821
+ const counts = countBrackets(rest, openBracket);
2676
2822
  depth += counts.open - counts.close;
2677
2823
  if (depth === 0) return startLine;
2678
2824
  }
2679
2825
 
2680
2826
  for (let i = startLine + 1; i < this.lines.length; i++) {
2681
- const counts = this._countBrackets(this.lines[i], openBracket);
2827
+ const counts = countBrackets(this.lines[i], openBracket);
2682
2828
  depth += counts.open - counts.close;
2683
2829
  if (depth === 0) return i;
2684
2830
  }
@@ -2686,7 +2832,7 @@ class GeoJsonEditor extends HTMLElement {
2686
2832
  return -1;
2687
2833
  }
2688
2834
 
2689
- _buildContextMap() {
2835
+ private _buildContextMap() {
2690
2836
  // Memoization: return cached result if content hasn't changed
2691
2837
  const linesLength = this.lines.length;
2692
2838
  if (this._contextMapCache &&
@@ -2696,9 +2842,9 @@ class GeoJsonEditor extends HTMLElement {
2696
2842
  return this._contextMapCache;
2697
2843
  }
2698
2844
 
2699
- const contextMap = new Map();
2700
- const contextStack = [];
2701
- let pendingContext = null;
2845
+ const contextMap = new Map<number, string>();
2846
+ const contextStack: { context: string; isArray: boolean }[] = [];
2847
+ let pendingContext: string | null = null;
2702
2848
 
2703
2849
  for (let i = 0; i < linesLength; i++) {
2704
2850
  const line = this.lines[i];
@@ -2711,10 +2857,14 @@ class GeoJsonEditor extends HTMLElement {
2711
2857
  else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
2712
2858
 
2713
2859
  // Track brackets
2714
- const openBraces = (line.match(/\{/g) || []).length;
2715
- const closeBraces = (line.match(/\}/g) || []).length;
2716
- const openBrackets = (line.match(/\[/g) || []).length;
2717
- const closeBrackets = (line.match(/\]/g) || []).length;
2860
+ RE_OPEN_BRACES.lastIndex = 0;
2861
+ RE_CLOSE_BRACES.lastIndex = 0;
2862
+ RE_OPEN_BRACKETS.lastIndex = 0;
2863
+ RE_CLOSE_BRACKET.lastIndex = 0;
2864
+ const openBraces = (line.match(RE_OPEN_BRACES) || []).length;
2865
+ const closeBraces = (line.match(RE_CLOSE_BRACES) || []).length;
2866
+ const openBrackets = (line.match(RE_OPEN_BRACKETS) || []).length;
2867
+ const closeBrackets = (line.match(RE_CLOSE_BRACKET) || []).length;
2718
2868
 
2719
2869
  for (let j = 0; j < openBraces + openBrackets; j++) {
2720
2870
  contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
@@ -2735,214 +2885,7 @@ class GeoJsonEditor extends HTMLElement {
2735
2885
  return contextMap;
2736
2886
  }
2737
2887
 
2738
- _highlightSyntax(text, context, meta) {
2739
- if (!text) return '';
2740
-
2741
- // For collapsed nodes, truncate the text at the opening bracket
2742
- let displayText = text;
2743
- let collapsedBracket = null;
2744
-
2745
- if (meta?.collapseButton?.isCollapsed) {
2746
- // Match "key": { or "key": [
2747
- const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
2748
- // Also match standalone { or [ (root Feature objects)
2749
- const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
2750
-
2751
- if (bracketMatch) {
2752
- displayText = bracketMatch[1] + bracketMatch[2];
2753
- collapsedBracket = bracketMatch[2];
2754
- } else if (rootMatch) {
2755
- displayText = rootMatch[1] + rootMatch[2];
2756
- collapsedBracket = rootMatch[2];
2757
- }
2758
- }
2759
-
2760
- // Escape HTML first
2761
- let result = displayText
2762
- .replace(RE_ESCAPE_AMP, '&amp;')
2763
- .replace(RE_ESCAPE_LT, '&lt;')
2764
- .replace(RE_ESCAPE_GT, '&gt;');
2765
-
2766
- // Punctuation FIRST (before other replacements can interfere)
2767
- result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
2768
-
2769
- // JSON keys - match "key" followed by :
2770
- // In properties context, all keys are treated as regular JSON keys
2771
- RE_JSON_KEYS.lastIndex = 0;
2772
- result = result.replace(RE_JSON_KEYS, (match, key, colon) => {
2773
- if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
2774
- return `<span class="geojson-key">"${key}"</span>${colon}`;
2775
- }
2776
- return `<span class="json-key">"${key}"</span>${colon}`;
2777
- });
2778
-
2779
- // Type values - "type": "Value" - but NOT inside properties context
2780
- if (context !== 'properties') {
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
- });
2787
- }
2788
-
2789
- // String values (not already wrapped in spans)
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>`;
2795
- }
2796
- return `${colon}${space}<span class="json-string">"${val}"</span>`;
2797
- });
2798
-
2799
- // Numbers after colon
2800
- RE_NUMBERS_COLON.lastIndex = 0;
2801
- result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
2802
-
2803
- // Numbers in arrays (after [ or ,)
2804
- RE_NUMBERS_ARRAY.lastIndex = 0;
2805
- result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
2806
-
2807
- // Standalone numbers at start of line (coordinates arrays)
2808
- RE_NUMBERS_START.lastIndex = 0;
2809
- result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
2810
-
2811
- // Booleans - use ::before for checkbox via CSS class
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
- });
2817
-
2818
- // Null
2819
- RE_NULL.lastIndex = 0;
2820
- result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
2821
-
2822
- // Collapsed bracket indicator
2823
- if (collapsedBracket) {
2824
- const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
2825
- result = result.replace(
2826
- new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
2827
- `<span class="${bracketClass}">${collapsedBracket}</span>`
2828
- );
2829
- }
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
- });
2848
-
2849
- // Note: visibility is now handled at line level (has-visibility class on .line element)
2850
-
2851
- return result;
2852
- }
2853
-
2854
- _validateGeoJSON(parsed) {
2855
- const errors = [];
2856
-
2857
- if (!parsed.features) return errors;
2858
-
2859
- parsed.features.forEach((feature, i) => {
2860
- if (feature.type !== 'Feature') {
2861
- errors.push(`features[${i}]: type must be "Feature"`);
2862
- }
2863
- if (feature.geometry && feature.geometry.type) {
2864
- if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2865
- errors.push(`features[${i}].geometry: invalid type "${feature.geometry.type}"`);
2866
- }
2867
- }
2868
- });
2869
-
2870
- return errors;
2871
- }
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
-
2945
- // ========== Public API ==========
2888
+ // ========== Public API ==========
2946
2889
 
2947
2890
  /**
2948
2891
  * Replace all features in the editor
@@ -2956,10 +2899,8 @@ class GeoJsonEditor extends HTMLElement {
2956
2899
  * @throws {Error} If input is invalid
2957
2900
  */
2958
2901
  set(input: FeatureInput, options: SetOptions = {}): void {
2959
- const features = this._normalizeToFeatures(input);
2960
- const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2961
- this.setValue(formatted, false); // Don't auto-collapse coordinates
2962
- this._applyCollapsedFromOptions(options, features);
2902
+ const features = normalizeToFeatures(input);
2903
+ this._setFeaturesInternal(features, options);
2963
2904
  }
2964
2905
 
2965
2906
  /**
@@ -2971,12 +2912,9 @@ class GeoJsonEditor extends HTMLElement {
2971
2912
  * @throws {Error} If input is invalid
2972
2913
  */
2973
2914
  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);
2915
+ const newFeatures = normalizeToFeatures(input);
2916
+ const allFeatures = [...this._parseFeatures(), ...newFeatures];
2917
+ this._setFeaturesInternal(allFeatures, options);
2980
2918
  }
2981
2919
 
2982
2920
  /**
@@ -2989,12 +2927,19 @@ class GeoJsonEditor extends HTMLElement {
2989
2927
  * @throws {Error} If input is invalid
2990
2928
  */
2991
2929
  insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
2992
- const newFeatures = this._normalizeToFeatures(input);
2930
+ const newFeatures = normalizeToFeatures(input);
2993
2931
  const features = this._parseFeatures();
2994
2932
  const idx = index < 0 ? features.length + index : index;
2995
2933
  features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
2934
+ this._setFeaturesInternal(features, options);
2935
+ }
2936
+
2937
+ /**
2938
+ * Internal method to set features with formatting and collapse options
2939
+ */
2940
+ private _setFeaturesInternal(features: Feature[], options: SetOptions): void {
2996
2941
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2997
- this.setValue(formatted, false); // Don't auto-collapse coordinates
2942
+ this.setValue(formatted, false);
2998
2943
  this._applyCollapsedFromOptions(options, features);
2999
2944
  }
3000
2945
 
@@ -3052,7 +2997,7 @@ class GeoJsonEditor extends HTMLElement {
3052
2997
  const blob = new Blob([json], { type: 'application/geo+json' });
3053
2998
  const url = URL.createObjectURL(blob);
3054
2999
 
3055
- const a = document.createElement('a');
3000
+ const a = _ce('a') as HTMLAnchorElement;
3056
3001
  a.href = url;
3057
3002
  a.download = filename;
3058
3003
  document.body.appendChild(a);
@@ -3075,12 +3020,12 @@ class GeoJsonEditor extends HTMLElement {
3075
3020
  */
3076
3021
  open(options: SetOptions = {}): Promise<boolean> {
3077
3022
  return new Promise((resolve) => {
3078
- const input = document.createElement('input');
3023
+ const input = _ce('input') as HTMLInputElement;
3079
3024
  input.type = 'file';
3080
3025
  input.accept = '.geojson,.json,application/geo+json,application/json';
3081
3026
  input.style.display = 'none';
3082
3027
 
3083
- input.addEventListener('change', (e: Event) => {
3028
+ input.addEventListener('change', (e) => {
3084
3029
  const file = (e.target as HTMLInputElement).files?.[0];
3085
3030
  if (!file) {
3086
3031
  document.body.removeChild(input);
@@ -3095,7 +3040,7 @@ class GeoJsonEditor extends HTMLElement {
3095
3040
  const parsed = JSON.parse(content);
3096
3041
 
3097
3042
  // Normalize and validate features
3098
- const features = this._normalizeToFeatures(parsed);
3043
+ const features = normalizeToFeatures(parsed);
3099
3044
 
3100
3045
  // Load features into editor
3101
3046
  this._saveToHistory('open');
@@ -3128,7 +3073,7 @@ class GeoJsonEditor extends HTMLElement {
3128
3073
  });
3129
3074
  }
3130
3075
 
3131
- _parseFeatures() {
3076
+ private _parseFeatures() {
3132
3077
  try {
3133
3078
  const content = this.lines.join('\n');
3134
3079
  if (!content.trim()) return [];