@softwarity/geojson-editor 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -187,6 +66,7 @@ class GeoJsonEditor extends HTMLElement {
187
66
  private _nodeIdCounter: number = 0;
188
67
  private _lineToNodeId: Map<number, string> = new Map();
189
68
  private _nodeIdToLines: Map<string, NodeRangeInfo> = new Map();
69
+ private _openedNodeKeys: Set<string> = new Set(); // UniqueKeys (nodeKey:occurrence) that user opened
190
70
 
191
71
  // ========== Derived State (computed from model) ==========
192
72
  visibleLines: VisibleLine[] = [];
@@ -211,8 +91,8 @@ class GeoJsonEditor extends HTMLElement {
211
91
  selectionEnd: CursorPosition | null = null;
212
92
 
213
93
  // ========== Debounce ==========
214
- private renderTimer: number | null = null;
215
- private inputTimer: number | null = null;
94
+ private renderTimer: ReturnType<typeof setTimeout> | undefined = undefined;
95
+ private inputTimer: ReturnType<typeof setTimeout> | undefined = undefined;
216
96
 
217
97
  // ========== Theme ==========
218
98
  themes: ThemeSettings = { dark: {}, light: {} };
@@ -229,19 +109,43 @@ class GeoJsonEditor extends HTMLElement {
229
109
  private _isSelecting: boolean = false;
230
110
  private _isComposing: boolean = false;
231
111
  private _blockRender: boolean = false;
112
+ private _insertMode: boolean = true; // true = insert, false = overwrite
232
113
  private _charWidth: number | null = null;
233
114
  private _contextMapCache: Map<number, string> | null = null;
234
115
  private _contextMapLinesLength: number = 0;
235
116
  private _contextMapFirstLine: string | undefined = undefined;
236
117
  private _contextMapLastLine: string | undefined = undefined;
118
+ private _errorLinesCache: Set<number> | null = null;
119
+
120
+ // ========== Cached DOM Elements ==========
121
+ private _viewport: HTMLElement | null = null;
122
+ private _linesContainer: HTMLElement | null = null;
123
+ private _scrollContent: HTMLElement | null = null;
124
+ private _hiddenTextarea: HTMLTextAreaElement | null = null;
125
+ private _gutterContent: HTMLElement | null = null;
126
+ private _gutterScrollContent: HTMLElement | null = null;
127
+ private _gutterScroll: HTMLElement | null = null;
128
+ private _gutter: HTMLElement | null = null;
129
+ private _clearBtn: HTMLButtonElement | null = null;
130
+ private _editorWrapper: HTMLElement | null = null;
131
+ private _placeholderLayer: HTMLElement | null = null;
132
+ private _editorPrefix: HTMLElement | null = null;
133
+ private _editorSuffix: HTMLElement | null = null;
134
+ private _errorNav: HTMLElement | null = null;
135
+ private _errorCount: HTMLElement | null = null;
136
+ private _prevErrorBtn: HTMLButtonElement | null = null;
137
+ private _nextErrorBtn: HTMLButtonElement | null = null;
237
138
 
238
139
  constructor() {
239
140
  super();
240
141
  this.attachShadow({ mode: 'open' });
241
142
  }
242
143
 
144
+ // Alias for shadowRoot.getElementById (minification)
145
+ private _id(id: string) { return this.shadowRoot!.getElementById(id); }
146
+
243
147
  // ========== Render Cache ==========
244
- _invalidateRenderCache() {
148
+ private _invalidateRenderCache() {
245
149
  this._lastStartIndex = -1;
246
150
  this._lastEndIndex = -1;
247
151
  this._lastTotalLines = -1;
@@ -253,7 +157,7 @@ class GeoJsonEditor extends HTMLElement {
253
157
  * Create a snapshot of current editor state
254
158
  * @returns {Object} State snapshot
255
159
  */
256
- _createSnapshot() {
160
+ private _createSnapshot() {
257
161
  return {
258
162
  lines: [...this.lines],
259
163
  cursorLine: this.cursorLine,
@@ -264,9 +168,8 @@ class GeoJsonEditor extends HTMLElement {
264
168
 
265
169
  /**
266
170
  * Restore editor state from snapshot
267
- * @param {Object} snapshot - State to restore
268
171
  */
269
- _restoreSnapshot(snapshot) {
172
+ private _restoreSnapshot(snapshot: EditorSnapshot): void {
270
173
  this.lines = [...snapshot.lines];
271
174
  this.cursorLine = snapshot.cursorLine;
272
175
  this.cursorColumn = snapshot.cursorColumn;
@@ -281,7 +184,7 @@ class GeoJsonEditor extends HTMLElement {
281
184
  * Save current state to undo stack before making changes
282
185
  * @param {string} actionType - Type of action (insert, delete, paste, etc.)
283
186
  */
284
- _saveToHistory(actionType = 'edit') {
187
+ private _saveToHistory(actionType = 'edit') {
285
188
  const now = Date.now();
286
189
  const shouldGroup = (
287
190
  actionType === this._lastActionType &&
@@ -310,7 +213,7 @@ class GeoJsonEditor extends HTMLElement {
310
213
  * Undo last action
311
214
  * @returns {boolean} True if undo was performed
312
215
  */
313
- undo() {
216
+ undo(): boolean {
314
217
  if (this._undoStack.length === 0) return false;
315
218
 
316
219
  // Save current state to redo stack
@@ -318,7 +221,7 @@ class GeoJsonEditor extends HTMLElement {
318
221
 
319
222
  // Restore previous state
320
223
  const previousState = this._undoStack.pop();
321
- this._restoreSnapshot(previousState);
224
+ if (previousState) this._restoreSnapshot(previousState);
322
225
 
323
226
  // Reset action tracking
324
227
  this._lastActionType = null;
@@ -331,7 +234,7 @@ class GeoJsonEditor extends HTMLElement {
331
234
  * Redo previously undone action
332
235
  * @returns {boolean} True if redo was performed
333
236
  */
334
- redo() {
237
+ redo(): boolean {
335
238
  if (this._redoStack.length === 0) return false;
336
239
 
337
240
  // Save current state to undo stack
@@ -339,7 +242,7 @@ class GeoJsonEditor extends HTMLElement {
339
242
 
340
243
  // Restore next state
341
244
  const nextState = this._redoStack.pop();
342
- this._restoreSnapshot(nextState);
245
+ if (nextState) this._restoreSnapshot(nextState);
343
246
 
344
247
  // Reset action tracking
345
248
  this._lastActionType = null;
@@ -351,7 +254,7 @@ class GeoJsonEditor extends HTMLElement {
351
254
  /**
352
255
  * Clear undo/redo history
353
256
  */
354
- clearHistory() {
257
+ clearHistory(): void {
355
258
  this._undoStack = [];
356
259
  this._redoStack = [];
357
260
  this._lastActionType = null;
@@ -362,7 +265,7 @@ class GeoJsonEditor extends HTMLElement {
362
265
  * Check if undo is available
363
266
  * @returns {boolean}
364
267
  */
365
- canUndo() {
268
+ canUndo(): boolean {
366
269
  return this._undoStack.length > 0;
367
270
  }
368
271
 
@@ -370,21 +273,19 @@ class GeoJsonEditor extends HTMLElement {
370
273
  * Check if redo is available
371
274
  * @returns {boolean}
372
275
  */
373
- canRedo() {
276
+ canRedo(): boolean {
374
277
  return this._redoStack.length > 0;
375
278
  }
376
279
 
377
280
  // ========== Unique ID Generation ==========
378
- _generateNodeId() {
281
+ private _generateNodeId() {
379
282
  return `node_${++this._nodeIdCounter}`;
380
283
  }
381
284
 
382
285
  /**
383
286
  * 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
287
  */
387
- _getCollapsedRangeForLine(lineIndex) {
288
+ private _getCollapsedRangeForLine(lineIndex: number): CollapsedNodeInfo | null {
388
289
  for (const [nodeId, info] of this._nodeIdToLines) {
389
290
  // Lines strictly between opening and closing are hidden
390
291
  if (this.collapsedNodes.has(nodeId) && lineIndex > info.startLine && lineIndex < info.endLine) {
@@ -396,10 +297,8 @@ class GeoJsonEditor extends HTMLElement {
396
297
 
397
298
  /**
398
299
  * 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
300
  */
402
- _getCollapsedClosingLine(lineIndex) {
301
+ private _getCollapsedClosingLine(lineIndex: number): CollapsedNodeInfo | null {
403
302
  for (const [nodeId, info] of this._nodeIdToLines) {
404
303
  if (this.collapsedNodes.has(nodeId) && lineIndex === info.endLine) {
405
304
  return { nodeId, ...info };
@@ -410,10 +309,8 @@ class GeoJsonEditor extends HTMLElement {
410
309
 
411
310
  /**
412
311
  * 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
312
  */
416
- _getClosingBracketPos(line) {
313
+ private _getClosingBracketPos(line: string): number {
417
314
  // Find the last ] or } on the line
418
315
  const lastBracket = Math.max(line.lastIndexOf(']'), line.lastIndexOf('}'));
419
316
  return lastBracket;
@@ -421,29 +318,12 @@ class GeoJsonEditor extends HTMLElement {
421
318
 
422
319
  /**
423
320
  * 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
321
  */
427
- _getCollapsedNodeAtLine(lineIndex) {
322
+ private _getCollapsedNodeAtLine(lineIndex: number): CollapsedNodeInfo | null {
428
323
  const nodeId = this._lineToNodeId.get(lineIndex);
429
324
  if (nodeId && this.collapsedNodes.has(nodeId)) {
430
325
  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 };
326
+ if (info) return { nodeId, ...info };
447
327
  }
448
328
  return null;
449
329
  }
@@ -451,16 +331,14 @@ class GeoJsonEditor extends HTMLElement {
451
331
  /**
452
332
  * Find the innermost expanded node that contains the given line
453
333
  * 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
334
  */
457
- _getContainingExpandedNode(lineIndex) {
458
- let bestMatch = null;
459
-
335
+ private _getContainingExpandedNode(lineIndex: number): CollapsedNodeInfo | null {
336
+ let bestMatch: CollapsedNodeInfo | null = null;
337
+
460
338
  for (const [nodeId, info] of this._nodeIdToLines) {
461
339
  // Skip collapsed nodes
462
340
  if (this.collapsedNodes.has(nodeId)) continue;
463
-
341
+
464
342
  // Check if line is within this node's range
465
343
  if (lineIndex >= info.startLine && lineIndex <= info.endLine) {
466
344
  // Prefer the innermost (smallest) containing node
@@ -469,15 +347,14 @@ class GeoJsonEditor extends HTMLElement {
469
347
  }
470
348
  }
471
349
  }
472
-
350
+
473
351
  return bestMatch;
474
352
  }
475
353
 
476
354
  /**
477
355
  * Delete an entire collapsed node (opening line to closing line)
478
- * @param {Object} range - The range info {startLine, endLine}
479
356
  */
480
- _deleteCollapsedNode(range) {
357
+ private _deleteCollapsedNode(range: CollapsedNodeInfo): void {
481
358
  this._saveToHistory('delete');
482
359
 
483
360
  // Remove all lines from startLine to endLine
@@ -495,75 +372,67 @@ class GeoJsonEditor extends HTMLElement {
495
372
  * Rebuild nodeId mappings after content changes
496
373
  * Preserves collapsed state by matching nodeKey + sequential occurrence
497
374
  */
498
- _rebuildNodeIdMappings() {
499
- // Save old state to try to preserve collapsed nodes
500
- const oldCollapsed = new Set(this.collapsedNodes);
501
- const oldNodeKeyMap = new Map(); // nodeId -> nodeKey
502
- for (const [nodeId, info] of this._nodeIdToLines) {
503
- if (info.nodeKey) oldNodeKeyMap.set(nodeId, info.nodeKey);
504
- }
505
-
506
- // Build list of collapsed nodeKeys for matching
507
- const collapsedNodeKeys = [];
508
- for (const nodeId of oldCollapsed) {
509
- const nodeKey = oldNodeKeyMap.get(nodeId);
510
- if (nodeKey) collapsedNodeKeys.push(nodeKey);
375
+ private _rebuildNodeIdMappings() {
376
+ // Save collapsed uniqueKeys from old state
377
+ const collapsedUniqueKeys = new Set<string>();
378
+ for (const nodeId of this.collapsedNodes) {
379
+ const info = this._nodeIdToLines.get(nodeId);
380
+ if (info?.uniqueKey) collapsedUniqueKeys.add(info.uniqueKey);
511
381
  }
512
-
382
+
513
383
  // Reset mappings
514
384
  this._nodeIdCounter = 0;
515
385
  this._lineToNodeId.clear();
516
386
  this._nodeIdToLines.clear();
517
387
  this.collapsedNodes.clear();
518
-
519
- // Track occurrences of each nodeKey for matching
520
- const nodeKeyOccurrences = new Map();
521
-
388
+
389
+ // Track occurrences of each nodeKey
390
+ const nodeKeyOccurrences = new Map<string, number>();
391
+
522
392
  // Assign fresh IDs to all collapsible nodes
523
393
  for (let i = 0; i < this.lines.length; i++) {
524
394
  const line = this.lines[i];
525
-
395
+
526
396
  // Match "key": { or "key": [
527
- const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
397
+ const kvMatch = line.match(RE_KV_MATCH);
528
398
  // Also match standalone { or {, (root Feature objects)
529
- const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
530
-
399
+ const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
400
+
531
401
  if (!kvMatch && !rootMatch) continue;
532
-
533
- let nodeKey, openBracket;
534
-
402
+
403
+ let nodeKey: string;
404
+ let openBracket: string;
405
+
535
406
  if (kvMatch) {
536
407
  nodeKey = kvMatch[1];
537
408
  openBracket = kvMatch[2];
538
- } else {
409
+ } else if (rootMatch) {
539
410
  // Root object - use special key based on line number and bracket type
540
411
  openBracket = rootMatch[1];
541
412
  nodeKey = `__root_${openBracket}_${i}`;
413
+ } else {
414
+ continue;
542
415
  }
543
-
416
+
544
417
  // Check if closes on same line
545
418
  const rest = line.substring(line.indexOf(openBracket) + 1);
546
- const counts = this._countBrackets(rest, openBracket);
419
+ const counts = countBrackets(rest, openBracket);
547
420
  if (counts.close > counts.open) continue;
548
-
421
+
549
422
  const endLine = this._findClosingLine(i, openBracket);
550
423
  if (endLine === -1 || endLine === i) continue;
551
-
552
- // Generate unique ID for this node
424
+
425
+ // Generate unique ID and unique key for this node
553
426
  const nodeId = this._generateNodeId();
554
-
555
- this._lineToNodeId.set(i, nodeId);
556
- this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, isRootFeature: !!rootMatch });
557
-
558
- // Track occurrence of this nodeKey
559
427
  const occurrence = nodeKeyOccurrences.get(nodeKey) || 0;
560
428
  nodeKeyOccurrences.set(nodeKey, occurrence + 1);
561
-
562
- // Check if this nodeKey was previously collapsed
563
- const keyIndex = collapsedNodeKeys.indexOf(nodeKey);
564
- if (keyIndex !== -1) {
565
- // Remove from list so we don't match it again
566
- collapsedNodeKeys.splice(keyIndex, 1);
429
+ const uniqueKey = `${nodeKey}:${occurrence}`;
430
+
431
+ this._lineToNodeId.set(i, nodeId);
432
+ this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, uniqueKey, isRootFeature: !!rootMatch });
433
+
434
+ // Restore collapsed state if was collapsed and not explicitly opened
435
+ if (collapsedUniqueKeys.has(uniqueKey) && !this._openedNodeKeys.has(uniqueKey)) {
567
436
  this.collapsedNodes.add(nodeId);
568
437
  }
569
438
  }
@@ -577,10 +446,11 @@ class GeoJsonEditor extends HTMLElement {
577
446
  // ========== Lifecycle ==========
578
447
  connectedCallback() {
579
448
  this.render();
449
+ this._cacheElements();
580
450
  this.setupEventListeners();
581
451
  this.updatePrefixSuffix();
582
452
  this.updateThemeCSS();
583
-
453
+
584
454
  if (this.value) {
585
455
  this.setValue(this.value);
586
456
  }
@@ -601,7 +471,7 @@ class GeoJsonEditor extends HTMLElement {
601
471
  }
602
472
  }
603
473
 
604
- attributeChangedCallback(name, oldValue, newValue) {
474
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
605
475
  if (oldValue === newValue) return;
606
476
 
607
477
  switch (name) {
@@ -629,27 +499,52 @@ class GeoJsonEditor extends HTMLElement {
629
499
 
630
500
  // ========== Initial Render ==========
631
501
  render() {
632
- const styleEl = document.createElement('style');
502
+ const shadowRoot = this.shadowRoot!;
503
+ const styleEl = _ce('style');
633
504
  styleEl.textContent = styles;
634
-
635
- const template = document.createElement('div');
505
+
506
+ const template = _ce('div');
636
507
  template.innerHTML = getTemplate(this.placeholder, VERSION);
637
-
638
- this.shadowRoot.innerHTML = '';
639
- this.shadowRoot.appendChild(styleEl);
508
+
509
+ shadowRoot.innerHTML = '';
510
+ shadowRoot.appendChild(styleEl);
640
511
  while (template.firstChild) {
641
- this.shadowRoot.appendChild(template.firstChild);
642
- }
512
+ shadowRoot.appendChild(template.firstChild);
513
+ }
514
+ }
515
+
516
+ // ========== DOM Element Cache ==========
517
+ private _cacheElements() {
518
+ this._viewport = this._id('viewport');
519
+ this._linesContainer = this._id('linesContainer');
520
+ this._scrollContent = this._id('scrollContent');
521
+ this._hiddenTextarea = this._id('hiddenTextarea') as HTMLTextAreaElement;
522
+ this._gutterContent = this._id('gutterContent');
523
+ this._gutterScrollContent = this._id('gutterScrollContent');
524
+ this._gutterScroll = this._id('gutterScroll');
525
+ this._gutter = this.shadowRoot!.querySelector('.gutter');
526
+ this._clearBtn = this._id('clearBtn') as HTMLButtonElement;
527
+ this._editorWrapper = this.shadowRoot!.querySelector('.editor-wrapper');
528
+ this._placeholderLayer = this._id('placeholderLayer');
529
+ this._editorPrefix = this._id('editorPrefix');
530
+ this._editorSuffix = this._id('editorSuffix');
531
+ this._errorNav = this._id('errorNav');
532
+ this._errorCount = this._id('errorCount');
533
+ this._prevErrorBtn = this._id('prevErrorBtn') as HTMLButtonElement;
534
+ this._nextErrorBtn = this._id('nextErrorBtn') as HTMLButtonElement;
643
535
  }
644
536
 
645
537
  // ========== Event Listeners ==========
646
538
  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');
539
+ const hiddenTextarea = this._hiddenTextarea;
540
+ const viewport = this._viewport;
541
+ const gutterContent = this._gutterContent;
542
+ const gutter = this._gutter;
543
+ const clearBtn = this._clearBtn;
544
+ const editorWrapper = this._editorWrapper;
545
+
546
+ // Guard: all elements must exist
547
+ if (!hiddenTextarea || !viewport || !gutterContent || !gutter || !clearBtn || !editorWrapper) return;
653
548
 
654
549
  // Mouse selection state
655
550
  this._isSelecting = false;
@@ -658,7 +553,7 @@ class GeoJsonEditor extends HTMLElement {
658
553
  // Editor inline control clicks (color swatches, checkboxes, visibility icons)
659
554
  // Use capture phase to intercept before mousedown
660
555
  viewport.addEventListener('click', (e) => {
661
- this.handleEditorClick(e);
556
+ this.handleEditorClick(e as MouseEvent);
662
557
  }, true);
663
558
 
664
559
  viewport.addEventListener('mousedown', (e: MouseEvent) => {
@@ -687,13 +582,13 @@ class GeoJsonEditor extends HTMLElement {
687
582
  return;
688
583
  }
689
584
  }
690
-
585
+
691
586
  // Prevent default to avoid losing focus after click
692
587
  e.preventDefault();
693
-
588
+
694
589
  // Calculate click position
695
590
  const pos = this._getPositionFromClick(e);
696
-
591
+
697
592
  if (e.shiftKey && this.selectionStart) {
698
593
  // Shift+click: extend selection
699
594
  this.selectionEnd = pos;
@@ -707,7 +602,7 @@ class GeoJsonEditor extends HTMLElement {
707
602
  this.selectionEnd = null;
708
603
  this._isSelecting = true;
709
604
  }
710
-
605
+
711
606
  // Focus textarea
712
607
  hiddenTextarea.focus();
713
608
  this._invalidateRenderCache();
@@ -715,19 +610,18 @@ class GeoJsonEditor extends HTMLElement {
715
610
  });
716
611
 
717
612
  // Mouse move for drag selection
718
- viewport.addEventListener('mousemove', (e) => {
613
+ viewport.addEventListener('mousemove', (e: MouseEvent) => {
719
614
  if (!this._isSelecting) return;
720
-
721
615
  const pos = this._getPositionFromClick(e);
722
616
  this.selectionEnd = pos;
723
617
  this.cursorLine = pos.line;
724
618
  this.cursorColumn = pos.column;
725
-
619
+
726
620
  // Auto-scroll when near edges
727
621
  const rect = viewport.getBoundingClientRect();
728
622
  const scrollMargin = 30; // pixels from edge to start scrolling
729
623
  const scrollSpeed = 20; // pixels to scroll per frame
730
-
624
+
731
625
  if (e.clientY < rect.top + scrollMargin) {
732
626
  // Near top edge, scroll up
733
627
  viewport.scrollTop -= scrollSpeed;
@@ -735,7 +629,7 @@ class GeoJsonEditor extends HTMLElement {
735
629
  // Near bottom edge, scroll down
736
630
  viewport.scrollTop += scrollSpeed;
737
631
  }
738
-
632
+
739
633
  this._invalidateRenderCache();
740
634
  this.scheduleRender();
741
635
  });
@@ -763,7 +657,7 @@ class GeoJsonEditor extends HTMLElement {
763
657
  viewport.addEventListener('scroll', () => {
764
658
  if (isRendering) return;
765
659
  this.syncGutterScroll();
766
-
660
+
767
661
  // Use requestAnimationFrame to batch scroll updates
768
662
  if (!this._scrollRaf) {
769
663
  this._scrollRaf = requestAnimationFrame(() => {
@@ -794,38 +688,38 @@ class GeoJsonEditor extends HTMLElement {
794
688
  });
795
689
 
796
690
  hiddenTextarea.addEventListener('keydown', (e) => {
797
- this.handleKeydown(e);
691
+ this.handleKeydown(e as KeyboardEvent);
798
692
  });
799
693
 
800
694
  // Paste handling
801
695
  hiddenTextarea.addEventListener('paste', (e) => {
802
- this.handlePaste(e);
696
+ this.handlePaste(e as ClipboardEvent);
803
697
  });
804
698
 
805
699
  // Copy handling
806
700
  hiddenTextarea.addEventListener('copy', (e) => {
807
- this.handleCopy(e);
701
+ this.handleCopy(e as ClipboardEvent);
808
702
  });
809
703
 
810
704
  // Cut handling
811
705
  hiddenTextarea.addEventListener('cut', (e) => {
812
- this.handleCut(e);
706
+ this.handleCut(e as ClipboardEvent);
813
707
  });
814
708
 
815
709
  // Gutter interactions
816
710
  gutterContent.addEventListener('click', (e) => {
817
- this.handleGutterClick(e);
711
+ this.handleGutterClick(e as MouseEvent);
818
712
  });
819
-
713
+
820
714
  // Prevent gutter from stealing focus
821
715
  gutter.addEventListener('mousedown', (e) => {
822
716
  e.preventDefault();
823
717
  });
824
718
 
825
719
  // Wheel on gutter -> scroll viewport
826
- gutter.addEventListener('wheel', (e: WheelEvent) => {
720
+ gutter.addEventListener('wheel', (e) => {
827
721
  e.preventDefault();
828
- viewport.scrollTop += e.deltaY;
722
+ viewport.scrollTop += (e as WheelEvent).deltaY;
829
723
  });
830
724
 
831
725
  // Clear button
@@ -833,6 +727,14 @@ class GeoJsonEditor extends HTMLElement {
833
727
  this.removeAll();
834
728
  });
835
729
 
730
+ // Error navigation buttons
731
+ this._prevErrorBtn?.addEventListener('click', () => {
732
+ this.goToPrevError();
733
+ });
734
+ this._nextErrorBtn?.addEventListener('click', () => {
735
+ this.goToNextError();
736
+ });
737
+
836
738
  // Initial readonly state
837
739
  this.updateReadonly();
838
740
  }
@@ -842,7 +744,7 @@ class GeoJsonEditor extends HTMLElement {
842
744
  /**
843
745
  * Set the editor content from a string value
844
746
  */
845
- setValue(value, autoCollapse = true) {
747
+ setValue(value: string | null, autoCollapse = true): void {
846
748
  // Save to history only if there's existing content
847
749
  if (this.lines.length > 0) {
848
750
  this._saveToHistory('setValue');
@@ -868,6 +770,7 @@ class GeoJsonEditor extends HTMLElement {
868
770
  // Clear state for new content
869
771
  this.collapsedNodes.clear();
870
772
  this.hiddenFeatures.clear();
773
+ this._openedNodeKeys.clear();
871
774
  this._lineToNodeId.clear();
872
775
  this._nodeIdToLines.clear();
873
776
  this.cursorLine = 0;
@@ -899,8 +802,9 @@ class GeoJsonEditor extends HTMLElement {
899
802
  * Rebuilds line-to-nodeId mapping while preserving collapsed state
900
803
  */
901
804
  updateModel() {
902
- // Invalidate context map cache since content changed
805
+ // Invalidate caches since content changed
903
806
  this._contextMapCache = null;
807
+ this._errorLinesCache = null;
904
808
 
905
809
  // Rebuild lineToNodeId mapping (may shift due to edits)
906
810
  this._rebuildNodeIdMappings();
@@ -941,7 +845,7 @@ class GeoJsonEditor extends HTMLElement {
941
845
  for (let i = 0; i < this.lines.length; i++) {
942
846
  const line = this.lines[i];
943
847
 
944
- if (!inFeature && /"type"\s*:\s*"Feature"/.test(line)) {
848
+ if (!inFeature && RE_IS_FEATURE.test(line)) {
945
849
  // Find opening brace
946
850
  let startLine = i;
947
851
  for (let j = i; j >= 0; j--) {
@@ -957,7 +861,7 @@ class GeoJsonEditor extends HTMLElement {
957
861
 
958
862
  // Count braces from start to current line
959
863
  for (let k = startLine; k <= i; k++) {
960
- const counts = this._countBrackets(this.lines[k], '{');
864
+ const counts = countBrackets(this.lines[k], '{');
961
865
  if (k === startLine) {
962
866
  braceDepth += (counts.open - 1) - counts.close;
963
867
  } else {
@@ -966,10 +870,10 @@ class GeoJsonEditor extends HTMLElement {
966
870
  }
967
871
 
968
872
  if (featureIndex < parsed.features.length) {
969
- currentFeatureKey = this._getFeatureKey(parsed.features[featureIndex]);
873
+ currentFeatureKey = getFeatureKey(parsed.features[featureIndex]);
970
874
  }
971
875
  } else if (inFeature) {
972
- const counts = this._countBrackets(line, '{');
876
+ const counts = countBrackets(line, '{');
973
877
  braceDepth += counts.open - counts.close;
974
878
 
975
879
  if (braceDepth <= 0) {
@@ -996,35 +900,45 @@ class GeoJsonEditor extends HTMLElement {
996
900
  */
997
901
  computeLineMetadata() {
998
902
  this.lineMetadata.clear();
999
-
903
+
1000
904
  const collapsibleRanges = this._findCollapsibleRanges();
1001
-
905
+
906
+ // Compute error lines once (cached)
907
+ const errorLines = this._computeErrorLines();
908
+
1002
909
  for (let i = 0; i < this.lines.length; i++) {
1003
910
  const line = this.lines[i];
1004
- const meta = {
911
+ const meta: LineMeta = {
1005
912
  colors: [],
1006
913
  booleans: [],
1007
914
  collapseButton: null,
1008
915
  visibilityButton: null,
1009
916
  isHidden: false,
1010
917
  isCollapsed: false,
1011
- featureKey: null
918
+ featureKey: null,
919
+ hasError: errorLines.has(i)
1012
920
  };
1013
-
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' });
921
+
922
+ // Detect colors and booleans in a single pass
923
+ RE_ATTR_VALUE.lastIndex = 0;
924
+ let match: RegExpExecArray | null;
925
+ while ((match = RE_ATTR_VALUE.exec(line)) !== null) {
926
+ const [, attributeName, strValue, boolValue] = match;
927
+ if (boolValue) {
928
+ // Boolean value
929
+ meta.booleans.push({ attributeName, value: boolValue === 'true' });
930
+ } else if (strValue) {
931
+ // String value - check if it's a color
932
+ if (RE_COLOR_HEX.test(strValue)) {
933
+ // Hex color (#fff or #ffffff)
934
+ meta.colors.push({ attributeName, color: strValue });
935
+ } else if (isNamedColor(strValue)) {
936
+ // Named CSS color (red, blue, etc.) - validated via browser
937
+ meta.colors.push({ attributeName, color: strValue });
938
+ }
939
+ }
1026
940
  }
1027
-
941
+
1028
942
  // Check if line starts a collapsible node
1029
943
  const collapsible = collapsibleRanges.find(r => r.startLine === i);
1030
944
  if (collapsible) {
@@ -1034,15 +948,15 @@ class GeoJsonEditor extends HTMLElement {
1034
948
  isCollapsed: this.collapsedNodes.has(collapsible.nodeId)
1035
949
  };
1036
950
  }
1037
-
951
+
1038
952
  // Check if line is inside a collapsed node (exclude closing bracket line)
1039
- const insideCollapsed = collapsibleRanges.find(r =>
953
+ const insideCollapsed = collapsibleRanges.find(r =>
1040
954
  this.collapsedNodes.has(r.nodeId) && i > r.startLine && i < r.endLine
1041
955
  );
1042
956
  if (insideCollapsed) {
1043
957
  meta.isCollapsed = true;
1044
958
  }
1045
-
959
+
1046
960
  // Check if line belongs to a hidden feature
1047
961
  for (const [featureKey, range] of this.featureRanges) {
1048
962
  if (i >= range.startLine && i <= range.endLine) {
@@ -1060,11 +974,152 @@ class GeoJsonEditor extends HTMLElement {
1060
974
  break;
1061
975
  }
1062
976
  }
1063
-
977
+
1064
978
  this.lineMetadata.set(i, meta);
1065
979
  }
1066
980
  }
1067
981
 
982
+ /**
983
+ * Compute error lines (syntax highlighting + structural errors)
984
+ * Called once per model update, result is used by computeLineMetadata
985
+ */
986
+ private _computeErrorLines(): Set<number> {
987
+ if (this._errorLinesCache !== null) {
988
+ return this._errorLinesCache;
989
+ }
990
+
991
+ const errorLines = new Set<number>();
992
+
993
+ // Check syntax highlighting errors for each line
994
+ for (let i = 0; i < this.lines.length; i++) {
995
+ const highlighted = highlightSyntax(this.lines[i], '', undefined);
996
+ if (highlighted.includes('json-error')) {
997
+ errorLines.add(i);
998
+ }
999
+ }
1000
+
1001
+ // Check structural error from JSON.parse
1002
+ try {
1003
+ const content = this.lines.join('\n');
1004
+ const wrapped = '[' + content + ']';
1005
+ JSON.parse(wrapped);
1006
+ } catch (e) {
1007
+ if (e instanceof Error) {
1008
+ // Try to extract line number from error message
1009
+ // Chrome/Node: "... at line X column Y"
1010
+ const lineMatch = e.message.match(/line (\d+)/);
1011
+ if (lineMatch) {
1012
+ // Subtract 1 because we wrapped with '[' on first line
1013
+ const errorLine = Math.max(0, parseInt(lineMatch[1], 10) - 1);
1014
+ errorLines.add(errorLine);
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ this._errorLinesCache = errorLines;
1020
+ return errorLines;
1021
+ }
1022
+
1023
+ /**
1024
+ * Get all lines that have errors (for navigation and counting)
1025
+ * Returns array of line indices sorted by line number
1026
+ */
1027
+ private _getErrorLines(): number[] {
1028
+ const errorLines: number[] = [];
1029
+ for (const [lineIndex, meta] of this.lineMetadata) {
1030
+ if (meta.hasError) {
1031
+ errorLines.push(lineIndex);
1032
+ }
1033
+ }
1034
+ return errorLines.sort((a, b) => a - b);
1035
+ }
1036
+
1037
+ /**
1038
+ * Navigate to the next error line
1039
+ */
1040
+ goToNextError(): boolean {
1041
+ const errorLines = this._getErrorLines();
1042
+ if (errorLines.length === 0) return false;
1043
+
1044
+ // Find next error after current cursor position
1045
+ const nextError = errorLines.find(line => line > this.cursorLine);
1046
+ const targetLine = nextError !== undefined ? nextError : errorLines[0]; // Wrap to first
1047
+
1048
+ return this._goToErrorLine(targetLine);
1049
+ }
1050
+
1051
+ /**
1052
+ * Navigate to the previous error line
1053
+ */
1054
+ goToPrevError(): boolean {
1055
+ const errorLines = this._getErrorLines();
1056
+ if (errorLines.length === 0) return false;
1057
+
1058
+ // Find previous error before current cursor position
1059
+ const prevErrors = errorLines.filter(line => line < this.cursorLine);
1060
+ const targetLine = prevErrors.length > 0 ? prevErrors[prevErrors.length - 1] : errorLines[errorLines.length - 1]; // Wrap to last
1061
+
1062
+ return this._goToErrorLine(targetLine);
1063
+ }
1064
+
1065
+ /**
1066
+ * Expand all collapsed nodes containing a specific line
1067
+ * Returns true if any nodes were expanded
1068
+ */
1069
+ private _expandNodesContainingLine(lineIndex: number): boolean {
1070
+ let expanded = false;
1071
+ for (const [nodeId, nodeInfo] of this._nodeIdToLines) {
1072
+ if (this.collapsedNodes.has(nodeId) && lineIndex > nodeInfo.startLine && lineIndex <= nodeInfo.endLine) {
1073
+ this.collapsedNodes.delete(nodeId);
1074
+ // Track that this node was opened - don't re-collapse during edits
1075
+ if (nodeInfo.uniqueKey) {
1076
+ this._openedNodeKeys.add(nodeInfo.uniqueKey);
1077
+ }
1078
+ expanded = true;
1079
+ }
1080
+ }
1081
+ return expanded;
1082
+ }
1083
+
1084
+ /**
1085
+ * Navigate to a specific error line
1086
+ */
1087
+ private _goToErrorLine(lineIndex: number): boolean {
1088
+ if (this._expandNodesContainingLine(lineIndex)) {
1089
+ this.updateView();
1090
+ }
1091
+
1092
+ this.cursorLine = lineIndex;
1093
+ this.cursorColumn = 0;
1094
+ this._invalidateRenderCache();
1095
+ this._scrollToCursor(true); // Center the error line
1096
+ this.renderViewport();
1097
+ this._updateErrorDisplay();
1098
+
1099
+ // Focus the editor
1100
+ this._hiddenTextarea?.focus();
1101
+ return true;
1102
+ }
1103
+
1104
+ /**
1105
+ * Expand all collapsed nodes that contain error lines
1106
+ */
1107
+ private _expandErrorNodes(): void {
1108
+ const errorLines = this._getErrorLines();
1109
+ if (errorLines.length === 0) return;
1110
+
1111
+ let expanded = false;
1112
+ for (const errorLine of errorLines) {
1113
+ if (this._expandNodesContainingLine(errorLine)) {
1114
+ expanded = true;
1115
+ }
1116
+ }
1117
+
1118
+ if (expanded) {
1119
+ this.updateView();
1120
+ }
1121
+ }
1122
+
1068
1123
  /**
1069
1124
  * Compute which lines are visible (not inside collapsed nodes)
1070
1125
  */
@@ -1084,8 +1139,6 @@ class GeoJsonEditor extends HTMLElement {
1084
1139
 
1085
1140
  // Reset render cache to force re-render
1086
1141
  this._invalidateRenderCache();
1087
- this._lastEndIndex = -1;
1088
- this._lastTotalLines = -1;
1089
1142
  }
1090
1143
 
1091
1144
  // ========== Rendering ==========
@@ -1093,7 +1146,7 @@ class GeoJsonEditor extends HTMLElement {
1093
1146
  scheduleRender() {
1094
1147
  if (this.renderTimer) return;
1095
1148
  this.renderTimer = requestAnimationFrame(() => {
1096
- this.renderTimer = null;
1149
+ this.renderTimer = undefined;
1097
1150
  this.renderViewport();
1098
1151
  });
1099
1152
  }
@@ -1103,10 +1156,9 @@ class GeoJsonEditor extends HTMLElement {
1103
1156
  if (this._blockRender) {
1104
1157
  return;
1105
1158
  }
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');
1159
+ const viewport = this._viewport;
1160
+ const linesContainer = this._linesContainer;
1161
+ const scrollContent = this._scrollContent;
1110
1162
 
1111
1163
  if (!viewport || !linesContainer) return;
1112
1164
 
@@ -1114,10 +1166,15 @@ class GeoJsonEditor extends HTMLElement {
1114
1166
 
1115
1167
  const totalLines = this.visibleLines.length;
1116
1168
  const totalHeight = totalLines * this.lineHeight;
1117
-
1118
- // Set total scrollable height (only once or when content changes)
1169
+
1170
+ // Set total scrollable dimensions (height and width based on content)
1119
1171
  if (scrollContent) {
1120
1172
  scrollContent.style.height = `${totalHeight}px`;
1173
+ // Calculate max line width to update horizontal scroll
1174
+ const charWidth = this._getCharWidth();
1175
+ const maxLineLength = this.lines.reduce((max, line) => Math.max(max, line.length), 0);
1176
+ const minWidth = maxLineLength * charWidth + 20; // 20px padding
1177
+ scrollContent.style.minWidth = `${minWidth}px`;
1121
1178
  }
1122
1179
 
1123
1180
  // Calculate visible range based on scroll position
@@ -1142,17 +1199,16 @@ class GeoJsonEditor extends HTMLElement {
1142
1199
 
1143
1200
  // Build context map for syntax highlighting
1144
1201
  const contextMap = this._buildContextMap();
1145
-
1202
+
1146
1203
  // Check if editor is focused (for cursor display)
1147
- const editorWrapper = this.shadowRoot.querySelector('.editor-wrapper');
1148
- const isFocused = editorWrapper?.classList.contains('focused');
1204
+ const isFocused = this._editorWrapper?.classList.contains('focused');
1149
1205
 
1150
1206
  // Render visible lines
1151
1207
  const fragment = document.createDocumentFragment();
1152
1208
 
1153
1209
  // Handle empty editor: render an empty line with cursor
1154
1210
  if (totalLines === 0) {
1155
- const lineEl = document.createElement('div');
1211
+ const lineEl = _ce('div');
1156
1212
  lineEl.className = 'line empty-line';
1157
1213
  lineEl.dataset.lineIndex = '0';
1158
1214
  if (isFocused) {
@@ -1169,7 +1225,7 @@ class GeoJsonEditor extends HTMLElement {
1169
1225
  const lineData = this.visibleLines[i];
1170
1226
  if (!lineData) continue;
1171
1227
 
1172
- const lineEl = document.createElement('div');
1228
+ const lineEl = _ce('div');
1173
1229
  lineEl.className = 'line';
1174
1230
  lineEl.dataset.lineIndex = String(lineData.index);
1175
1231
 
@@ -1188,8 +1244,8 @@ class GeoJsonEditor extends HTMLElement {
1188
1244
  }
1189
1245
 
1190
1246
  // 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);
1247
+ const context = contextMap.get(lineData.index) || 'Feature';
1248
+ let html = highlightSyntax(lineData.content, context, lineData.meta);
1193
1249
 
1194
1250
  // Add selection highlight if line is in selection
1195
1251
  if (isFocused && this._hasSelection()) {
@@ -1216,20 +1272,27 @@ class GeoJsonEditor extends HTMLElement {
1216
1272
  /**
1217
1273
  * Insert cursor element at the specified column position
1218
1274
  * Uses absolute positioning to avoid affecting text layout
1275
+ * In overwrite mode, cursor is a block covering the next character
1219
1276
  */
1220
- _insertCursor(column) {
1221
- // Calculate cursor position in pixels using character width
1277
+ private _insertCursor(column: number): string {
1222
1278
  const charWidth = this._getCharWidth();
1223
1279
  const left = column * charWidth;
1224
- return `<span class="cursor" style="left: ${left}px"></span>`;
1280
+ if (this._insertMode) {
1281
+ // Insert mode: thin line cursor
1282
+ return `<span class="cursor" style="left: ${left}px"></span>`;
1283
+ } else {
1284
+ // Overwrite mode: block cursor covering the character
1285
+ return `<span class="cursor cursor-block" style="left: ${left}px; width: ${charWidth}px"></span>`;
1286
+ }
1225
1287
  }
1226
1288
 
1227
1289
  /**
1228
1290
  * Add selection highlight to a line
1229
1291
  */
1230
- _addSelectionHighlight(html, lineIndex, content) {
1231
- const { start, end } = this._normalizeSelection();
1232
- if (!start || !end) return html;
1292
+ private _addSelectionHighlight(html: string, lineIndex: number, content: string): string {
1293
+ const sel = this._normalizeSelection();
1294
+ if (!sel) return html;
1295
+ const { start, end } = sel;
1233
1296
 
1234
1297
  // Check if this line is in the selection
1235
1298
  if (lineIndex < start.line || lineIndex > end.line) return html;
@@ -1266,20 +1329,25 @@ class GeoJsonEditor extends HTMLElement {
1266
1329
  /**
1267
1330
  * Get character width for monospace font
1268
1331
  */
1269
- _getCharWidth() {
1332
+ private _getCharWidth(): number {
1270
1333
  if (!this._charWidth) {
1271
- const canvas = document.createElement('canvas');
1334
+ const canvas = _ce('canvas') as HTMLCanvasElement;
1272
1335
  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;
1336
+ if (ctx) {
1337
+ // Use exact same font as CSS: 'Courier New', Courier, monospace at 13px
1338
+ ctx.font = "13px 'Courier New', Courier, monospace";
1339
+ this._charWidth = ctx.measureText('M').width;
1340
+ } else {
1341
+ // Fallback to approximate monospace character width
1342
+ this._charWidth = 7.8;
1343
+ }
1276
1344
  }
1277
1345
  return this._charWidth;
1278
1346
  }
1279
1347
 
1280
- renderGutter(startIndex, endIndex) {
1281
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
1282
- const gutterScrollContent = this.shadowRoot.getElementById('gutterScrollContent');
1348
+ renderGutter(startIndex: number, endIndex: number): void {
1349
+ const gutterContent = this._gutterContent;
1350
+ const gutterScrollContent = this._gutterScrollContent;
1283
1351
  if (!gutterContent) return;
1284
1352
 
1285
1353
  // Set total height for gutter scroll
@@ -1297,23 +1365,28 @@ class GeoJsonEditor extends HTMLElement {
1297
1365
  for (let i = startIndex; i < endIndex; i++) {
1298
1366
  const lineData = this.visibleLines[i];
1299
1367
  if (!lineData) continue;
1300
-
1301
- const gutterLine = document.createElement('div');
1368
+
1369
+ const gutterLine = _ce('div');
1302
1370
  gutterLine.className = 'gutter-line';
1303
-
1371
+
1304
1372
  const meta = lineData.meta;
1305
-
1373
+
1374
+ // Add error indicator class
1375
+ if (meta?.hasError) {
1376
+ gutterLine.classList.add('has-error');
1377
+ }
1378
+
1306
1379
  // Line number first
1307
- const lineNum = document.createElement('span');
1380
+ const lineNum = _ce('span');
1308
1381
  lineNum.className = 'line-number';
1309
1382
  lineNum.textContent = String(lineData.index + 1);
1310
1383
  gutterLine.appendChild(lineNum);
1311
-
1384
+
1312
1385
  // Collapse column (always present for alignment)
1313
- const collapseCol = document.createElement('div');
1386
+ const collapseCol = _ce('div');
1314
1387
  collapseCol.className = 'collapse-column';
1315
1388
  if (meta?.collapseButton) {
1316
- const btn = document.createElement('div');
1389
+ const btn = _ce('div');
1317
1390
  btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
1318
1391
  btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
1319
1392
  btn.dataset.line = String(lineData.index);
@@ -1322,7 +1395,7 @@ class GeoJsonEditor extends HTMLElement {
1322
1395
  collapseCol.appendChild(btn);
1323
1396
  }
1324
1397
  gutterLine.appendChild(collapseCol);
1325
-
1398
+
1326
1399
  fragment.appendChild(gutterLine);
1327
1400
  }
1328
1401
 
@@ -1331,28 +1404,31 @@ class GeoJsonEditor extends HTMLElement {
1331
1404
  }
1332
1405
 
1333
1406
  syncGutterScroll() {
1334
- const gutterScroll = this.shadowRoot.getElementById('gutterScroll');
1335
- const viewport = this.shadowRoot.getElementById('viewport');
1336
- if (gutterScroll && viewport) {
1407
+ if (this._gutterScroll && this._viewport) {
1337
1408
  // Sync gutter scroll position with viewport
1338
- gutterScroll.scrollTop = viewport.scrollTop;
1409
+ this._gutterScroll.scrollTop = this._viewport.scrollTop;
1339
1410
  }
1340
1411
  }
1341
1412
 
1342
1413
  // ========== Input Handling ==========
1343
1414
 
1344
1415
  handleInput(): void {
1345
- const textarea = this.shadowRoot!.getElementById('hiddenTextarea') as HTMLTextAreaElement;
1346
- const inputValue = textarea.value;
1347
-
1416
+ const textarea = this._hiddenTextarea;
1417
+ const inputValue = textarea?.value;
1418
+
1348
1419
  if (!inputValue) return;
1349
-
1420
+
1421
+ // Delete selection first if any (replace selection with input)
1422
+ if (this._hasSelection()) {
1423
+ this._deleteSelection();
1424
+ }
1425
+
1350
1426
  // Block input in hidden collapsed zones
1351
1427
  if (this._getCollapsedRangeForLine(this.cursorLine)) {
1352
1428
  textarea.value = '';
1353
1429
  return;
1354
1430
  }
1355
-
1431
+
1356
1432
  // On closing line, only allow after bracket
1357
1433
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1358
1434
  if (onClosingLine) {
@@ -1363,31 +1439,40 @@ class GeoJsonEditor extends HTMLElement {
1363
1439
  return;
1364
1440
  }
1365
1441
  }
1366
-
1442
+
1367
1443
  // On collapsed opening line, only allow before bracket
1368
1444
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1369
1445
  if (onCollapsed) {
1370
1446
  const line = this.lines[this.cursorLine];
1371
- const bracketPos = line.search(/[{\[]/);
1447
+ const bracketPos = line.search(RE_BRACKET_POS);
1372
1448
  if (this.cursorColumn > bracketPos) {
1373
1449
  textarea.value = '';
1374
1450
  return;
1375
1451
  }
1376
1452
  }
1377
-
1378
- // Insert the input at cursor position
1453
+
1454
+ // Insert or overwrite the input at cursor position
1379
1455
  if (this.cursorLine < this.lines.length) {
1380
1456
  const line = this.lines[this.cursorLine];
1381
1457
  const before = line.substring(0, this.cursorColumn);
1382
- const after = line.substring(this.cursorColumn);
1383
-
1458
+
1384
1459
  // Handle newlines in input
1385
1460
  const inputLines = inputValue.split('\n');
1386
1461
  if (inputLines.length === 1) {
1387
- this.lines[this.cursorLine] = before + inputValue + after;
1462
+ // Single line input: insert or overwrite mode
1463
+ if (this._insertMode) {
1464
+ // Insert mode: keep text after cursor
1465
+ const after = line.substring(this.cursorColumn);
1466
+ this.lines[this.cursorLine] = before + inputValue + after;
1467
+ } else {
1468
+ // Overwrite mode: replace characters after cursor
1469
+ const after = line.substring(this.cursorColumn + inputValue.length);
1470
+ this.lines[this.cursorLine] = before + inputValue + after;
1471
+ }
1388
1472
  this.cursorColumn += inputValue.length;
1389
1473
  } else {
1390
- // Multi-line input
1474
+ // Multi-line input: always insert mode
1475
+ const after = line.substring(this.cursorColumn);
1391
1476
  this.lines[this.cursorLine] = before + inputLines[0];
1392
1477
  for (let i = 1; i < inputLines.length - 1; i++) {
1393
1478
  this.lines.splice(this.cursorLine + i, 0, inputLines[i]);
@@ -1415,17 +1500,17 @@ class GeoJsonEditor extends HTMLElement {
1415
1500
  }, 150);
1416
1501
  }
1417
1502
 
1418
- handleKeydown(e) {
1503
+ handleKeydown(e: KeyboardEvent): void {
1419
1504
  // Build context for collapsed zone detection
1420
- const ctx = {
1505
+ const ctx: CollapsedZoneContext = {
1421
1506
  inCollapsedZone: this._getCollapsedRangeForLine(this.cursorLine),
1422
1507
  onCollapsedNode: this._getCollapsedNodeAtLine(this.cursorLine),
1423
1508
  onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
1424
1509
  };
1425
1510
 
1426
1511
  // Lookup table for key handlers
1427
- const keyHandlers = {
1428
- 'Enter': () => this._handleEnter(ctx),
1512
+ const keyHandlers: Record<string, () => void> = {
1513
+ 'Enter': () => this._handleEnter(e.shiftKey, ctx),
1429
1514
  'Backspace': () => this._handleBackspace(ctx),
1430
1515
  'Delete': () => this._handleDelete(ctx),
1431
1516
  'ArrowUp': () => this._handleArrowKey(-1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
@@ -1434,11 +1519,14 @@ class GeoJsonEditor extends HTMLElement {
1434
1519
  'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1435
1520
  'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1436
1521
  'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1437
- 'Tab': () => this._handleTab(e.shiftKey, ctx)
1522
+ 'PageUp': () => this._handlePageUpDown('up', e.shiftKey),
1523
+ 'PageDown': () => this._handlePageUpDown('down', e.shiftKey),
1524
+ 'Tab': () => this._handleTab(e.shiftKey, ctx),
1525
+ 'Insert': () => { this._insertMode = !this._insertMode; this.scheduleRender(); }
1438
1526
  };
1439
1527
 
1440
1528
  // Modifier key handlers (Ctrl/Cmd)
1441
- const modifierHandlers = {
1529
+ const modifierHandlers: Record<string, () => void | boolean | Promise<boolean>> = {
1442
1530
  'a': () => this._selectAll(),
1443
1531
  'z': () => e.shiftKey ? this.redo() : this.undo(),
1444
1532
  'y': () => this.redo(),
@@ -1460,21 +1548,46 @@ class GeoJsonEditor extends HTMLElement {
1460
1548
  }
1461
1549
  }
1462
1550
 
1463
- _handleEnter(ctx) {
1464
- // Block in collapsed zones
1465
- if (ctx.onCollapsedNode || ctx.inCollapsedZone) return;
1466
- // On closing line, before bracket -> block
1467
- if (ctx.onClosingLine) {
1468
- const line = this.lines[this.cursorLine];
1469
- const bracketPos = this._getClosingBracketPos(line);
1470
- if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
1471
- return;
1551
+ private _handleEnter(isShiftKey: boolean, ctx: CollapsedZoneContext): void {
1552
+ // Shift+Enter: collapse the containing expanded node
1553
+ if (isShiftKey) {
1554
+ const containingNode = this._getContainingExpandedNode(this.cursorLine);
1555
+ if (containingNode) {
1556
+ const startLine = this.lines[containingNode.startLine];
1557
+ const bracketPos = startLine.search(RE_BRACKET_POS);
1558
+ this.toggleCollapse(containingNode.nodeId);
1559
+ this.cursorLine = containingNode.startLine;
1560
+ this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
1561
+ this._clearSelection();
1562
+ this._scrollToCursor();
1472
1563
  }
1564
+ return;
1473
1565
  }
1474
- this.insertNewline();
1566
+
1567
+ // Enter on collapsed node: expand it
1568
+ if (ctx.onCollapsedNode) {
1569
+ this.toggleCollapse(ctx.onCollapsedNode.nodeId);
1570
+ return;
1571
+ }
1572
+
1573
+ // Enter on closing line of collapsed node: expand it
1574
+ if (ctx.onClosingLine) {
1575
+ const line = this.lines[this.cursorLine];
1576
+ const bracketPos = this._getClosingBracketPos(line);
1577
+ // If cursor is before or on bracket, expand
1578
+ if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
1579
+ this.toggleCollapse(ctx.onClosingLine.nodeId);
1580
+ return;
1581
+ }
1582
+ }
1583
+
1584
+ // Block in collapsed zones
1585
+ if (ctx.inCollapsedZone) return;
1586
+
1587
+ // Enter anywhere else: do nothing (JSON structure is managed automatically)
1475
1588
  }
1476
1589
 
1477
- _handleBackspace(ctx) {
1590
+ private _handleBackspace(ctx: CollapsedZoneContext): void {
1478
1591
  // Delete selection if any
1479
1592
  if (this._hasSelection()) {
1480
1593
  this._deleteSelection();
@@ -1502,7 +1615,7 @@ class GeoJsonEditor extends HTMLElement {
1502
1615
  // On opening line, allow editing before bracket
1503
1616
  if (ctx.onCollapsedNode) {
1504
1617
  const line = this.lines[this.cursorLine];
1505
- const bracketPos = line.search(/[{\[]/);
1618
+ const bracketPos = line.search(RE_BRACKET_POS);
1506
1619
  if (this.cursorColumn > bracketPos + 1) {
1507
1620
  this._deleteCollapsedNode(ctx.onCollapsedNode);
1508
1621
  return;
@@ -1511,7 +1624,7 @@ class GeoJsonEditor extends HTMLElement {
1511
1624
  this.deleteBackward();
1512
1625
  }
1513
1626
 
1514
- _handleDelete(ctx) {
1627
+ private _handleDelete(ctx: CollapsedZoneContext): void {
1515
1628
  // Delete selection if any
1516
1629
  if (this._hasSelection()) {
1517
1630
  this._deleteSelection();
@@ -1532,7 +1645,7 @@ class GeoJsonEditor extends HTMLElement {
1532
1645
  // If on collapsed node opening line
1533
1646
  if (ctx.onCollapsedNode) {
1534
1647
  const line = this.lines[this.cursorLine];
1535
- const bracketPos = line.search(/[{\[]/);
1648
+ const bracketPos = line.search(RE_BRACKET_POS);
1536
1649
  if (this.cursorColumn > bracketPos) {
1537
1650
  this._deleteCollapsedNode(ctx.onCollapsedNode);
1538
1651
  return;
@@ -1543,29 +1656,328 @@ class GeoJsonEditor extends HTMLElement {
1543
1656
  this.deleteForward();
1544
1657
  }
1545
1658
 
1546
- _handleTab(isShiftKey, ctx) {
1547
- // Shift+Tab: collapse the containing expanded node
1659
+ private _handleTab(isShiftKey: boolean, _ctx: CollapsedZoneContext): void {
1660
+ // Tab/Shift+Tab: navigate between attributes (key and value)
1548
1661
  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();
1662
+ this._navigateToPrevAttribute();
1663
+ } else {
1664
+ this._navigateToNextAttribute();
1665
+ }
1666
+ }
1667
+
1668
+ /**
1669
+ * Navigate to the next attribute (key or value) in the JSON
1670
+ * Also stops on collapsed node brackets to allow expansion with Enter
1671
+ */
1672
+ private _navigateToNextAttribute(): void {
1673
+ const totalLines = this.visibleLines.length;
1674
+ let currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1675
+ if (currentVisibleIdx < 0) currentVisibleIdx = 0;
1676
+
1677
+ // Search from current position forward
1678
+ for (let i = currentVisibleIdx; i < totalLines; i++) {
1679
+ const vl = this.visibleLines[i];
1680
+ const line = this.lines[vl.index];
1681
+ const startCol = (i === currentVisibleIdx) ? this.cursorColumn : 0;
1682
+
1683
+ const pos = this._findNextAttributeOrBracket(line, startCol, vl.index);
1684
+ if (pos !== null) {
1685
+ this.cursorLine = vl.index;
1686
+ this.cursorColumn = pos.start;
1687
+ // Select the attribute key or value (not brackets)
1688
+ if (!pos.isBracket) {
1689
+ this.selectionStart = { line: vl.index, column: pos.start };
1690
+ this.selectionEnd = { line: vl.index, column: pos.end };
1691
+ } else {
1692
+ this._clearSelection();
1693
+ }
1557
1694
  this._scrollToCursor();
1695
+ this._invalidateRenderCache();
1696
+ this.scheduleRender();
1697
+ return;
1558
1698
  }
1559
- return;
1560
1699
  }
1561
- // Tab: expand collapsed node if on one
1562
- if (ctx.onCollapsedNode) {
1563
- this.toggleCollapse(ctx.onCollapsedNode.nodeId);
1564
- return;
1700
+
1701
+ // Wrap to beginning
1702
+ for (let i = 0; i < currentVisibleIdx; i++) {
1703
+ const vl = this.visibleLines[i];
1704
+ const line = this.lines[vl.index];
1705
+ const pos = this._findNextAttributeOrBracket(line, 0, vl.index);
1706
+ if (pos !== null) {
1707
+ this.cursorLine = vl.index;
1708
+ this.cursorColumn = pos.start;
1709
+ if (!pos.isBracket) {
1710
+ this.selectionStart = { line: vl.index, column: pos.start };
1711
+ this.selectionEnd = { line: vl.index, column: pos.end };
1712
+ } else {
1713
+ this._clearSelection();
1714
+ }
1715
+ this._scrollToCursor();
1716
+ this._invalidateRenderCache();
1717
+ this.scheduleRender();
1718
+ return;
1719
+ }
1565
1720
  }
1566
- if (ctx.onClosingLine) {
1567
- this.toggleCollapse(ctx.onClosingLine.nodeId);
1721
+ }
1722
+
1723
+ /**
1724
+ * Navigate to the previous attribute (key or value) in the JSON
1725
+ * Also stops on collapsed node brackets to allow expansion with Enter
1726
+ */
1727
+ private _navigateToPrevAttribute(): void {
1728
+ const totalLines = this.visibleLines.length;
1729
+ let currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1730
+ if (currentVisibleIdx < 0) currentVisibleIdx = totalLines - 1;
1731
+
1732
+ // Search from current position backward
1733
+ for (let i = currentVisibleIdx; i >= 0; i--) {
1734
+ const vl = this.visibleLines[i];
1735
+ const line = this.lines[vl.index];
1736
+ const endCol = (i === currentVisibleIdx) ? this.cursorColumn : line.length;
1737
+
1738
+ const pos = this._findPrevAttributeOrBracket(line, endCol, vl.index);
1739
+ if (pos !== null) {
1740
+ this.cursorLine = vl.index;
1741
+ this.cursorColumn = pos.start;
1742
+ if (!pos.isBracket) {
1743
+ this.selectionStart = { line: vl.index, column: pos.start };
1744
+ this.selectionEnd = { line: vl.index, column: pos.end };
1745
+ } else {
1746
+ this._clearSelection();
1747
+ }
1748
+ this._scrollToCursor();
1749
+ this._invalidateRenderCache();
1750
+ this.scheduleRender();
1751
+ return;
1752
+ }
1753
+ }
1754
+
1755
+ // Wrap to end
1756
+ for (let i = totalLines - 1; i > currentVisibleIdx; i--) {
1757
+ const vl = this.visibleLines[i];
1758
+ const line = this.lines[vl.index];
1759
+ const pos = this._findPrevAttributeOrBracket(line, line.length, vl.index);
1760
+ if (pos !== null) {
1761
+ this.cursorLine = vl.index;
1762
+ this.cursorColumn = pos.start;
1763
+ if (!pos.isBracket) {
1764
+ this.selectionStart = { line: vl.index, column: pos.start };
1765
+ this.selectionEnd = { line: vl.index, column: pos.end };
1766
+ } else {
1767
+ this._clearSelection();
1768
+ }
1769
+ this._scrollToCursor();
1770
+ this._invalidateRenderCache();
1771
+ this.scheduleRender();
1772
+ return;
1773
+ }
1774
+ }
1775
+ }
1776
+
1777
+ /**
1778
+ * Find next attribute position in a line after startCol
1779
+ * Returns {start, end} for the key or value, or null if none found
1780
+ * Also finds standalone values (numbers in arrays, etc.)
1781
+ */
1782
+ private _findNextAttributeInLine(line: string, startCol: number): { start: number; end: number } | null {
1783
+ // Collect all navigable positions
1784
+ const positions: { start: number; end: number }[] = [];
1785
+
1786
+ // Pattern for "key": value pairs
1787
+ const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1788
+ let match;
1789
+
1790
+ while ((match = keyValueRe.exec(line)) !== null) {
1791
+ const keyStart = match.index + 1; // Skip opening quote
1792
+ const keyEnd = keyStart + match[1].length;
1793
+ positions.push({ start: keyStart, end: keyEnd });
1794
+
1795
+ // Check if there's a value (string, number, boolean, null)
1796
+ if (match[2] !== undefined) {
1797
+ // String value
1798
+ const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
1799
+ if (valueMatch) {
1800
+ const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1801
+ const valueEnd = valueStart + match[2].length;
1802
+ positions.push({ start: valueStart, end: valueEnd });
1803
+ }
1804
+ } else if (match[3] !== undefined) {
1805
+ // Number value after colon
1806
+ const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1807
+ if (numMatch) {
1808
+ const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1809
+ const valueEnd = valueStart + numMatch[1].length;
1810
+ positions.push({ start: valueStart, end: valueEnd });
1811
+ }
1812
+ } else {
1813
+ // Boolean or null
1814
+ const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1815
+ if (boolMatch) {
1816
+ const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1817
+ const valueEnd = valueStart + boolMatch[1].length;
1818
+ positions.push({ start: valueStart, end: valueEnd });
1819
+ }
1820
+ }
1821
+ }
1822
+
1823
+ // Also find standalone numbers (not after a colon) - for array elements
1824
+ const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
1825
+ while ((match = standaloneNumRe.exec(line)) !== null) {
1826
+ const numStr = match[1];
1827
+ const numStart = match.index + match[0].indexOf(numStr);
1828
+ const numEnd = numStart + numStr.length;
1829
+ // Avoid duplicates (numbers already captured by key-value pattern)
1830
+ if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
1831
+ positions.push({ start: numStart, end: numEnd });
1832
+ }
1833
+ }
1834
+
1835
+ // Sort by start position and find first after startCol
1836
+ positions.sort((a, b) => a.start - b.start);
1837
+ for (const pos of positions) {
1838
+ if (pos.start > startCol) {
1839
+ return pos;
1840
+ }
1568
1841
  }
1842
+
1843
+ return null;
1844
+ }
1845
+
1846
+ /**
1847
+ * Find previous attribute position in a line before endCol
1848
+ * Also finds standalone values (numbers in arrays, etc.)
1849
+ */
1850
+ private _findPrevAttributeInLine(line: string, endCol: number): { start: number; end: number } | null {
1851
+ // Collect all navigable positions
1852
+ const positions: { start: number; end: number }[] = [];
1853
+
1854
+ // Pattern for "key": value pairs
1855
+ const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
1856
+ let match;
1857
+
1858
+ while ((match = keyValueRe.exec(line)) !== null) {
1859
+ const keyStart = match.index + 1;
1860
+ const keyEnd = keyStart + match[1].length;
1861
+ positions.push({ start: keyStart, end: keyEnd });
1862
+
1863
+ // Check for value
1864
+ if (match[2] !== undefined) {
1865
+ const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
1866
+ if (valueMatch) {
1867
+ const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
1868
+ const valueEnd = valueStart + match[2].length;
1869
+ positions.push({ start: valueStart, end: valueEnd });
1870
+ }
1871
+ } else if (match[3] !== undefined) {
1872
+ const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
1873
+ if (numMatch) {
1874
+ const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
1875
+ const valueEnd = valueStart + numMatch[1].length;
1876
+ positions.push({ start: valueStart, end: valueEnd });
1877
+ }
1878
+ } else {
1879
+ const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
1880
+ if (boolMatch) {
1881
+ const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
1882
+ const valueEnd = valueStart + boolMatch[1].length;
1883
+ positions.push({ start: valueStart, end: valueEnd });
1884
+ }
1885
+ }
1886
+ }
1887
+
1888
+ // Also find standalone numbers (not after a colon) - for array elements
1889
+ const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
1890
+ while ((match = standaloneNumRe.exec(line)) !== null) {
1891
+ const numStr = match[1];
1892
+ const numStart = match.index + match[0].indexOf(numStr);
1893
+ const numEnd = numStart + numStr.length;
1894
+ // Avoid duplicates
1895
+ if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
1896
+ positions.push({ start: numStart, end: numEnd });
1897
+ }
1898
+ }
1899
+
1900
+ // Sort by start position and find last that ends before endCol
1901
+ positions.sort((a, b) => a.start - b.start);
1902
+ for (let i = positions.length - 1; i >= 0; i--) {
1903
+ if (positions[i].end < endCol) {
1904
+ return positions[i];
1905
+ }
1906
+ }
1907
+
1908
+ return null;
1909
+ }
1910
+
1911
+ /**
1912
+ * Find bracket position in a line (opening bracket for collapsible nodes)
1913
+ * Looks for { or [ at end of line (for both expanded and collapsed nodes)
1914
+ * Returns position AFTER the bracket, or null if not found
1915
+ */
1916
+ private _findBracketInLine(line: string): number | null {
1917
+ // Look for { or [ at end of line (indicates a collapsible node)
1918
+ // Works for both expanded and collapsed nodes - collapsed nodes still have
1919
+ // the bracket in raw text, the "..." is only added visually via CSS
1920
+ const bracketMatch = line.match(/[\[{]\s*$/);
1921
+ if (bracketMatch && bracketMatch.index !== undefined) {
1922
+ return bracketMatch.index + 1; // Position after bracket
1923
+ }
1924
+ return null;
1925
+ }
1926
+
1927
+ /**
1928
+ * Find next attribute or bracket position in a line
1929
+ * Returns position with isBracket flag to indicate if it's a bracket
1930
+ * For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
1931
+ * Stops on ALL opening brackets to allow collapse/expand navigation
1932
+ */
1933
+ private _findNextAttributeOrBracket(line: string, startCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
1934
+ // First check for regular attributes
1935
+ const attrPos = this._findNextAttributeInLine(line, startCol);
1936
+
1937
+ // Find opening bracket position (collapsed or expanded)
1938
+ const bracketPos = this._findBracketInLine(line);
1939
+
1940
+ // Return whichever comes first after startCol
1941
+ if (attrPos !== null && bracketPos !== null) {
1942
+ if (bracketPos > startCol && (bracketPos < attrPos.start)) {
1943
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1944
+ }
1945
+ return { ...attrPos, isBracket: false };
1946
+ } else if (attrPos !== null) {
1947
+ return { ...attrPos, isBracket: false };
1948
+ } else if (bracketPos !== null && bracketPos > startCol) {
1949
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1950
+ }
1951
+
1952
+ return null;
1953
+ }
1954
+
1955
+ /**
1956
+ * Find previous attribute or bracket position in a line
1957
+ * Returns position with isBracket flag to indicate if it's a bracket
1958
+ * For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
1959
+ * Stops on ALL opening brackets to allow collapse/expand navigation
1960
+ */
1961
+ private _findPrevAttributeOrBracket(line: string, endCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
1962
+ // First check for regular attributes
1963
+ const attrPos = this._findPrevAttributeInLine(line, endCol);
1964
+
1965
+ // Find opening bracket position (collapsed or expanded)
1966
+ const bracketPos = this._findBracketInLine(line);
1967
+
1968
+ // Return whichever comes last STRICTLY BEFORE endCol (to avoid staying in place)
1969
+ if (attrPos !== null && bracketPos !== null) {
1970
+ if (bracketPos < endCol && bracketPos > attrPos.end) {
1971
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1972
+ }
1973
+ return { ...attrPos, isBracket: false };
1974
+ } else if (attrPos !== null) {
1975
+ return { ...attrPos, isBracket: false };
1976
+ } else if (bracketPos !== null && bracketPos < endCol) {
1977
+ return { start: bracketPos, end: bracketPos, isBracket: true };
1978
+ }
1979
+
1980
+ return null;
1569
1981
  }
1570
1982
 
1571
1983
  insertNewline() {
@@ -1629,7 +2041,7 @@ class GeoJsonEditor extends HTMLElement {
1629
2041
  /**
1630
2042
  * Move cursor vertically, skipping hidden collapsed lines only
1631
2043
  */
1632
- moveCursorSkipCollapsed(deltaLine) {
2044
+ moveCursorSkipCollapsed(deltaLine: number): void {
1633
2045
  let targetLine = this.cursorLine + deltaLine;
1634
2046
 
1635
2047
  // Skip over hidden collapsed zones only (not opening/closing lines)
@@ -1642,8 +2054,9 @@ class GeoJsonEditor extends HTMLElement {
1642
2054
  } else {
1643
2055
  targetLine = collapsed.startLine; // Jump to opening line
1644
2056
  }
2057
+ } else {
2058
+ break; // Not in a collapsed zone, stop
1645
2059
  }
1646
- break;
1647
2060
  }
1648
2061
 
1649
2062
  this.cursorLine = Math.max(0, Math.min(this.lines.length - 1, targetLine));
@@ -1660,7 +2073,7 @@ class GeoJsonEditor extends HTMLElement {
1660
2073
  /**
1661
2074
  * Move cursor horizontally with smart navigation around collapsed nodes
1662
2075
  */
1663
- moveCursorHorizontal(delta) {
2076
+ moveCursorHorizontal(delta: number): void {
1664
2077
  if (delta > 0) {
1665
2078
  this._moveCursorRight();
1666
2079
  } else {
@@ -1671,7 +2084,7 @@ class GeoJsonEditor extends HTMLElement {
1671
2084
  this.scheduleRender();
1672
2085
  }
1673
2086
 
1674
- _moveCursorRight() {
2087
+ private _moveCursorRight() {
1675
2088
  const line = this.lines[this.cursorLine];
1676
2089
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1677
2090
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
@@ -1689,7 +2102,7 @@ class GeoJsonEditor extends HTMLElement {
1689
2102
  this.cursorColumn++;
1690
2103
  }
1691
2104
  } else if (onCollapsed) {
1692
- const bracketPos = line.search(/[{\[]/);
2105
+ const bracketPos = line.search(RE_BRACKET_POS);
1693
2106
  if (this.cursorColumn < bracketPos) {
1694
2107
  this.cursorColumn++;
1695
2108
  } else if (this.cursorColumn === bracketPos) {
@@ -1713,7 +2126,7 @@ class GeoJsonEditor extends HTMLElement {
1713
2126
  }
1714
2127
  }
1715
2128
 
1716
- _moveCursorLeft() {
2129
+ private _moveCursorLeft() {
1717
2130
  const line = this.lines[this.cursorLine];
1718
2131
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1719
2132
  const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
@@ -1726,10 +2139,10 @@ class GeoJsonEditor extends HTMLElement {
1726
2139
  // Jump to opening line after bracket
1727
2140
  this.cursorLine = onClosingLine.startLine;
1728
2141
  const openLine = this.lines[this.cursorLine];
1729
- this.cursorColumn = openLine.search(/[{\[]/) + 1;
2142
+ this.cursorColumn = openLine.search(RE_BRACKET_POS) + 1;
1730
2143
  }
1731
2144
  } else if (onCollapsed) {
1732
- const bracketPos = line.search(/[{\[]/);
2145
+ const bracketPos = line.search(RE_BRACKET_POS);
1733
2146
  if (this.cursorColumn > bracketPos + 1) {
1734
2147
  this.cursorColumn = bracketPos + 1;
1735
2148
  } else if (this.cursorColumn === bracketPos + 1) {
@@ -1752,7 +2165,7 @@ class GeoJsonEditor extends HTMLElement {
1752
2165
  if (collapsed) {
1753
2166
  this.cursorLine = collapsed.startLine;
1754
2167
  const openLine = this.lines[this.cursorLine];
1755
- this.cursorColumn = openLine.search(/[{\[]/) + 1;
2168
+ this.cursorColumn = openLine.search(RE_BRACKET_POS) + 1;
1756
2169
  } else {
1757
2170
  this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
1758
2171
  }
@@ -1762,33 +2175,41 @@ class GeoJsonEditor extends HTMLElement {
1762
2175
 
1763
2176
  /**
1764
2177
  * Scroll viewport to ensure cursor is visible
2178
+ * @param center - if true, center the cursor line in the viewport
1765
2179
  */
1766
- _scrollToCursor() {
1767
- const viewport = this.shadowRoot.getElementById('viewport');
2180
+ private _scrollToCursor(center = false) {
2181
+ const viewport = this._viewport;
1768
2182
  if (!viewport) return;
1769
-
2183
+
1770
2184
  // Find the visible line index for the cursor
1771
2185
  const visibleIndex = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
1772
2186
  if (visibleIndex === -1) return;
1773
-
2187
+
1774
2188
  const cursorY = visibleIndex * this.lineHeight;
1775
- const viewportTop = viewport.scrollTop;
1776
- const viewportBottom = viewportTop + viewport.clientHeight;
1777
-
1778
- // Scroll up if cursor is above viewport
1779
- if (cursorY < viewportTop) {
1780
- viewport.scrollTop = cursorY;
1781
- }
1782
- // Scroll down if cursor is below viewport
1783
- else if (cursorY + this.lineHeight > viewportBottom) {
1784
- viewport.scrollTop = cursorY + this.lineHeight - viewport.clientHeight;
2189
+ const viewportHeight = viewport.clientHeight;
2190
+
2191
+ if (center) {
2192
+ // Center the cursor line in the viewport
2193
+ viewport.scrollTop = Math.max(0, cursorY - viewportHeight / 2 + this.lineHeight / 2);
2194
+ } else {
2195
+ const viewportTop = viewport.scrollTop;
2196
+ const viewportBottom = viewportTop + viewportHeight;
2197
+
2198
+ // Scroll up if cursor is above viewport
2199
+ if (cursorY < viewportTop) {
2200
+ viewport.scrollTop = cursorY;
2201
+ }
2202
+ // Scroll down if cursor is below viewport
2203
+ else if (cursorY + this.lineHeight > viewportBottom) {
2204
+ viewport.scrollTop = cursorY + this.lineHeight - viewportHeight;
2205
+ }
1785
2206
  }
1786
2207
  }
1787
2208
 
1788
2209
  /**
1789
2210
  * Handle arrow key with optional selection and word jump
1790
2211
  */
1791
- _handleArrowKey(deltaLine, deltaCol, isShift, isCtrl = false) {
2212
+ private _handleArrowKey(deltaLine: number, deltaCol: number, isShift: boolean, isCtrl = false): void {
1792
2213
  // Start selection if shift is pressed and no selection exists
1793
2214
  if (isShift && !this.selectionStart) {
1794
2215
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
@@ -1822,10 +2243,10 @@ class GeoJsonEditor extends HTMLElement {
1822
2243
  * - Ctrl+Right: move to end of current word, or start of next word
1823
2244
  * - Ctrl+Left: move to start of current word, or start of previous word
1824
2245
  */
1825
- _moveCursorByWord(direction) {
2246
+ private _moveCursorByWord(direction: number): void {
1826
2247
  const line = this.lines[this.cursorLine] || '';
1827
2248
  // Word character: alphanumeric, underscore, or hyphen (for kebab-case identifiers)
1828
- const isWordChar = (ch) => /[\w-]/.test(ch);
2249
+ const isWordChar = (ch: string) => RE_IS_WORD_CHAR.test(ch);
1829
2250
 
1830
2251
  // Check if we're on a collapsed node's opening line
1831
2252
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
@@ -1836,7 +2257,7 @@ class GeoJsonEditor extends HTMLElement {
1836
2257
 
1837
2258
  // If on collapsed node opening line and cursor is at/after the bracket, jump to closing line
1838
2259
  if (onCollapsed) {
1839
- const bracketPos = line.search(/[{\[]/);
2260
+ const bracketPos = line.search(RE_BRACKET_POS);
1840
2261
  if (bracketPos >= 0 && pos >= bracketPos) {
1841
2262
  this.cursorLine = onCollapsed.endLine;
1842
2263
  this.cursorColumn = (this.lines[this.cursorLine] || '').length;
@@ -1884,7 +2305,7 @@ class GeoJsonEditor extends HTMLElement {
1884
2305
  // Jump to opening line, after the bracket
1885
2306
  this.cursorLine = onClosingLine.startLine;
1886
2307
  const openLine = this.lines[this.cursorLine] || '';
1887
- const openBracketPos = openLine.search(/[{\[]/);
2308
+ const openBracketPos = openLine.search(RE_BRACKET_POS);
1888
2309
  this.cursorColumn = openBracketPos >= 0 ? openBracketPos : 0;
1889
2310
  this._invalidateRenderCache();
1890
2311
  this._scrollToCursor();
@@ -1931,23 +2352,37 @@ class GeoJsonEditor extends HTMLElement {
1931
2352
  /**
1932
2353
  * Handle Home/End with optional selection
1933
2354
  */
1934
- _handleHomeEnd(key, isShift, onClosingLine) {
2355
+ private _handleHomeEnd(key: string, isShift: boolean, onClosingLine: CollapsedNodeInfo | null): void {
1935
2356
  // Start selection if shift is pressed and no selection exists
1936
2357
  if (isShift && !this.selectionStart) {
1937
2358
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
1938
2359
  }
1939
-
2360
+
1940
2361
  if (key === 'home') {
1941
2362
  if (onClosingLine) {
2363
+ // On closing line of collapsed node: go to start line
1942
2364
  this.cursorLine = onClosingLine.startLine;
2365
+ this.cursorColumn = 0;
2366
+ } else if (this.cursorColumn === 0) {
2367
+ // Already at start of line: go to start of document
2368
+ this.cursorLine = 0;
2369
+ this.cursorColumn = 0;
2370
+ } else {
2371
+ // Go to start of line
2372
+ this.cursorColumn = 0;
1943
2373
  }
1944
- this.cursorColumn = 0;
1945
2374
  } else {
1946
- if (this.cursorLine < this.lines.length) {
1947
- this.cursorColumn = this.lines[this.cursorLine].length;
2375
+ const lineLength = this.lines[this.cursorLine]?.length || 0;
2376
+ if (this.cursorColumn === lineLength) {
2377
+ // Already at end of line: go to end of document
2378
+ this.cursorLine = this.lines.length - 1;
2379
+ this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
2380
+ } else {
2381
+ // Go to end of line
2382
+ this.cursorColumn = lineLength;
1948
2383
  }
1949
2384
  }
1950
-
2385
+
1951
2386
  // Update selection end if shift is pressed
1952
2387
  if (isShift) {
1953
2388
  this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
@@ -1955,7 +2390,50 @@ class GeoJsonEditor extends HTMLElement {
1955
2390
  this.selectionStart = null;
1956
2391
  this.selectionEnd = null;
1957
2392
  }
1958
-
2393
+
2394
+ this._invalidateRenderCache();
2395
+ this._scrollToCursor();
2396
+ this.scheduleRender();
2397
+ }
2398
+
2399
+ /**
2400
+ * Handle PageUp/PageDown
2401
+ */
2402
+ private _handlePageUpDown(direction: 'up' | 'down', isShift: boolean): void {
2403
+ // Start selection if shift is pressed and no selection exists
2404
+ if (isShift && !this.selectionStart) {
2405
+ this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
2406
+ }
2407
+
2408
+ const viewport = this._viewport;
2409
+ if (!viewport) return;
2410
+
2411
+ // Calculate how many lines fit in the viewport
2412
+ const linesPerPage = Math.floor(viewport.clientHeight / this.lineHeight);
2413
+
2414
+ if (direction === 'up') {
2415
+ // Find current visible index and move up by page
2416
+ const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
2417
+ const newVisibleIdx = Math.max(0, currentVisibleIdx - linesPerPage);
2418
+ this.cursorLine = this.visibleLines[newVisibleIdx]?.index || 0;
2419
+ } else {
2420
+ // Find current visible index and move down by page
2421
+ const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
2422
+ const newVisibleIdx = Math.min(this.visibleLines.length - 1, currentVisibleIdx + linesPerPage);
2423
+ this.cursorLine = this.visibleLines[newVisibleIdx]?.index || this.lines.length - 1;
2424
+ }
2425
+
2426
+ // Clamp cursor column to line length
2427
+ this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
2428
+
2429
+ // Update selection end if shift is pressed
2430
+ if (isShift) {
2431
+ this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
2432
+ } else {
2433
+ this.selectionStart = null;
2434
+ this.selectionEnd = null;
2435
+ }
2436
+
1959
2437
  this._invalidateRenderCache();
1960
2438
  this._scrollToCursor();
1961
2439
  this.scheduleRender();
@@ -1964,26 +2442,25 @@ class GeoJsonEditor extends HTMLElement {
1964
2442
  /**
1965
2443
  * Select all content
1966
2444
  */
1967
- _selectAll() {
2445
+ private _selectAll() {
1968
2446
  this.selectionStart = { line: 0, column: 0 };
1969
2447
  const lastLine = this.lines.length - 1;
1970
2448
  this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
1971
2449
  this.cursorLine = lastLine;
1972
2450
  this.cursorColumn = this.lines[lastLine]?.length || 0;
1973
-
2451
+
1974
2452
  this._invalidateRenderCache();
1975
- this._scrollToCursor();
2453
+ // Don't scroll - viewport should stay in place when selecting all
1976
2454
  this.scheduleRender();
1977
2455
  }
1978
2456
 
1979
2457
  /**
1980
2458
  * Get selected text
1981
2459
  */
1982
- _getSelectedText() {
1983
- if (!this.selectionStart || !this.selectionEnd) return '';
1984
-
1985
- const { start, end } = this._normalizeSelection();
1986
- if (!start || !end) return '';
2460
+ private _getSelectedText(): string {
2461
+ const sel = this._normalizeSelection();
2462
+ if (!sel) return '';
2463
+ const { start, end } = sel;
1987
2464
 
1988
2465
  if (start.line === end.line) {
1989
2466
  return this.lines[start.line].substring(start.column, end.column);
@@ -2001,14 +2478,14 @@ class GeoJsonEditor extends HTMLElement {
2001
2478
  /**
2002
2479
  * Normalize selection so start is before end
2003
2480
  */
2004
- _normalizeSelection() {
2481
+ private _normalizeSelection(): { start: CursorPosition; end: CursorPosition } | null {
2005
2482
  if (!this.selectionStart || !this.selectionEnd) {
2006
- return { start: null, end: null };
2483
+ return null;
2007
2484
  }
2008
-
2485
+
2009
2486
  const s = this.selectionStart;
2010
2487
  const e = this.selectionEnd;
2011
-
2488
+
2012
2489
  if (s.line < e.line || (s.line === e.line && s.column <= e.column)) {
2013
2490
  return { start: s, end: e };
2014
2491
  } else {
@@ -2019,7 +2496,7 @@ class GeoJsonEditor extends HTMLElement {
2019
2496
  /**
2020
2497
  * Check if there is an active selection
2021
2498
  */
2022
- _hasSelection() {
2499
+ private _hasSelection() {
2023
2500
  if (!this.selectionStart || !this.selectionEnd) return false;
2024
2501
  return this.selectionStart.line !== this.selectionEnd.line ||
2025
2502
  this.selectionStart.column !== this.selectionEnd.column;
@@ -2028,7 +2505,7 @@ class GeoJsonEditor extends HTMLElement {
2028
2505
  /**
2029
2506
  * Clear the current selection
2030
2507
  */
2031
- _clearSelection() {
2508
+ private _clearSelection() {
2032
2509
  this.selectionStart = null;
2033
2510
  this.selectionEnd = null;
2034
2511
  }
@@ -2036,13 +2513,13 @@ class GeoJsonEditor extends HTMLElement {
2036
2513
  /**
2037
2514
  * Delete selected text
2038
2515
  */
2039
- _deleteSelection() {
2040
- if (!this._hasSelection()) return false;
2516
+ private _deleteSelection(): boolean {
2517
+ const sel = this._normalizeSelection();
2518
+ if (!sel) return false;
2519
+ const { start, end } = sel;
2041
2520
 
2042
2521
  this._saveToHistory('delete');
2043
2522
 
2044
- const { start, end } = this._normalizeSelection();
2045
-
2046
2523
  if (start.line === end.line) {
2047
2524
  // Single line selection
2048
2525
  const line = this.lines[start.line];
@@ -2063,7 +2540,7 @@ class GeoJsonEditor extends HTMLElement {
2063
2540
  return true;
2064
2541
  }
2065
2542
 
2066
- insertText(text) {
2543
+ insertText(text: string): void {
2067
2544
  // Delete selection first if any
2068
2545
  if (this._hasSelection()) {
2069
2546
  this._deleteSelection();
@@ -2084,7 +2561,7 @@ class GeoJsonEditor extends HTMLElement {
2084
2561
  const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
2085
2562
  if (onCollapsed) {
2086
2563
  const line = this.lines[this.cursorLine];
2087
- const bracketPos = line.search(/[{\[]/);
2564
+ const bracketPos = line.search(RE_BRACKET_POS);
2088
2565
  if (this.cursorColumn > bracketPos) return;
2089
2566
  }
2090
2567
 
@@ -2106,9 +2583,9 @@ class GeoJsonEditor extends HTMLElement {
2106
2583
  this.formatAndUpdate();
2107
2584
  }
2108
2585
 
2109
- handlePaste(e) {
2586
+ handlePaste(e: ClipboardEvent): void {
2110
2587
  e.preventDefault();
2111
- const text = e.clipboardData.getData('text/plain');
2588
+ const text = e.clipboardData?.getData('text/plain');
2112
2589
  if (!text) return;
2113
2590
 
2114
2591
  const wasEmpty = this.lines.length === 0;
@@ -2116,7 +2593,7 @@ class GeoJsonEditor extends HTMLElement {
2116
2593
  // Try to parse as GeoJSON and normalize
2117
2594
  try {
2118
2595
  const parsed = JSON.parse(text);
2119
- const features = this._normalizeToFeatures(parsed);
2596
+ const features = normalizeToFeatures(parsed);
2120
2597
  // Valid GeoJSON - insert formatted features
2121
2598
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2122
2599
  this.insertText(formatted);
@@ -2125,19 +2602,27 @@ class GeoJsonEditor extends HTMLElement {
2125
2602
  this.insertText(text);
2126
2603
  }
2127
2604
 
2605
+ // Cancel any pending render from insertText/formatAndUpdate
2606
+ if (this.renderTimer) {
2607
+ cancelAnimationFrame(this.renderTimer);
2608
+ this.renderTimer = undefined;
2609
+ }
2610
+
2128
2611
  // Auto-collapse coordinates after pasting into empty editor
2129
2612
  if (wasEmpty && this.lines.length > 0) {
2130
- // Cancel pending render, collapse first, then render once
2131
- if (this.renderTimer) {
2132
- cancelAnimationFrame(this.renderTimer);
2133
- this.renderTimer = null;
2134
- }
2135
2613
  this.autoCollapseCoordinates();
2136
2614
  }
2615
+
2616
+ // Expand any collapsed nodes that contain errors
2617
+ this._expandErrorNodes();
2618
+
2619
+ // Force immediate render (not via RAF) to ensure content displays instantly
2620
+ this.renderViewport();
2137
2621
  }
2138
2622
 
2139
- handleCopy(e) {
2623
+ handleCopy(e: ClipboardEvent): void {
2140
2624
  e.preventDefault();
2625
+ if (!e.clipboardData) return;
2141
2626
  // Copy selected text if there's a selection, otherwise copy all
2142
2627
  if (this._hasSelection()) {
2143
2628
  e.clipboardData.setData('text/plain', this._getSelectedText());
@@ -2146,8 +2631,9 @@ class GeoJsonEditor extends HTMLElement {
2146
2631
  }
2147
2632
  }
2148
2633
 
2149
- handleCut(e) {
2634
+ handleCut(e: ClipboardEvent): void {
2150
2635
  e.preventDefault();
2636
+ if (!e.clipboardData) return;
2151
2637
  if (this._hasSelection()) {
2152
2638
  e.clipboardData.setData('text/plain', this._getSelectedText());
2153
2639
  this._saveToHistory('cut');
@@ -2167,9 +2653,10 @@ class GeoJsonEditor extends HTMLElement {
2167
2653
  /**
2168
2654
  * Get line/column position from mouse event
2169
2655
  */
2170
- _getPositionFromClick(e) {
2171
- const viewport = this.shadowRoot.getElementById('viewport');
2172
- const linesContainer = this.shadowRoot.getElementById('linesContainer');
2656
+ private _getPositionFromClick(e: MouseEvent): { line: number; column: number } {
2657
+ const viewport = this._viewport;
2658
+ const linesContainer = this._linesContainer;
2659
+ if (!viewport) return { line: 0, column: 0 };
2173
2660
  const rect = viewport.getBoundingClientRect();
2174
2661
 
2175
2662
  const paddingTop = 8;
@@ -2210,28 +2697,34 @@ class GeoJsonEditor extends HTMLElement {
2210
2697
 
2211
2698
  // ========== Gutter Interactions ==========
2212
2699
 
2213
- handleGutterClick(e) {
2700
+ handleGutterClick(e: MouseEvent): void {
2701
+ const target = e.target as HTMLElement;
2702
+ if (!target) return;
2703
+
2214
2704
  // Visibility button in gutter
2215
- const visBtn = e.target.closest('.visibility-button');
2705
+ const visBtn = target.closest('.visibility-button') as HTMLElement | null;
2216
2706
  if (visBtn) {
2217
2707
  this.toggleFeatureVisibility(visBtn.dataset.featureKey);
2218
2708
  return;
2219
2709
  }
2220
-
2710
+
2221
2711
  // Collapse button in gutter
2222
- if (e.target.classList.contains('collapse-button')) {
2223
- const nodeId = e.target.dataset.nodeId;
2224
- this.toggleCollapse(nodeId);
2712
+ if (target.classList.contains('collapse-button')) {
2713
+ const nodeId = target.dataset.nodeId;
2714
+ if (nodeId) this.toggleCollapse(nodeId);
2225
2715
  return;
2226
2716
  }
2227
2717
  }
2228
2718
 
2229
- handleEditorClick(e) {
2719
+ handleEditorClick(e: MouseEvent): void {
2720
+ const target = e.target as HTMLElement;
2721
+ if (!target) return;
2722
+
2230
2723
  // Unblock render now that click is being processed
2231
2724
  this._blockRender = false;
2232
2725
 
2233
2726
  // Line-level visibility button (pseudo-element ::before on .line.has-visibility)
2234
- const lineEl = e.target.closest('.line.has-visibility');
2727
+ const lineEl = target.closest('.line.has-visibility') as HTMLElement | null;
2235
2728
  if (lineEl) {
2236
2729
  const rect = lineEl.getBoundingClientRect();
2237
2730
  const clickX = e.clientX - rect.left;
@@ -2245,42 +2738,44 @@ class GeoJsonEditor extends HTMLElement {
2245
2738
  return;
2246
2739
  }
2247
2740
  }
2248
-
2741
+
2249
2742
  // Inline color swatch (pseudo-element positioned with left: -8px)
2250
- if (e.target.classList.contains('json-color')) {
2251
- const rect = e.target.getBoundingClientRect();
2743
+ if (target.classList.contains('json-color')) {
2744
+ const rect = target.getBoundingClientRect();
2252
2745
  const clickX = e.clientX - rect.left;
2253
2746
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
2254
2747
  if (clickX < 0 && clickX >= -8) {
2255
2748
  e.preventDefault();
2256
2749
  e.stopPropagation();
2257
- const color = e.target.dataset.color;
2258
- const targetLineEl = e.target.closest('.line');
2750
+ const color = target.dataset.color;
2751
+ const targetLineEl = target.closest('.line') as HTMLElement | null;
2259
2752
  if (targetLineEl) {
2260
- const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
2753
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
2261
2754
  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]);
2755
+ // Match any string attribute (hex or named color)
2756
+ // RE_ATTR_VALUE_SINGLE captures: [1] attributeName, [2] stringValue
2757
+ const match = line.match(RE_ATTR_VALUE_SINGLE);
2758
+ if (match && match[1] && color) {
2759
+ this.showColorPicker(target, lineIndex, color, match[1]);
2265
2760
  }
2266
2761
  }
2267
2762
  return;
2268
2763
  }
2269
2764
  }
2270
-
2765
+
2271
2766
  // Inline boolean checkbox (pseudo-element positioned with left: -8px)
2272
- if (e.target.classList.contains('json-boolean')) {
2273
- const rect = e.target.getBoundingClientRect();
2767
+ if (target.classList.contains('json-boolean')) {
2768
+ const rect = target.getBoundingClientRect();
2274
2769
  const clickX = e.clientX - rect.left;
2275
2770
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
2276
2771
  if (clickX < 0 && clickX >= -8) {
2277
2772
  e.preventDefault();
2278
2773
  e.stopPropagation();
2279
- const targetLineEl = e.target.closest('.line');
2774
+ const targetLineEl = target.closest('.line') as HTMLElement | null;
2280
2775
  if (targetLineEl) {
2281
- const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
2776
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
2282
2777
  const line = this.lines[lineIndex];
2283
- const match = line.match(/"([\w-]+)"\s*:\s*(true|false)/);
2778
+ const match = line.match(RE_ATTR_AND_BOOL_VALUE);
2284
2779
  if (match) {
2285
2780
  const currentValue = match[2] === 'true';
2286
2781
  this.updateBooleanValue(lineIndex, !currentValue, match[1]);
@@ -2292,14 +2787,23 @@ class GeoJsonEditor extends HTMLElement {
2292
2787
  }
2293
2788
 
2294
2789
  // ========== Collapse/Expand ==========
2295
-
2296
- toggleCollapse(nodeId) {
2790
+
2791
+ toggleCollapse(nodeId: string): void {
2792
+ const nodeInfo = this._nodeIdToLines.get(nodeId);
2297
2793
  if (this.collapsedNodes.has(nodeId)) {
2298
2794
  this.collapsedNodes.delete(nodeId);
2795
+ // Track that user opened this node - don't re-collapse during edits
2796
+ if (nodeInfo?.uniqueKey) {
2797
+ this._openedNodeKeys.add(nodeInfo.uniqueKey);
2798
+ }
2299
2799
  } else {
2300
2800
  this.collapsedNodes.add(nodeId);
2801
+ // User closed it - allow re-collapse
2802
+ if (nodeInfo?.uniqueKey) {
2803
+ this._openedNodeKeys.delete(nodeInfo.uniqueKey);
2804
+ }
2301
2805
  }
2302
-
2806
+
2303
2807
  // Use updateView - don't rebuild nodeId mappings since content didn't change
2304
2808
  this.updateView();
2305
2809
  this._invalidateRenderCache(); // Force re-render
@@ -2307,15 +2811,47 @@ class GeoJsonEditor extends HTMLElement {
2307
2811
  }
2308
2812
 
2309
2813
  autoCollapseCoordinates() {
2814
+ // Don't collapse if there are errors - they should remain visible
2815
+ if (this._hasErrors()) {
2816
+ return;
2817
+ }
2310
2818
  this._applyCollapsedOption(['coordinates']);
2311
2819
  }
2312
2820
 
2821
+ /**
2822
+ * Check if current content has any errors (JSON parse errors or syntax highlighting errors)
2823
+ */
2824
+ private _hasErrors(): boolean {
2825
+ // Check JSON parse errors
2826
+ try {
2827
+ const content = this.lines.join('\n');
2828
+ const wrapped = '[' + content + ']';
2829
+ JSON.parse(wrapped);
2830
+ } catch {
2831
+ return true;
2832
+ }
2833
+
2834
+ // Check for syntax highlighting errors (json-error class)
2835
+ for (const line of this.lines) {
2836
+ const highlighted = highlightSyntax(line, '', undefined);
2837
+ if (highlighted.includes('json-error')) {
2838
+ return true;
2839
+ }
2840
+ }
2841
+
2842
+ return false;
2843
+ }
2844
+
2313
2845
  /**
2314
2846
  * 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
2847
+ * Does not collapse if there are errors (so they remain visible)
2317
2848
  */
2318
- _applyCollapsedFromOptions(options, features) {
2849
+ private _applyCollapsedFromOptions(options: SetOptions, features: Feature[]): void {
2850
+ // Don't collapse if there are errors - they should remain visible
2851
+ if (this._hasErrors()) {
2852
+ return;
2853
+ }
2854
+
2319
2855
  const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2320
2856
  if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2321
2857
  this._applyCollapsedOption(collapsed, features);
@@ -2324,10 +2860,8 @@ class GeoJsonEditor extends HTMLElement {
2324
2860
 
2325
2861
  /**
2326
2862
  * 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
2863
  */
2330
- _applyCollapsedOption(collapsed, features = null) {
2864
+ private _applyCollapsedOption(collapsed: string[] | ((feature: Feature | null, index: number) => string[]), features: Feature[] | null = null): void {
2331
2865
  const ranges = this._findCollapsibleRanges();
2332
2866
 
2333
2867
  // Group ranges by feature (root nodes)
@@ -2372,7 +2906,8 @@ class GeoJsonEditor extends HTMLElement {
2372
2906
 
2373
2907
  // ========== Feature Visibility ==========
2374
2908
 
2375
- toggleFeatureVisibility(featureKey) {
2909
+ toggleFeatureVisibility(featureKey: string | undefined): void {
2910
+ if (!featureKey) return;
2376
2911
  if (this.hiddenFeatures.has(featureKey)) {
2377
2912
  this.hiddenFeatures.delete(featureKey);
2378
2913
  } else {
@@ -2386,17 +2921,17 @@ class GeoJsonEditor extends HTMLElement {
2386
2921
  }
2387
2922
 
2388
2923
  // ========== Color Picker ==========
2389
-
2390
- showColorPicker(indicator, line, currentColor, attributeName) {
2924
+
2925
+ showColorPicker(indicator: HTMLElement, line: number, currentColor: string, attributeName: string) {
2391
2926
  // Remove existing picker and anchor
2392
2927
  const existing = document.querySelector('.geojson-color-picker-anchor');
2393
2928
  if (existing) {
2394
2929
  existing.remove();
2395
2930
  }
2396
-
2931
+
2397
2932
  // Create an anchor element at the pseudo-element position
2398
2933
  // The browser will position the color picker popup relative to this
2399
- const anchor = document.createElement('div');
2934
+ const anchor = _ce('div');
2400
2935
  anchor.className = 'geojson-color-picker-anchor';
2401
2936
  const rect = indicator.getBoundingClientRect();
2402
2937
  anchor.style.cssText = `
@@ -2408,10 +2943,19 @@ class GeoJsonEditor extends HTMLElement {
2408
2943
  z-index: 9998;
2409
2944
  `;
2410
2945
  document.body.appendChild(anchor);
2411
-
2412
- const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
2946
+
2947
+ const colorInput = _ce('input') as HTMLInputElement & { _closeListener?: EventListener };
2413
2948
  colorInput.type = 'color';
2414
- colorInput.value = currentColor;
2949
+ // Convert color to hex format for the color picker
2950
+ let hexColor = currentColor;
2951
+ if (!currentColor.startsWith('#')) {
2952
+ // Named color - convert to hex
2953
+ hexColor = namedColorToHex(currentColor) || '#000000';
2954
+ } else {
2955
+ // Expand 3-char hex to 6-char (#abc -> #aabbcc)
2956
+ hexColor = currentColor.replace(RE_NORMALIZE_COLOR, '#$1$1$2$2$3$3');
2957
+ }
2958
+ colorInput.value = hexColor;
2415
2959
  colorInput.className = 'geojson-color-picker-input';
2416
2960
 
2417
2961
  // Position the color input inside the anchor
@@ -2428,7 +2972,7 @@ class GeoJsonEditor extends HTMLElement {
2428
2972
  `;
2429
2973
  anchor.appendChild(colorInput);
2430
2974
 
2431
- colorInput.addEventListener('input', (e: Event) => {
2975
+ colorInput.addEventListener('input', (e) => {
2432
2976
  this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
2433
2977
  });
2434
2978
 
@@ -2440,7 +2984,7 @@ class GeoJsonEditor extends HTMLElement {
2440
2984
  };
2441
2985
 
2442
2986
  colorInput._closeListener = closeOnClickOutside;
2443
-
2987
+
2444
2988
  setTimeout(() => {
2445
2989
  document.addEventListener('click', closeOnClickOutside, true);
2446
2990
  }, 100);
@@ -2449,17 +2993,18 @@ class GeoJsonEditor extends HTMLElement {
2449
2993
  colorInput.click();
2450
2994
  }
2451
2995
 
2452
- updateColorValue(line, newColor, attributeName) {
2453
- const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"`);
2996
+ updateColorValue(line: number, newColor: string, attributeName: string) {
2997
+ // Match both hex colors (#xxx, #xxxxxx) and named colors (red, blue, etc.)
2998
+ const regex = new RegExp(`"${attributeName}"\\s*:\\s*"(?:#[0-9a-fA-F]{3,6}|[a-zA-Z]+)"`);
2454
2999
  this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
2455
-
3000
+
2456
3001
  // Use updateView to preserve collapsed state (line count didn't change)
2457
3002
  this.updateView();
2458
3003
  this.scheduleRender();
2459
3004
  this.emitChange();
2460
3005
  }
2461
3006
 
2462
- updateBooleanValue(line, newValue, attributeName) {
3007
+ updateBooleanValue(line: number, newValue: boolean, attributeName: string): void {
2463
3008
  const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
2464
3009
  this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": ${newValue}`);
2465
3010
 
@@ -2470,23 +3015,223 @@ class GeoJsonEditor extends HTMLElement {
2470
3015
  }
2471
3016
 
2472
3017
  // ========== Format and Update ==========
2473
-
3018
+
3019
+ /**
3020
+ * Best-effort formatting for invalid JSON
3021
+ * Splits on structural characters and indents as much as possible
3022
+ * @param content The content to format
3023
+ * @param skipLineIndex Optional line index to skip (keep as-is)
3024
+ */
3025
+ private _bestEffortFormat(content: string, skipLineIndex?: number): string[] {
3026
+ const sourceLines = content.split('\n');
3027
+
3028
+ // If we have a line to skip, handle it specially
3029
+ if (skipLineIndex !== undefined && skipLineIndex >= 0 && skipLineIndex < sourceLines.length) {
3030
+ const skippedLine = sourceLines[skipLineIndex];
3031
+
3032
+ // Format content before the skipped line
3033
+ const beforeContent = sourceLines.slice(0, skipLineIndex).join('\n');
3034
+ const beforeLines = beforeContent.trim() ? this._formatChunk(beforeContent) : [];
3035
+
3036
+ // Keep skipped line exactly as-is (don't re-indent, user is typing on it)
3037
+ const depthBefore = this._computeDepthAtEnd(beforeLines);
3038
+
3039
+ // Compute depth after the skipped line (including its brackets)
3040
+ const depthAfterSkipped = depthBefore + this._computeBracketDelta(skippedLine);
3041
+
3042
+ // Format content after the skipped line, starting at correct depth
3043
+ const afterContent = sourceLines.slice(skipLineIndex + 1).join('\n');
3044
+ const afterLines = afterContent.trim() ? this._formatChunk(afterContent, depthAfterSkipped) : [];
3045
+
3046
+ return [...beforeLines, skippedLine, ...afterLines];
3047
+ }
3048
+
3049
+ // No line to skip - format everything
3050
+ return this._formatChunk(content);
3051
+ }
3052
+
3053
+ /**
3054
+ * Compute the net bracket delta for a line (opens - closes)
3055
+ */
3056
+ private _computeBracketDelta(line: string): number {
3057
+ let delta = 0;
3058
+ let inString = false;
3059
+ let escaped = false;
3060
+ for (const char of line) {
3061
+ if (escaped) { escaped = false; continue; }
3062
+ if (char === '\\' && inString) { escaped = true; continue; }
3063
+ if (char === '"') { inString = !inString; continue; }
3064
+ if (inString) continue;
3065
+ if (char === '{' || char === '[') delta++;
3066
+ else if (char === '}' || char === ']') delta--;
3067
+ }
3068
+ return delta;
3069
+ }
3070
+
3071
+ /**
3072
+ * Compute the bracket depth at the end of formatted lines
3073
+ * Starts at 1 to account for FeatureCollection wrapper
3074
+ */
3075
+ private _computeDepthAtEnd(lines: string[]): number {
3076
+ let depth = 1; // Start at 1 for FeatureCollection wrapper
3077
+ for (const line of lines) {
3078
+ for (const char of line) {
3079
+ if (char === '{' || char === '[') depth++;
3080
+ else if (char === '}' || char === ']') depth = Math.max(0, depth - 1);
3081
+ }
3082
+ }
3083
+ return depth;
3084
+ }
3085
+
3086
+ /**
3087
+ * Format a chunk of JSON content
3088
+ * @param content The content to format
3089
+ * @param initialDepth Starting indentation depth (default 1 for FeatureCollection wrapper)
3090
+ */
3091
+ private _formatChunk(content: string, initialDepth: number = 1): string[] {
3092
+ const result: string[] = [];
3093
+ let currentLine = '';
3094
+ let depth = initialDepth;
3095
+ let inString = false;
3096
+ let escaped = false;
3097
+
3098
+ for (let i = 0; i < content.length; i++) {
3099
+ const char = content[i];
3100
+
3101
+ // Track escape sequences inside strings
3102
+ if (escaped) {
3103
+ currentLine += char;
3104
+ escaped = false;
3105
+ continue;
3106
+ }
3107
+
3108
+ if (char === '\\' && inString) {
3109
+ currentLine += char;
3110
+ escaped = true;
3111
+ continue;
3112
+ }
3113
+
3114
+ // Track if we're inside a string
3115
+ if (char === '"') {
3116
+ inString = !inString;
3117
+ currentLine += char;
3118
+ continue;
3119
+ }
3120
+
3121
+ // Inside string - just append
3122
+ if (inString) {
3123
+ currentLine += char;
3124
+ continue;
3125
+ }
3126
+
3127
+ // Outside string - handle structural characters
3128
+ if (char === '{' || char === '[') {
3129
+ currentLine += char;
3130
+ result.push(' '.repeat(depth) + currentLine.trim());
3131
+ depth++;
3132
+ currentLine = '';
3133
+ } else if (char === '}' || char === ']') {
3134
+ if (currentLine.trim()) {
3135
+ result.push(' '.repeat(depth) + currentLine.trim());
3136
+ }
3137
+ depth = Math.max(0, depth - 1);
3138
+ currentLine = char;
3139
+ } else if (char === ',') {
3140
+ currentLine += char;
3141
+ result.push(' '.repeat(depth) + currentLine.trim());
3142
+ currentLine = '';
3143
+ } else if (char === ':') {
3144
+ currentLine += ': '; // Add space after colon for readability
3145
+ i++; // Skip if next char is space
3146
+ if (content[i] === ' ') continue;
3147
+ i--; // Not a space, go back
3148
+ } else if (char === '\n' || char === '\r') {
3149
+ // Ignore existing newlines
3150
+ continue;
3151
+ } else {
3152
+ currentLine += char;
3153
+ }
3154
+ }
3155
+
3156
+ // Don't forget last line
3157
+ if (currentLine.trim()) {
3158
+ result.push(' '.repeat(depth) + currentLine.trim());
3159
+ }
3160
+
3161
+ return result;
3162
+ }
3163
+
2474
3164
  formatAndUpdate() {
3165
+ // Save cursor position
3166
+ const oldCursorLine = this.cursorLine;
3167
+ const oldCursorColumn = this.cursorColumn;
3168
+ const oldContent = this.lines.join('\n');
3169
+
2475
3170
  try {
2476
- const content = this.lines.join('\n');
2477
- const wrapped = '[' + content + ']';
3171
+ const wrapped = '[' + oldContent + ']';
2478
3172
  const parsed = JSON.parse(wrapped);
2479
-
3173
+
2480
3174
  const formatted = JSON.stringify(parsed, null, 2);
2481
3175
  const lines = formatted.split('\n');
2482
3176
  this.lines = lines.slice(1, -1); // Remove wrapper brackets
2483
- } catch (e) {
2484
- // Invalid JSON, keep as-is
3177
+ } catch {
3178
+ // Invalid JSON - apply best-effort formatting
3179
+ if (oldContent.trim()) {
3180
+ // Skip the cursor line only for small content (typing, not paste)
3181
+ // This avoids text jumping while user is typing
3182
+ // For paste/large insertions, format everything for proper structure
3183
+ const cursorLineContent = this.lines[oldCursorLine] || '';
3184
+ // If cursor line is short, likely typing. Long lines = paste
3185
+ const isSmallEdit = cursorLineContent.length < 80;
3186
+ const skipLine = isSmallEdit ? oldCursorLine : undefined;
3187
+ this.lines = this._bestEffortFormat(oldContent, skipLine);
3188
+ }
2485
3189
  }
2486
-
3190
+
3191
+ const newContent = this.lines.join('\n');
3192
+
3193
+ // If content didn't change, keep cursor exactly where it was
3194
+ if (newContent === oldContent) {
3195
+ this.cursorLine = oldCursorLine;
3196
+ this.cursorColumn = oldCursorColumn;
3197
+ } else {
3198
+ // Content changed due to reformatting
3199
+ // The cursor position (this.cursorLine, this.cursorColumn) was set by the calling
3200
+ // operation (insertText, insertNewline, etc.) BEFORE formatAndUpdate was called.
3201
+ // We need to adjust for indentation changes while keeping the logical position.
3202
+
3203
+ // If cursor is at column 0 (e.g., after newline), keep it there
3204
+ // This preserves expected behavior for newline insertion
3205
+ if (this.cursorColumn === 0) {
3206
+ // Just keep line, column 0 - indentation will be handled by auto-indent
3207
+ } else {
3208
+ // For other cases, try to maintain position relative to content (not indentation)
3209
+ const oldLines = oldContent.split('\n');
3210
+ const oldLineContent = oldLines[oldCursorLine] || '';
3211
+ const oldLeadingSpaces = oldLineContent.length - oldLineContent.trimStart().length;
3212
+ const oldColumnInContent = Math.max(0, oldCursorColumn - oldLeadingSpaces);
3213
+
3214
+ // Apply same offset to new line's indentation
3215
+ if (this.cursorLine < this.lines.length) {
3216
+ const newLineContent = this.lines[this.cursorLine];
3217
+ const newLeadingSpaces = newLineContent.length - newLineContent.trimStart().length;
3218
+ this.cursorColumn = newLeadingSpaces + oldColumnInContent;
3219
+ }
3220
+ }
3221
+ }
3222
+
3223
+ // Clamp cursor to valid range
3224
+ this.cursorLine = Math.min(this.cursorLine, Math.max(0, this.lines.length - 1));
3225
+ this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
3226
+
2487
3227
  this.updateModel();
3228
+
3229
+ // Expand any nodes that contain errors (prevents closing edited nodes with typos)
3230
+ this._expandErrorNodes();
3231
+
2488
3232
  this.scheduleRender();
2489
3233
  this.updatePlaceholderVisibility();
3234
+ this._updateErrorDisplay();
2490
3235
  this.emitChange();
2491
3236
  }
2492
3237
 
@@ -2501,14 +3246,14 @@ class GeoJsonEditor extends HTMLElement {
2501
3246
 
2502
3247
  // Filter hidden features
2503
3248
  if (this.hiddenFeatures.size > 0) {
2504
- parsed.features = parsed.features.filter((feature) => {
2505
- const key = this._getFeatureKey(feature);
2506
- return !this.hiddenFeatures.has(key);
3249
+ parsed.features = parsed.features.filter((feature: Feature) => {
3250
+ const key = getFeatureKey(feature);
3251
+ return key ? !this.hiddenFeatures.has(key) : true;
2507
3252
  });
2508
3253
  }
2509
3254
 
2510
3255
  // Validate
2511
- const errors = this._validateGeoJSON(parsed);
3256
+ const errors = validateGeoJSON(parsed);
2512
3257
 
2513
3258
  if (errors.length > 0) {
2514
3259
  this.dispatchEvent(new CustomEvent('error', {
@@ -2525,7 +3270,7 @@ class GeoJsonEditor extends HTMLElement {
2525
3270
  }
2526
3271
  } catch (e) {
2527
3272
  this.dispatchEvent(new CustomEvent('error', {
2528
- detail: { error: e.message, content },
3273
+ detail: { error: e instanceof Error ? e.message : 'Unknown error', content },
2529
3274
  bubbles: true,
2530
3275
  composed: true
2531
3276
  }));
@@ -2533,56 +3278,63 @@ class GeoJsonEditor extends HTMLElement {
2533
3278
  }
2534
3279
 
2535
3280
  // ========== UI Updates ==========
2536
-
2537
- updateReadonly() {
2538
- const textarea = this.shadowRoot.getElementById('hiddenTextarea');
2539
- const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
2540
3281
 
3282
+ updateReadonly() {
2541
3283
  // 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;
3284
+ if (this._hiddenTextarea) this._hiddenTextarea.readOnly = this.readonly;
3285
+ if (this._clearBtn) this._clearBtn.hidden = this.readonly;
2544
3286
  }
2545
3287
 
2546
3288
  updatePlaceholderVisibility() {
2547
- const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2548
- if (placeholder) {
2549
- placeholder.style.display = this.lines.length > 0 ? 'none' : 'block';
3289
+ if (this._placeholderLayer) {
3290
+ this._placeholderLayer.style.display = this.lines.length > 0 ? 'none' : 'block';
3291
+ }
3292
+ }
3293
+
3294
+ /**
3295
+ * Update error display (counter and navigation visibility)
3296
+ */
3297
+ private _updateErrorDisplay() {
3298
+ const errorLines = this._getErrorLines();
3299
+ const count = errorLines.length;
3300
+
3301
+ if (this._errorNav) {
3302
+ this._errorNav.classList.toggle('visible', count > 0);
3303
+ }
3304
+ if (this._errorCount) {
3305
+ this._errorCount.textContent = count > 0 ? String(count) : '';
2550
3306
  }
2551
3307
  }
2552
3308
 
2553
3309
  updatePlaceholderContent() {
2554
- const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2555
- if (placeholder) {
2556
- placeholder.textContent = this.placeholder;
3310
+ if (this._placeholderLayer) {
3311
+ this._placeholderLayer.textContent = this.placeholder;
2557
3312
  }
2558
3313
  this.updatePlaceholderVisibility();
2559
3314
  }
2560
3315
 
2561
3316
  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;
3317
+ if (this._editorPrefix) this._editorPrefix.textContent = this.prefix;
3318
+ if (this._editorSuffix) this._editorSuffix.textContent = this.suffix;
2567
3319
  }
2568
3320
 
2569
3321
  // ========== Theme ==========
2570
3322
 
2571
3323
  updateThemeCSS() {
2572
3324
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2573
- const darkRule = this._parseSelectorToHostRule(darkSelector);
2574
-
2575
- let themeStyle = this.shadowRoot.getElementById('theme-styles');
3325
+ const darkRule = parseSelectorToHostRule(darkSelector);
3326
+
3327
+ let themeStyle = this._id('theme-styles') as HTMLStyleElement;
2576
3328
  if (!themeStyle) {
2577
- themeStyle = document.createElement('style');
3329
+ themeStyle = _ce('style') as HTMLStyleElement;
2578
3330
  themeStyle.id = 'theme-styles';
2579
- this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
3331
+ this.shadowRoot!.insertBefore(themeStyle, this.shadowRoot!.firstChild);
2580
3332
  }
2581
3333
 
2582
3334
  const darkDefaults = {
2583
3335
  bgColor: '#2b2b2b',
2584
3336
  textColor: '#a9b7c6',
2585
- caretColor: '#bbbbbb',
3337
+ caretColor: '#bbb',
2586
3338
  gutterBg: '#313335',
2587
3339
  gutterBorder: '#3c3f41',
2588
3340
  gutterText: '#606366',
@@ -2602,14 +3354,16 @@ class GeoJsonEditor extends HTMLElement {
2602
3354
  jsonKeyInvalid: '#ff6b68'
2603
3355
  };
2604
3356
 
2605
- const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
2606
- const generateVars = (obj) => Object.entries(obj)
3357
+ RE_TO_KEBAB.lastIndex = 0;
3358
+ const toKebab = (str: string) => str.replace(RE_TO_KEBAB, '-$1').toLowerCase();
3359
+ const generateVars = (obj: Record<string, string | undefined>) => Object.entries(obj)
3360
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
2607
3361
  .map(([k, v]) => `--${toKebab(k)}: ${v};`)
2608
3362
  .join('\n ');
2609
3363
 
2610
- const lightVars = generateVars(this.themes.light || {});
3364
+ const lightVars = generateVars(this.themes.light as Record<string, string | undefined> || {});
2611
3365
  const darkTheme = { ...darkDefaults, ...this.themes.dark };
2612
- const darkVars = generateVars(darkTheme);
3366
+ const darkVars = generateVars(darkTheme as Record<string, string | undefined>);
2613
3367
 
2614
3368
  let css = lightVars ? `:host {\n ${lightVars}\n }\n` : '';
2615
3369
  css += `${darkRule} {\n ${darkVars}\n }`;
@@ -2617,14 +3371,6 @@ class GeoJsonEditor extends HTMLElement {
2617
3371
  themeStyle.textContent = css;
2618
3372
  }
2619
3373
 
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
3374
  setTheme(theme: ThemeSettings): void {
2629
3375
  if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2630
3376
  if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
@@ -2636,45 +3382,15 @@ class GeoJsonEditor extends HTMLElement {
2636
3382
  this.updateThemeCSS();
2637
3383
  }
2638
3384
 
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 };
3385
+ getTheme(): ThemeSettings {
3386
+ return { ...this.themes };
2671
3387
  }
2672
3388
 
2673
3389
  /**
2674
3390
  * Find all collapsible ranges using the mappings built by _rebuildNodeIdMappings
2675
3391
  * This method only READS the existing mappings, it doesn't create new IDs
2676
3392
  */
2677
- _findCollapsibleRanges() {
3393
+ private _findCollapsibleRanges() {
2678
3394
  const ranges = [];
2679
3395
 
2680
3396
  // Simply iterate through the existing mappings
@@ -2686,13 +3402,13 @@ class GeoJsonEditor extends HTMLElement {
2686
3402
  if (!line) continue;
2687
3403
 
2688
3404
  // Match "key": { or "key": [
2689
- const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
3405
+ const kvMatch = line.match(RE_KV_MATCH);
2690
3406
  // Also match standalone { or [ (root Feature objects)
2691
- const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
3407
+ const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
2692
3408
 
2693
3409
  if (!kvMatch && !rootMatch) continue;
2694
-
2695
- const openBracket = kvMatch ? kvMatch[2] : rootMatch[1];
3410
+
3411
+ const openBracket = kvMatch ? kvMatch[2] : (rootMatch ? rootMatch[1] : '{');
2696
3412
 
2697
3413
  ranges.push({
2698
3414
  startLine: rangeInfo.startLine,
@@ -2710,20 +3426,20 @@ class GeoJsonEditor extends HTMLElement {
2710
3426
  return ranges;
2711
3427
  }
2712
3428
 
2713
- _findClosingLine(startLine, openBracket) {
3429
+ private _findClosingLine(startLine: number, openBracket: string): number {
2714
3430
  let depth = 1;
2715
3431
  const line = this.lines[startLine];
2716
3432
  const bracketPos = line.indexOf(openBracket);
2717
3433
 
2718
3434
  if (bracketPos !== -1) {
2719
3435
  const rest = line.substring(bracketPos + 1);
2720
- const counts = this._countBrackets(rest, openBracket);
3436
+ const counts = countBrackets(rest, openBracket);
2721
3437
  depth += counts.open - counts.close;
2722
3438
  if (depth === 0) return startLine;
2723
3439
  }
2724
3440
 
2725
3441
  for (let i = startLine + 1; i < this.lines.length; i++) {
2726
- const counts = this._countBrackets(this.lines[i], openBracket);
3442
+ const counts = countBrackets(this.lines[i], openBracket);
2727
3443
  depth += counts.open - counts.close;
2728
3444
  if (depth === 0) return i;
2729
3445
  }
@@ -2731,7 +3447,7 @@ class GeoJsonEditor extends HTMLElement {
2731
3447
  return -1;
2732
3448
  }
2733
3449
 
2734
- _buildContextMap() {
3450
+ private _buildContextMap() {
2735
3451
  // Memoization: return cached result if content hasn't changed
2736
3452
  const linesLength = this.lines.length;
2737
3453
  if (this._contextMapCache &&
@@ -2741,9 +3457,9 @@ class GeoJsonEditor extends HTMLElement {
2741
3457
  return this._contextMapCache;
2742
3458
  }
2743
3459
 
2744
- const contextMap = new Map();
2745
- const contextStack = [];
2746
- let pendingContext = null;
3460
+ const contextMap = new Map<number, string>();
3461
+ const contextStack: { context: string; isArray: boolean }[] = [];
3462
+ let pendingContext: string | null = null;
2747
3463
 
2748
3464
  for (let i = 0; i < linesLength; i++) {
2749
3465
  const line = this.lines[i];
@@ -2756,10 +3472,14 @@ class GeoJsonEditor extends HTMLElement {
2756
3472
  else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
2757
3473
 
2758
3474
  // 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;
3475
+ RE_OPEN_BRACES.lastIndex = 0;
3476
+ RE_CLOSE_BRACES.lastIndex = 0;
3477
+ RE_OPEN_BRACKETS.lastIndex = 0;
3478
+ RE_CLOSE_BRACKET.lastIndex = 0;
3479
+ const openBraces = (line.match(RE_OPEN_BRACES) || []).length;
3480
+ const closeBraces = (line.match(RE_CLOSE_BRACES) || []).length;
3481
+ const openBrackets = (line.match(RE_OPEN_BRACKETS) || []).length;
3482
+ const closeBrackets = (line.match(RE_CLOSE_BRACKET) || []).length;
2763
3483
 
2764
3484
  for (let j = 0; j < openBraces + openBrackets; j++) {
2765
3485
  contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
@@ -2780,214 +3500,7 @@ class GeoJsonEditor extends HTMLElement {
2780
3500
  return contextMap;
2781
3501
  }
2782
3502
 
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 ==========
3503
+ // ========== Public API ==========
2991
3504
 
2992
3505
  /**
2993
3506
  * Replace all features in the editor
@@ -3001,10 +3514,8 @@ class GeoJsonEditor extends HTMLElement {
3001
3514
  * @throws {Error} If input is invalid
3002
3515
  */
3003
3516
  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);
3517
+ const features = normalizeToFeatures(input);
3518
+ this._setFeaturesInternal(features, options);
3008
3519
  }
3009
3520
 
3010
3521
  /**
@@ -3016,12 +3527,9 @@ class GeoJsonEditor extends HTMLElement {
3016
3527
  * @throws {Error} If input is invalid
3017
3528
  */
3018
3529
  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);
3530
+ const newFeatures = normalizeToFeatures(input);
3531
+ const allFeatures = [...this._parseFeatures(), ...newFeatures];
3532
+ this._setFeaturesInternal(allFeatures, options);
3025
3533
  }
3026
3534
 
3027
3535
  /**
@@ -3034,12 +3542,19 @@ class GeoJsonEditor extends HTMLElement {
3034
3542
  * @throws {Error} If input is invalid
3035
3543
  */
3036
3544
  insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
3037
- const newFeatures = this._normalizeToFeatures(input);
3545
+ const newFeatures = normalizeToFeatures(input);
3038
3546
  const features = this._parseFeatures();
3039
3547
  const idx = index < 0 ? features.length + index : index;
3040
3548
  features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
3549
+ this._setFeaturesInternal(features, options);
3550
+ }
3551
+
3552
+ /**
3553
+ * Internal method to set features with formatting and collapse options
3554
+ */
3555
+ private _setFeaturesInternal(features: Feature[], options: SetOptions): void {
3041
3556
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
3042
- this.setValue(formatted, false); // Don't auto-collapse coordinates
3557
+ this.setValue(formatted, false);
3043
3558
  this._applyCollapsedFromOptions(options, features);
3044
3559
  }
3045
3560
 
@@ -3097,7 +3612,7 @@ class GeoJsonEditor extends HTMLElement {
3097
3612
  const blob = new Blob([json], { type: 'application/geo+json' });
3098
3613
  const url = URL.createObjectURL(blob);
3099
3614
 
3100
- const a = document.createElement('a');
3615
+ const a = _ce('a') as HTMLAnchorElement;
3101
3616
  a.href = url;
3102
3617
  a.download = filename;
3103
3618
  document.body.appendChild(a);
@@ -3120,12 +3635,12 @@ class GeoJsonEditor extends HTMLElement {
3120
3635
  */
3121
3636
  open(options: SetOptions = {}): Promise<boolean> {
3122
3637
  return new Promise((resolve) => {
3123
- const input = document.createElement('input');
3638
+ const input = _ce('input') as HTMLInputElement;
3124
3639
  input.type = 'file';
3125
3640
  input.accept = '.geojson,.json,application/geo+json,application/json';
3126
3641
  input.style.display = 'none';
3127
3642
 
3128
- input.addEventListener('change', (e: Event) => {
3643
+ input.addEventListener('change', (e) => {
3129
3644
  const file = (e.target as HTMLInputElement).files?.[0];
3130
3645
  if (!file) {
3131
3646
  document.body.removeChild(input);
@@ -3140,7 +3655,7 @@ class GeoJsonEditor extends HTMLElement {
3140
3655
  const parsed = JSON.parse(content);
3141
3656
 
3142
3657
  // Normalize and validate features
3143
- const features = this._normalizeToFeatures(parsed);
3658
+ const features = normalizeToFeatures(parsed);
3144
3659
 
3145
3660
  // Load features into editor
3146
3661
  this._saveToHistory('open');
@@ -3173,7 +3688,7 @@ class GeoJsonEditor extends HTMLElement {
3173
3688
  });
3174
3689
  }
3175
3690
 
3176
- _parseFeatures() {
3691
+ private _parseFeatures() {
3177
3692
  try {
3178
3693
  const content = this.lines.join('\n');
3179
3694
  if (!content.trim()) return [];