@softwarity/geojson-editor 1.0.17 → 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
+ }
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
+ }
1568
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,10 +1950,10 @@ 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);
1829
1957
 
1830
1958
  // Check if we're on a collapsed node's opening line
1831
1959
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
@@ -1836,7 +1964,7 @@ class GeoJsonEditor extends HTMLElement {
1836
1964
 
1837
1965
  // If on collapsed node opening line and cursor is at/after the bracket, jump to closing line
1838
1966
  if (onCollapsed) {
1839
- const bracketPos = line.search(/[{\[]/);
1967
+ const bracketPos = line.search(RE_BRACKET_POS);
1840
1968
  if (bracketPos >= 0 && pos >= bracketPos) {
1841
1969
  this.cursorLine = onCollapsed.endLine;
1842
1970
  this.cursorColumn = (this.lines[this.cursorLine] || '').length;
@@ -1884,7 +2012,7 @@ class GeoJsonEditor extends HTMLElement {
1884
2012
  // Jump to opening line, after the bracket
1885
2013
  this.cursorLine = onClosingLine.startLine;
1886
2014
  const openLine = this.lines[this.cursorLine] || '';
1887
- const openBracketPos = openLine.search(/[{\[]/);
2015
+ const openBracketPos = openLine.search(RE_BRACKET_POS);
1888
2016
  this.cursorColumn = openBracketPos >= 0 ? openBracketPos : 0;
1889
2017
  this._invalidateRenderCache();
1890
2018
  this._scrollToCursor();
@@ -1931,7 +2059,7 @@ class GeoJsonEditor extends HTMLElement {
1931
2059
  /**
1932
2060
  * Handle Home/End with optional selection
1933
2061
  */
1934
- _handleHomeEnd(key, isShift, onClosingLine) {
2062
+ private _handleHomeEnd(key: string, isShift: boolean, onClosingLine: CollapsedNodeInfo | null): void {
1935
2063
  // Start selection if shift is pressed and no selection exists
1936
2064
  if (isShift && !this.selectionStart) {
1937
2065
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
@@ -1964,7 +2092,7 @@ class GeoJsonEditor extends HTMLElement {
1964
2092
  /**
1965
2093
  * Select all content
1966
2094
  */
1967
- _selectAll() {
2095
+ private _selectAll() {
1968
2096
  this.selectionStart = { line: 0, column: 0 };
1969
2097
  const lastLine = this.lines.length - 1;
1970
2098
  this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
@@ -1979,11 +2107,10 @@ class GeoJsonEditor extends HTMLElement {
1979
2107
  /**
1980
2108
  * Get selected text
1981
2109
  */
1982
- _getSelectedText() {
1983
- if (!this.selectionStart || !this.selectionEnd) return '';
1984
-
1985
- const { start, end } = this._normalizeSelection();
1986
- if (!start || !end) return '';
2110
+ private _getSelectedText(): string {
2111
+ const sel = this._normalizeSelection();
2112
+ if (!sel) return '';
2113
+ const { start, end } = sel;
1987
2114
 
1988
2115
  if (start.line === end.line) {
1989
2116
  return this.lines[start.line].substring(start.column, end.column);
@@ -2001,14 +2128,14 @@ class GeoJsonEditor extends HTMLElement {
2001
2128
  /**
2002
2129
  * Normalize selection so start is before end
2003
2130
  */
2004
- _normalizeSelection() {
2131
+ private _normalizeSelection(): { start: CursorPosition; end: CursorPosition } | null {
2005
2132
  if (!this.selectionStart || !this.selectionEnd) {
2006
- return { start: null, end: null };
2133
+ return null;
2007
2134
  }
2008
-
2135
+
2009
2136
  const s = this.selectionStart;
2010
2137
  const e = this.selectionEnd;
2011
-
2138
+
2012
2139
  if (s.line < e.line || (s.line === e.line && s.column <= e.column)) {
2013
2140
  return { start: s, end: e };
2014
2141
  } else {
@@ -2019,7 +2146,7 @@ class GeoJsonEditor extends HTMLElement {
2019
2146
  /**
2020
2147
  * Check if there is an active selection
2021
2148
  */
2022
- _hasSelection() {
2149
+ private _hasSelection() {
2023
2150
  if (!this.selectionStart || !this.selectionEnd) return false;
2024
2151
  return this.selectionStart.line !== this.selectionEnd.line ||
2025
2152
  this.selectionStart.column !== this.selectionEnd.column;
@@ -2028,7 +2155,7 @@ class GeoJsonEditor extends HTMLElement {
2028
2155
  /**
2029
2156
  * Clear the current selection
2030
2157
  */
2031
- _clearSelection() {
2158
+ private _clearSelection() {
2032
2159
  this.selectionStart = null;
2033
2160
  this.selectionEnd = null;
2034
2161
  }
@@ -2036,13 +2163,13 @@ class GeoJsonEditor extends HTMLElement {
2036
2163
  /**
2037
2164
  * Delete selected text
2038
2165
  */
2039
- _deleteSelection() {
2040
- if (!this._hasSelection()) return false;
2166
+ private _deleteSelection(): boolean {
2167
+ const sel = this._normalizeSelection();
2168
+ if (!sel) return false;
2169
+ const { start, end } = sel;
2041
2170
 
2042
2171
  this._saveToHistory('delete');
2043
2172
 
2044
- const { start, end } = this._normalizeSelection();
2045
-
2046
2173
  if (start.line === end.line) {
2047
2174
  // Single line selection
2048
2175
  const line = this.lines[start.line];
@@ -2063,7 +2190,7 @@ class GeoJsonEditor extends HTMLElement {
2063
2190
  return true;
2064
2191
  }
2065
2192
 
2066
- insertText(text) {
2193
+ insertText(text: string): void {
2067
2194
  // Delete selection first if any
2068
2195
  if (this._hasSelection()) {
2069
2196
  this._deleteSelection();
@@ -2084,7 +2211,7 @@ class GeoJsonEditor extends HTMLElement {
2084
2211
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
2085
2212
  if (onCollapsed) {
2086
2213
  const line = this.lines[this.cursorLine];
2087
- const bracketPos = line.search(/[{\[]/);
2214
+ const bracketPos = line.search(RE_BRACKET_POS);
2088
2215
  if (this.cursorColumn > bracketPos) return;
2089
2216
  }
2090
2217
 
@@ -2106,9 +2233,9 @@ class GeoJsonEditor extends HTMLElement {
2106
2233
  this.formatAndUpdate();
2107
2234
  }
2108
2235
 
2109
- handlePaste(e) {
2236
+ handlePaste(e: ClipboardEvent): void {
2110
2237
  e.preventDefault();
2111
- const text = e.clipboardData.getData('text/plain');
2238
+ const text = e.clipboardData?.getData('text/plain');
2112
2239
  if (!text) return;
2113
2240
 
2114
2241
  const wasEmpty = this.lines.length === 0;
@@ -2116,7 +2243,7 @@ class GeoJsonEditor extends HTMLElement {
2116
2243
  // Try to parse as GeoJSON and normalize
2117
2244
  try {
2118
2245
  const parsed = JSON.parse(text);
2119
- const features = this._normalizeToFeatures(parsed);
2246
+ const features = normalizeToFeatures(parsed);
2120
2247
  // Valid GeoJSON - insert formatted features
2121
2248
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2122
2249
  this.insertText(formatted);
@@ -2130,14 +2257,15 @@ class GeoJsonEditor extends HTMLElement {
2130
2257
  // Cancel pending render, collapse first, then render once
2131
2258
  if (this.renderTimer) {
2132
2259
  cancelAnimationFrame(this.renderTimer);
2133
- this.renderTimer = null;
2260
+ this.renderTimer = undefined;
2134
2261
  }
2135
2262
  this.autoCollapseCoordinates();
2136
2263
  }
2137
2264
  }
2138
2265
 
2139
- handleCopy(e) {
2266
+ handleCopy(e: ClipboardEvent): void {
2140
2267
  e.preventDefault();
2268
+ if (!e.clipboardData) return;
2141
2269
  // Copy selected text if there's a selection, otherwise copy all
2142
2270
  if (this._hasSelection()) {
2143
2271
  e.clipboardData.setData('text/plain', this._getSelectedText());
@@ -2146,8 +2274,9 @@ class GeoJsonEditor extends HTMLElement {
2146
2274
  }
2147
2275
  }
2148
2276
 
2149
- handleCut(e) {
2277
+ handleCut(e: ClipboardEvent): void {
2150
2278
  e.preventDefault();
2279
+ if (!e.clipboardData) return;
2151
2280
  if (this._hasSelection()) {
2152
2281
  e.clipboardData.setData('text/plain', this._getSelectedText());
2153
2282
  this._saveToHistory('cut');
@@ -2167,9 +2296,10 @@ class GeoJsonEditor extends HTMLElement {
2167
2296
  /**
2168
2297
  * Get line/column position from mouse event
2169
2298
  */
2170
- _getPositionFromClick(e) {
2171
- const viewport = this.shadowRoot.getElementById('viewport');
2172
- 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 };
2173
2303
  const rect = viewport.getBoundingClientRect();
2174
2304
 
2175
2305
  const paddingTop = 8;
@@ -2210,28 +2340,34 @@ class GeoJsonEditor extends HTMLElement {
2210
2340
 
2211
2341
  // ========== Gutter Interactions ==========
2212
2342
 
2213
- handleGutterClick(e) {
2343
+ handleGutterClick(e: MouseEvent): void {
2344
+ const target = e.target as HTMLElement;
2345
+ if (!target) return;
2346
+
2214
2347
  // Visibility button in gutter
2215
- const visBtn = e.target.closest('.visibility-button');
2348
+ const visBtn = target.closest('.visibility-button') as HTMLElement | null;
2216
2349
  if (visBtn) {
2217
2350
  this.toggleFeatureVisibility(visBtn.dataset.featureKey);
2218
2351
  return;
2219
2352
  }
2220
-
2353
+
2221
2354
  // Collapse button in gutter
2222
- if (e.target.classList.contains('collapse-button')) {
2223
- const nodeId = e.target.dataset.nodeId;
2224
- this.toggleCollapse(nodeId);
2355
+ if (target.classList.contains('collapse-button')) {
2356
+ const nodeId = target.dataset.nodeId;
2357
+ if (nodeId) this.toggleCollapse(nodeId);
2225
2358
  return;
2226
2359
  }
2227
2360
  }
2228
2361
 
2229
- handleEditorClick(e) {
2362
+ handleEditorClick(e: MouseEvent): void {
2363
+ const target = e.target as HTMLElement;
2364
+ if (!target) return;
2365
+
2230
2366
  // Unblock render now that click is being processed
2231
2367
  this._blockRender = false;
2232
2368
 
2233
2369
  // Line-level visibility button (pseudo-element ::before on .line.has-visibility)
2234
- const lineEl = e.target.closest('.line.has-visibility');
2370
+ const lineEl = target.closest('.line.has-visibility') as HTMLElement | null;
2235
2371
  if (lineEl) {
2236
2372
  const rect = lineEl.getBoundingClientRect();
2237
2373
  const clickX = e.clientX - rect.left;
@@ -2245,42 +2381,44 @@ class GeoJsonEditor extends HTMLElement {
2245
2381
  return;
2246
2382
  }
2247
2383
  }
2248
-
2384
+
2249
2385
  // Inline color swatch (pseudo-element positioned with left: -8px)
2250
- if (e.target.classList.contains('json-color')) {
2251
- const rect = e.target.getBoundingClientRect();
2386
+ if (target.classList.contains('json-color')) {
2387
+ const rect = target.getBoundingClientRect();
2252
2388
  const clickX = e.clientX - rect.left;
2253
2389
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
2254
2390
  if (clickX < 0 && clickX >= -8) {
2255
2391
  e.preventDefault();
2256
2392
  e.stopPropagation();
2257
- const color = e.target.dataset.color;
2258
- const targetLineEl = e.target.closest('.line');
2393
+ const color = target.dataset.color;
2394
+ const targetLineEl = target.closest('.line') as HTMLElement | null;
2259
2395
  if (targetLineEl) {
2260
- const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
2396
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
2261
2397
  const line = this.lines[lineIndex];
2262
- const match = line.match(/"([\w-]+)"\s*:\s*"#/);
2263
- if (match) {
2264
- 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]);
2265
2403
  }
2266
2404
  }
2267
2405
  return;
2268
2406
  }
2269
2407
  }
2270
-
2408
+
2271
2409
  // Inline boolean checkbox (pseudo-element positioned with left: -8px)
2272
- if (e.target.classList.contains('json-boolean')) {
2273
- const rect = e.target.getBoundingClientRect();
2410
+ if (target.classList.contains('json-boolean')) {
2411
+ const rect = target.getBoundingClientRect();
2274
2412
  const clickX = e.clientX - rect.left;
2275
2413
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
2276
2414
  if (clickX < 0 && clickX >= -8) {
2277
2415
  e.preventDefault();
2278
2416
  e.stopPropagation();
2279
- const targetLineEl = e.target.closest('.line');
2417
+ const targetLineEl = target.closest('.line') as HTMLElement | null;
2280
2418
  if (targetLineEl) {
2281
- const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
2419
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
2282
2420
  const line = this.lines[lineIndex];
2283
- const match = line.match(/"([\w-]+)"\s*:\s*(true|false)/);
2421
+ const match = line.match(RE_ATTR_AND_BOOL_VALUE);
2284
2422
  if (match) {
2285
2423
  const currentValue = match[2] === 'true';
2286
2424
  this.updateBooleanValue(lineIndex, !currentValue, match[1]);
@@ -2293,7 +2431,7 @@ class GeoJsonEditor extends HTMLElement {
2293
2431
 
2294
2432
  // ========== Collapse/Expand ==========
2295
2433
 
2296
- toggleCollapse(nodeId) {
2434
+ toggleCollapse(nodeId: string): void {
2297
2435
  if (this.collapsedNodes.has(nodeId)) {
2298
2436
  this.collapsedNodes.delete(nodeId);
2299
2437
  } else {
@@ -2312,10 +2450,8 @@ class GeoJsonEditor extends HTMLElement {
2312
2450
 
2313
2451
  /**
2314
2452
  * Helper to apply collapsed option from API methods
2315
- * @param {object} options - Options object with optional collapsed property
2316
- * @param {array} features - Features array for function mode
2317
2453
  */
2318
- _applyCollapsedFromOptions(options, features) {
2454
+ private _applyCollapsedFromOptions(options: SetOptions, features: Feature[]): void {
2319
2455
  const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2320
2456
  if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2321
2457
  this._applyCollapsedOption(collapsed, features);
@@ -2324,10 +2460,8 @@ class GeoJsonEditor extends HTMLElement {
2324
2460
 
2325
2461
  /**
2326
2462
  * Apply collapsed option to nodes
2327
- * @param {string[]|function} collapsed - Attributes to collapse or function returning them
2328
- * @param {array} features - Features array for function mode (optional)
2329
2463
  */
2330
- _applyCollapsedOption(collapsed, features = null) {
2464
+ private _applyCollapsedOption(collapsed: string[] | ((feature: Feature | null, index: number) => string[]), features: Feature[] | null = null): void {
2331
2465
  const ranges = this._findCollapsibleRanges();
2332
2466
 
2333
2467
  // Group ranges by feature (root nodes)
@@ -2372,7 +2506,8 @@ class GeoJsonEditor extends HTMLElement {
2372
2506
 
2373
2507
  // ========== Feature Visibility ==========
2374
2508
 
2375
- toggleFeatureVisibility(featureKey) {
2509
+ toggleFeatureVisibility(featureKey: string | undefined): void {
2510
+ if (!featureKey) return;
2376
2511
  if (this.hiddenFeatures.has(featureKey)) {
2377
2512
  this.hiddenFeatures.delete(featureKey);
2378
2513
  } else {
@@ -2386,17 +2521,17 @@ class GeoJsonEditor extends HTMLElement {
2386
2521
  }
2387
2522
 
2388
2523
  // ========== Color Picker ==========
2389
-
2390
- showColorPicker(indicator, line, currentColor, attributeName) {
2524
+
2525
+ showColorPicker(indicator: HTMLElement, line: number, currentColor: string, attributeName: string) {
2391
2526
  // Remove existing picker and anchor
2392
2527
  const existing = document.querySelector('.geojson-color-picker-anchor');
2393
2528
  if (existing) {
2394
2529
  existing.remove();
2395
2530
  }
2396
-
2531
+
2397
2532
  // Create an anchor element at the pseudo-element position
2398
2533
  // The browser will position the color picker popup relative to this
2399
- const anchor = document.createElement('div');
2534
+ const anchor = _ce('div');
2400
2535
  anchor.className = 'geojson-color-picker-anchor';
2401
2536
  const rect = indicator.getBoundingClientRect();
2402
2537
  anchor.style.cssText = `
@@ -2408,10 +2543,19 @@ class GeoJsonEditor extends HTMLElement {
2408
2543
  z-index: 9998;
2409
2544
  `;
2410
2545
  document.body.appendChild(anchor);
2411
-
2412
- const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
2546
+
2547
+ const colorInput = _ce('input') as HTMLInputElement & { _closeListener?: EventListener };
2413
2548
  colorInput.type = 'color';
2414
- 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;
2415
2559
  colorInput.className = 'geojson-color-picker-input';
2416
2560
 
2417
2561
  // Position the color input inside the anchor
@@ -2428,7 +2572,7 @@ class GeoJsonEditor extends HTMLElement {
2428
2572
  `;
2429
2573
  anchor.appendChild(colorInput);
2430
2574
 
2431
- colorInput.addEventListener('input', (e: Event) => {
2575
+ colorInput.addEventListener('input', (e) => {
2432
2576
  this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
2433
2577
  });
2434
2578
 
@@ -2440,7 +2584,7 @@ class GeoJsonEditor extends HTMLElement {
2440
2584
  };
2441
2585
 
2442
2586
  colorInput._closeListener = closeOnClickOutside;
2443
-
2587
+
2444
2588
  setTimeout(() => {
2445
2589
  document.addEventListener('click', closeOnClickOutside, true);
2446
2590
  }, 100);
@@ -2449,17 +2593,18 @@ class GeoJsonEditor extends HTMLElement {
2449
2593
  colorInput.click();
2450
2594
  }
2451
2595
 
2452
- updateColorValue(line, newColor, attributeName) {
2453
- 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]+)"`);
2454
2599
  this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
2455
-
2600
+
2456
2601
  // Use updateView to preserve collapsed state (line count didn't change)
2457
2602
  this.updateView();
2458
2603
  this.scheduleRender();
2459
2604
  this.emitChange();
2460
2605
  }
2461
2606
 
2462
- updateBooleanValue(line, newValue, attributeName) {
2607
+ updateBooleanValue(line: number, newValue: boolean, attributeName: string): void {
2463
2608
  const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
2464
2609
  this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": ${newValue}`);
2465
2610
 
@@ -2501,14 +2646,14 @@ class GeoJsonEditor extends HTMLElement {
2501
2646
 
2502
2647
  // Filter hidden features
2503
2648
  if (this.hiddenFeatures.size > 0) {
2504
- parsed.features = parsed.features.filter((feature) => {
2505
- const key = this._getFeatureKey(feature);
2506
- 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;
2507
2652
  });
2508
2653
  }
2509
2654
 
2510
2655
  // Validate
2511
- const errors = this._validateGeoJSON(parsed);
2656
+ const errors = validateGeoJSON(parsed);
2512
2657
 
2513
2658
  if (errors.length > 0) {
2514
2659
  this.dispatchEvent(new CustomEvent('error', {
@@ -2525,7 +2670,7 @@ class GeoJsonEditor extends HTMLElement {
2525
2670
  }
2526
2671
  } catch (e) {
2527
2672
  this.dispatchEvent(new CustomEvent('error', {
2528
- detail: { error: e.message, content },
2673
+ detail: { error: e instanceof Error ? e.message : 'Unknown error', content },
2529
2674
  bubbles: true,
2530
2675
  composed: true
2531
2676
  }));
@@ -2533,56 +2678,48 @@ class GeoJsonEditor extends HTMLElement {
2533
2678
  }
2534
2679
 
2535
2680
  // ========== UI Updates ==========
2536
-
2537
- updateReadonly() {
2538
- const textarea = this.shadowRoot.getElementById('hiddenTextarea');
2539
- const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
2540
2681
 
2682
+ updateReadonly() {
2541
2683
  // Use readOnly instead of disabled to allow text selection for copying
2542
- if (textarea) (textarea as HTMLTextAreaElement).readOnly = this.readonly;
2543
- if (clearBtn) clearBtn.hidden = this.readonly;
2684
+ if (this._hiddenTextarea) this._hiddenTextarea.readOnly = this.readonly;
2685
+ if (this._clearBtn) this._clearBtn.hidden = this.readonly;
2544
2686
  }
2545
2687
 
2546
2688
  updatePlaceholderVisibility() {
2547
- const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2548
- if (placeholder) {
2549
- placeholder.style.display = this.lines.length > 0 ? 'none' : 'block';
2689
+ if (this._placeholderLayer) {
2690
+ this._placeholderLayer.style.display = this.lines.length > 0 ? 'none' : 'block';
2550
2691
  }
2551
2692
  }
2552
2693
 
2553
2694
  updatePlaceholderContent() {
2554
- const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2555
- if (placeholder) {
2556
- placeholder.textContent = this.placeholder;
2695
+ if (this._placeholderLayer) {
2696
+ this._placeholderLayer.textContent = this.placeholder;
2557
2697
  }
2558
2698
  this.updatePlaceholderVisibility();
2559
2699
  }
2560
2700
 
2561
2701
  updatePrefixSuffix() {
2562
- const prefix = this.shadowRoot.getElementById('editorPrefix');
2563
- const suffix = this.shadowRoot.getElementById('editorSuffix');
2564
-
2565
- if (prefix) prefix.textContent = this.prefix;
2566
- if (suffix) suffix.textContent = this.suffix;
2702
+ if (this._editorPrefix) this._editorPrefix.textContent = this.prefix;
2703
+ if (this._editorSuffix) this._editorSuffix.textContent = this.suffix;
2567
2704
  }
2568
2705
 
2569
2706
  // ========== Theme ==========
2570
2707
 
2571
2708
  updateThemeCSS() {
2572
2709
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2573
- const darkRule = this._parseSelectorToHostRule(darkSelector);
2574
-
2575
- let themeStyle = this.shadowRoot.getElementById('theme-styles');
2710
+ const darkRule = parseSelectorToHostRule(darkSelector);
2711
+
2712
+ let themeStyle = this._id('theme-styles') as HTMLStyleElement;
2576
2713
  if (!themeStyle) {
2577
- themeStyle = document.createElement('style');
2714
+ themeStyle = _ce('style') as HTMLStyleElement;
2578
2715
  themeStyle.id = 'theme-styles';
2579
- this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
2716
+ this.shadowRoot!.insertBefore(themeStyle, this.shadowRoot!.firstChild);
2580
2717
  }
2581
2718
 
2582
2719
  const darkDefaults = {
2583
2720
  bgColor: '#2b2b2b',
2584
2721
  textColor: '#a9b7c6',
2585
- caretColor: '#bbbbbb',
2722
+ caretColor: '#bbb',
2586
2723
  gutterBg: '#313335',
2587
2724
  gutterBorder: '#3c3f41',
2588
2725
  gutterText: '#606366',
@@ -2602,14 +2739,16 @@ class GeoJsonEditor extends HTMLElement {
2602
2739
  jsonKeyInvalid: '#ff6b68'
2603
2740
  };
2604
2741
 
2605
- const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
2606
- 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)
2607
2746
  .map(([k, v]) => `--${toKebab(k)}: ${v};`)
2608
2747
  .join('\n ');
2609
2748
 
2610
- const lightVars = generateVars(this.themes.light || {});
2749
+ const lightVars = generateVars(this.themes.light as Record<string, string | undefined> || {});
2611
2750
  const darkTheme = { ...darkDefaults, ...this.themes.dark };
2612
- const darkVars = generateVars(darkTheme);
2751
+ const darkVars = generateVars(darkTheme as Record<string, string | undefined>);
2613
2752
 
2614
2753
  let css = lightVars ? `:host {\n ${lightVars}\n }\n` : '';
2615
2754
  css += `${darkRule} {\n ${darkVars}\n }`;
@@ -2617,14 +2756,6 @@ class GeoJsonEditor extends HTMLElement {
2617
2756
  themeStyle.textContent = css;
2618
2757
  }
2619
2758
 
2620
- _parseSelectorToHostRule(selector) {
2621
- if (!selector) return ':host([data-color-scheme="dark"])';
2622
- if (selector.startsWith('.') && !selector.includes(' ')) {
2623
- return `:host(${selector})`;
2624
- }
2625
- return `:host-context(${selector})`;
2626
- }
2627
-
2628
2759
  setTheme(theme: ThemeSettings): void {
2629
2760
  if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2630
2761
  if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
@@ -2636,45 +2767,15 @@ class GeoJsonEditor extends HTMLElement {
2636
2767
  this.updateThemeCSS();
2637
2768
  }
2638
2769
 
2639
- // ========== Helper Methods ==========
2640
-
2641
- _getFeatureKey(feature) {
2642
- if (!feature) return null;
2643
- if (feature.id !== undefined) return `id:${feature.id}`;
2644
- if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
2645
-
2646
- const geomType = feature.geometry?.type || 'null';
2647
- const coords = JSON.stringify(feature.geometry?.coordinates || []);
2648
- let hash = 0;
2649
- for (let i = 0; i < coords.length; i++) {
2650
- hash = ((hash << 5) - hash) + coords.charCodeAt(i);
2651
- hash = hash & hash;
2652
- }
2653
- return `hash:${geomType}:${hash.toString(36)}`;
2654
- }
2655
-
2656
- _countBrackets(line, openBracket) {
2657
- const closeBracket = openBracket === '{' ? '}' : ']';
2658
- let open = 0, close = 0, inString = false, escape = false;
2659
-
2660
- for (const char of line) {
2661
- if (escape) { escape = false; continue; }
2662
- if (char === '\\' && inString) { escape = true; continue; }
2663
- if (char === '"') { inString = !inString; continue; }
2664
- if (!inString) {
2665
- if (char === openBracket) open++;
2666
- if (char === closeBracket) close++;
2667
- }
2668
- }
2669
-
2670
- return { open, close };
2770
+ getTheme(): ThemeSettings {
2771
+ return { ...this.themes };
2671
2772
  }
2672
2773
 
2673
2774
  /**
2674
2775
  * Find all collapsible ranges using the mappings built by _rebuildNodeIdMappings
2675
2776
  * This method only READS the existing mappings, it doesn't create new IDs
2676
2777
  */
2677
- _findCollapsibleRanges() {
2778
+ private _findCollapsibleRanges() {
2678
2779
  const ranges = [];
2679
2780
 
2680
2781
  // Simply iterate through the existing mappings
@@ -2686,13 +2787,13 @@ class GeoJsonEditor extends HTMLElement {
2686
2787
  if (!line) continue;
2687
2788
 
2688
2789
  // Match "key": { or "key": [
2689
- const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
2790
+ const kvMatch = line.match(RE_KV_MATCH);
2690
2791
  // Also match standalone { or [ (root Feature objects)
2691
- const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
2792
+ const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
2692
2793
 
2693
2794
  if (!kvMatch && !rootMatch) continue;
2694
-
2695
- const openBracket = kvMatch ? kvMatch[2] : rootMatch[1];
2795
+
2796
+ const openBracket = kvMatch ? kvMatch[2] : (rootMatch ? rootMatch[1] : '{');
2696
2797
 
2697
2798
  ranges.push({
2698
2799
  startLine: rangeInfo.startLine,
@@ -2710,20 +2811,20 @@ class GeoJsonEditor extends HTMLElement {
2710
2811
  return ranges;
2711
2812
  }
2712
2813
 
2713
- _findClosingLine(startLine, openBracket) {
2814
+ private _findClosingLine(startLine: number, openBracket: string): number {
2714
2815
  let depth = 1;
2715
2816
  const line = this.lines[startLine];
2716
2817
  const bracketPos = line.indexOf(openBracket);
2717
2818
 
2718
2819
  if (bracketPos !== -1) {
2719
2820
  const rest = line.substring(bracketPos + 1);
2720
- const counts = this._countBrackets(rest, openBracket);
2821
+ const counts = countBrackets(rest, openBracket);
2721
2822
  depth += counts.open - counts.close;
2722
2823
  if (depth === 0) return startLine;
2723
2824
  }
2724
2825
 
2725
2826
  for (let i = startLine + 1; i < this.lines.length; i++) {
2726
- const counts = this._countBrackets(this.lines[i], openBracket);
2827
+ const counts = countBrackets(this.lines[i], openBracket);
2727
2828
  depth += counts.open - counts.close;
2728
2829
  if (depth === 0) return i;
2729
2830
  }
@@ -2731,7 +2832,7 @@ class GeoJsonEditor extends HTMLElement {
2731
2832
  return -1;
2732
2833
  }
2733
2834
 
2734
- _buildContextMap() {
2835
+ private _buildContextMap() {
2735
2836
  // Memoization: return cached result if content hasn't changed
2736
2837
  const linesLength = this.lines.length;
2737
2838
  if (this._contextMapCache &&
@@ -2741,9 +2842,9 @@ class GeoJsonEditor extends HTMLElement {
2741
2842
  return this._contextMapCache;
2742
2843
  }
2743
2844
 
2744
- const contextMap = new Map();
2745
- const contextStack = [];
2746
- let pendingContext = null;
2845
+ const contextMap = new Map<number, string>();
2846
+ const contextStack: { context: string; isArray: boolean }[] = [];
2847
+ let pendingContext: string | null = null;
2747
2848
 
2748
2849
  for (let i = 0; i < linesLength; i++) {
2749
2850
  const line = this.lines[i];
@@ -2756,10 +2857,14 @@ class GeoJsonEditor extends HTMLElement {
2756
2857
  else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
2757
2858
 
2758
2859
  // Track brackets
2759
- const openBraces = (line.match(/\{/g) || []).length;
2760
- const closeBraces = (line.match(/\}/g) || []).length;
2761
- const openBrackets = (line.match(/\[/g) || []).length;
2762
- 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;
2763
2868
 
2764
2869
  for (let j = 0; j < openBraces + openBrackets; j++) {
2765
2870
  contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
@@ -2780,214 +2885,7 @@ class GeoJsonEditor extends HTMLElement {
2780
2885
  return contextMap;
2781
2886
  }
2782
2887
 
2783
- _highlightSyntax(text, context, meta) {
2784
- if (!text) return '';
2785
-
2786
- // For collapsed nodes, truncate the text at the opening bracket
2787
- let displayText = text;
2788
- let collapsedBracket = null;
2789
-
2790
- if (meta?.collapseButton?.isCollapsed) {
2791
- // Match "key": { or "key": [
2792
- const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
2793
- // Also match standalone { or [ (root Feature objects)
2794
- const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
2795
-
2796
- if (bracketMatch) {
2797
- displayText = bracketMatch[1] + bracketMatch[2];
2798
- collapsedBracket = bracketMatch[2];
2799
- } else if (rootMatch) {
2800
- displayText = rootMatch[1] + rootMatch[2];
2801
- collapsedBracket = rootMatch[2];
2802
- }
2803
- }
2804
-
2805
- // Escape HTML first
2806
- let result = displayText
2807
- .replace(RE_ESCAPE_AMP, '&amp;')
2808
- .replace(RE_ESCAPE_LT, '&lt;')
2809
- .replace(RE_ESCAPE_GT, '&gt;');
2810
-
2811
- // Punctuation FIRST (before other replacements can interfere)
2812
- result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
2813
-
2814
- // JSON keys - match "key" followed by :
2815
- // In properties context, all keys are treated as regular JSON keys
2816
- RE_JSON_KEYS.lastIndex = 0;
2817
- result = result.replace(RE_JSON_KEYS, (match, key, colon) => {
2818
- if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
2819
- return `<span class="geojson-key">"${key}"</span>${colon}`;
2820
- }
2821
- return `<span class="json-key">"${key}"</span>${colon}`;
2822
- });
2823
-
2824
- // Type values - "type": "Value" - but NOT inside properties context
2825
- if (context !== 'properties') {
2826
- RE_TYPE_VALUES.lastIndex = 0;
2827
- result = result.replace(RE_TYPE_VALUES, (match, space, type) => {
2828
- const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
2829
- const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
2830
- return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
2831
- });
2832
- }
2833
-
2834
- // String values (not already wrapped in spans)
2835
- RE_STRING_VALUES.lastIndex = 0;
2836
- result = result.replace(RE_STRING_VALUES, (match, colon, space, val) => {
2837
- if (match.includes('geojson-type') || match.includes('json-string')) return match;
2838
- if (RE_COLOR_HEX.test(val)) {
2839
- return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
2840
- }
2841
- return `${colon}${space}<span class="json-string">"${val}"</span>`;
2842
- });
2843
-
2844
- // Numbers after colon
2845
- RE_NUMBERS_COLON.lastIndex = 0;
2846
- result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
2847
-
2848
- // Numbers in arrays (after [ or ,)
2849
- RE_NUMBERS_ARRAY.lastIndex = 0;
2850
- result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
2851
-
2852
- // Standalone numbers at start of line (coordinates arrays)
2853
- RE_NUMBERS_START.lastIndex = 0;
2854
- result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
2855
-
2856
- // Booleans - use ::before for checkbox via CSS class
2857
- RE_BOOLEANS.lastIndex = 0;
2858
- result = result.replace(RE_BOOLEANS, (match, colon, space, val) => {
2859
- const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
2860
- return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
2861
- });
2862
-
2863
- // Null
2864
- RE_NULL.lastIndex = 0;
2865
- result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
2866
-
2867
- // Collapsed bracket indicator
2868
- if (collapsedBracket) {
2869
- const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
2870
- result = result.replace(
2871
- new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
2872
- `<span class="${bracketClass}">${collapsedBracket}</span>`
2873
- );
2874
- }
2875
-
2876
- // Mark unrecognized text as error
2877
- RE_UNRECOGNIZED.lastIndex = 0;
2878
- result = result.replace(RE_UNRECOGNIZED, (match, before, text, after) => {
2879
- if (!text || RE_WHITESPACE_ONLY.test(text)) return match;
2880
- // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
2881
- // Keep whitespace as-is, wrap any non-whitespace unrecognized token
2882
- const parts = text.split(RE_WHITESPACE_SPLIT);
2883
- let hasError = false;
2884
- const processed = parts.map(part => {
2885
- // If it's whitespace, keep it
2886
- if (RE_WHITESPACE_ONLY.test(part)) return part;
2887
- // Mark as error
2888
- hasError = true;
2889
- return `<span class="json-error">${part}</span>`;
2890
- }).join('');
2891
- return hasError ? before + processed + after : match;
2892
- });
2893
-
2894
- // Note: visibility is now handled at line level (has-visibility class on .line element)
2895
-
2896
- return result;
2897
- }
2898
-
2899
- _validateGeoJSON(parsed) {
2900
- const errors = [];
2901
-
2902
- if (!parsed.features) return errors;
2903
-
2904
- parsed.features.forEach((feature, i) => {
2905
- if (feature.type !== 'Feature') {
2906
- errors.push(`features[${i}]: type must be "Feature"`);
2907
- }
2908
- if (feature.geometry && feature.geometry.type) {
2909
- if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2910
- errors.push(`features[${i}].geometry: invalid type "${feature.geometry.type}"`);
2911
- }
2912
- }
2913
- });
2914
-
2915
- return errors;
2916
- }
2917
-
2918
- /**
2919
- * Validate a single feature object
2920
- * @param {object} feature - The feature to validate
2921
- * @throws {Error} If the feature is invalid
2922
- */
2923
- _validateFeature(feature) {
2924
- if (!feature || typeof feature !== 'object') {
2925
- throw new Error('Feature must be an object');
2926
- }
2927
- if (feature.type !== 'Feature') {
2928
- throw new Error('Feature type must be "Feature"');
2929
- }
2930
- if (!('geometry' in feature)) {
2931
- throw new Error('Feature must have a geometry property');
2932
- }
2933
- if (!('properties' in feature)) {
2934
- throw new Error('Feature must have a properties property');
2935
- }
2936
- if (feature.geometry !== null) {
2937
- if (!feature.geometry || typeof feature.geometry !== 'object') {
2938
- throw new Error('Feature geometry must be an object or null');
2939
- }
2940
- if (!feature.geometry.type) {
2941
- throw new Error('Feature geometry must have a type');
2942
- }
2943
- if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2944
- throw new Error(`Invalid geometry type: "${feature.geometry.type}"`);
2945
- }
2946
- if (!('coordinates' in feature.geometry)) {
2947
- throw new Error('Feature geometry must have coordinates');
2948
- }
2949
- }
2950
- if (feature.properties !== null && typeof feature.properties !== 'object') {
2951
- throw new Error('Feature properties must be an object or null');
2952
- }
2953
- }
2954
-
2955
- /**
2956
- * Normalize input to an array of features
2957
- * Accepts: FeatureCollection, Feature[], or single Feature
2958
- * @param {object|array} input - Input to normalize
2959
- * @returns {array} Array of features
2960
- * @throws {Error} If input is invalid
2961
- */
2962
- _normalizeToFeatures(input) {
2963
- let features = [];
2964
-
2965
- if (Array.isArray(input)) {
2966
- // Array of features
2967
- features = input;
2968
- } else if (input && typeof input === 'object') {
2969
- if (input.type === 'FeatureCollection' && Array.isArray(input.features)) {
2970
- // FeatureCollection
2971
- features = input.features;
2972
- } else if (input.type === 'Feature') {
2973
- // Single Feature
2974
- features = [input];
2975
- } else {
2976
- throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2977
- }
2978
- } else {
2979
- throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2980
- }
2981
-
2982
- // Validate each feature
2983
- for (const feature of features) {
2984
- this._validateFeature(feature);
2985
- }
2986
-
2987
- return features;
2988
- }
2989
-
2990
- // ========== Public API ==========
2888
+ // ========== Public API ==========
2991
2889
 
2992
2890
  /**
2993
2891
  * Replace all features in the editor
@@ -3001,10 +2899,8 @@ class GeoJsonEditor extends HTMLElement {
3001
2899
  * @throws {Error} If input is invalid
3002
2900
  */
3003
2901
  set(input: FeatureInput, options: SetOptions = {}): void {
3004
- const features = this._normalizeToFeatures(input);
3005
- const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
3006
- this.setValue(formatted, false); // Don't auto-collapse coordinates
3007
- this._applyCollapsedFromOptions(options, features);
2902
+ const features = normalizeToFeatures(input);
2903
+ this._setFeaturesInternal(features, options);
3008
2904
  }
3009
2905
 
3010
2906
  /**
@@ -3016,12 +2912,9 @@ class GeoJsonEditor extends HTMLElement {
3016
2912
  * @throws {Error} If input is invalid
3017
2913
  */
3018
2914
  add(input: FeatureInput, options: SetOptions = {}): void {
3019
- const newFeatures = this._normalizeToFeatures(input);
3020
- const existingFeatures = this._parseFeatures();
3021
- const allFeatures = [...existingFeatures, ...newFeatures];
3022
- const formatted = allFeatures.map(f => JSON.stringify(f, null, 2)).join(',\n');
3023
- this.setValue(formatted, false); // Don't auto-collapse coordinates
3024
- this._applyCollapsedFromOptions(options, allFeatures);
2915
+ const newFeatures = normalizeToFeatures(input);
2916
+ const allFeatures = [...this._parseFeatures(), ...newFeatures];
2917
+ this._setFeaturesInternal(allFeatures, options);
3025
2918
  }
3026
2919
 
3027
2920
  /**
@@ -3034,12 +2927,19 @@ class GeoJsonEditor extends HTMLElement {
3034
2927
  * @throws {Error} If input is invalid
3035
2928
  */
3036
2929
  insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
3037
- const newFeatures = this._normalizeToFeatures(input);
2930
+ const newFeatures = normalizeToFeatures(input);
3038
2931
  const features = this._parseFeatures();
3039
2932
  const idx = index < 0 ? features.length + index : index;
3040
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 {
3041
2941
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
3042
- this.setValue(formatted, false); // Don't auto-collapse coordinates
2942
+ this.setValue(formatted, false);
3043
2943
  this._applyCollapsedFromOptions(options, features);
3044
2944
  }
3045
2945
 
@@ -3097,7 +2997,7 @@ class GeoJsonEditor extends HTMLElement {
3097
2997
  const blob = new Blob([json], { type: 'application/geo+json' });
3098
2998
  const url = URL.createObjectURL(blob);
3099
2999
 
3100
- const a = document.createElement('a');
3000
+ const a = _ce('a') as HTMLAnchorElement;
3101
3001
  a.href = url;
3102
3002
  a.download = filename;
3103
3003
  document.body.appendChild(a);
@@ -3120,12 +3020,12 @@ class GeoJsonEditor extends HTMLElement {
3120
3020
  */
3121
3021
  open(options: SetOptions = {}): Promise<boolean> {
3122
3022
  return new Promise((resolve) => {
3123
- const input = document.createElement('input');
3023
+ const input = _ce('input') as HTMLInputElement;
3124
3024
  input.type = 'file';
3125
3025
  input.accept = '.geojson,.json,application/geo+json,application/json';
3126
3026
  input.style.display = 'none';
3127
3027
 
3128
- input.addEventListener('change', (e: Event) => {
3028
+ input.addEventListener('change', (e) => {
3129
3029
  const file = (e.target as HTMLInputElement).files?.[0];
3130
3030
  if (!file) {
3131
3031
  document.body.removeChild(input);
@@ -3140,7 +3040,7 @@ class GeoJsonEditor extends HTMLElement {
3140
3040
  const parsed = JSON.parse(content);
3141
3041
 
3142
3042
  // Normalize and validate features
3143
- const features = this._normalizeToFeatures(parsed);
3043
+ const features = normalizeToFeatures(parsed);
3144
3044
 
3145
3045
  // Load features into editor
3146
3046
  this._saveToHistory('open');
@@ -3173,7 +3073,7 @@ class GeoJsonEditor extends HTMLElement {
3173
3073
  });
3174
3074
  }
3175
3075
 
3176
- _parseFeatures() {
3076
+ private _parseFeatures() {
3177
3077
  try {
3178
3078
  const content = this.lines.join('\n');
3179
3079
  if (!content.trim()) return [];