@softwarity/geojson-editor 1.0.4 → 1.0.6

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.
@@ -7,6 +7,8 @@ class GeoJsonEditor extends HTMLElement {
7
7
  this.collapsedData = new Map(); // nodeKey -> {originalLines: string[], indent: number}
8
8
  this.colorPositions = []; // {line, color}
9
9
  this.nodeTogglePositions = []; // {line, nodeKey, isCollapsed, indent}
10
+ this.hiddenFeatures = new Set(); // Set of feature keys (hidden from events)
11
+ this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
10
12
 
11
13
  // Debounce timer for syntax highlighting
12
14
  this.highlightTimer = null;
@@ -15,65 +17,40 @@ class GeoJsonEditor extends HTMLElement {
15
17
  this._cachedLineHeight = null;
16
18
  this._cachedPaddingTop = null;
17
19
 
18
- // Initialize themes from defaults
19
- this.themes = {
20
- dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
21
- light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
22
- };
20
+ // Custom theme overrides (empty by default, CSS has defaults)
21
+ this.themes = { dark: {}, light: {} };
23
22
  }
24
23
 
25
24
  static get observedAttributes() {
26
- return ['readonly', 'value', 'placeholder', 'auto-format', 'dark-selector', 'feature-collection'];
27
- }
28
-
29
-
30
- // Default theme values
31
- static DEFAULT_THEMES = {
32
- dark: {
33
- background: '#1e1e1e',
34
- textColor: '#d4d4d4',
35
- caretColor: '#fff',
36
- gutterBackground: '#252526',
37
- gutterBorder: '#3e3e42',
38
- jsonKey: '#9cdcfe',
39
- jsonString: '#ce9178',
40
- jsonNumber: '#b5cea8',
41
- jsonBoolean: '#569cd6',
42
- jsonNull: '#569cd6',
43
- jsonPunctuation: '#d4d4d4',
44
- controlColor: '#c586c0',
45
- controlBg: '#3e3e42',
46
- controlBorder: '#555',
47
- geojsonKey: '#c586c0',
48
- geojsonType: '#4ec9b0',
49
- geojsonTypeInvalid: '#f44747',
50
- jsonKeyInvalid: '#f44747'
51
- },
52
- light: {
53
- background: '#ffffff',
54
- textColor: '#333333',
55
- caretColor: '#000',
56
- gutterBackground: '#f5f5f5',
57
- gutterBorder: '#ddd',
58
- jsonKey: '#0000ff',
59
- jsonString: '#a31515',
60
- jsonNumber: '#098658',
61
- jsonBoolean: '#0000ff',
62
- jsonNull: '#0000ff',
63
- jsonPunctuation: '#333333',
64
- controlColor: '#a31515',
65
- controlBg: '#e0e0e0',
66
- controlBorder: '#999',
67
- geojsonKey: '#af00db',
68
- geojsonType: '#267f99',
69
- geojsonTypeInvalid: '#d32f2f',
70
- jsonKeyInvalid: '#d32f2f'
71
- }
72
- };
25
+ return ['readonly', 'value', 'placeholder', 'dark-selector'];
26
+ }
73
27
 
74
- // FeatureCollection wrapper constants
75
- static FEATURE_COLLECTION_PREFIX = '{"type": "FeatureCollection", "features": [';
76
- static FEATURE_COLLECTION_SUFFIX = ']}';
28
+ // Helper: Convert camelCase to kebab-case
29
+ static _toKebabCase(str) {
30
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
31
+ }
32
+
33
+ // Dark theme defaults - IntelliJ Darcula (light defaults are CSS fallbacks)
34
+ static DARK_THEME_DEFAULTS = {
35
+ bgColor: '#2b2b2b',
36
+ textColor: '#a9b7c6',
37
+ caretColor: '#bbbbbb',
38
+ gutterBg: '#313335',
39
+ gutterBorder: '#3c3f41',
40
+ jsonKey: '#9876aa',
41
+ jsonString: '#6a8759',
42
+ jsonNumber: '#6897bb',
43
+ jsonBoolean: '#cc7832',
44
+ jsonNull: '#cc7832',
45
+ jsonPunct: '#a9b7c6',
46
+ controlColor: '#cc7832',
47
+ controlBg: '#3c3f41',
48
+ controlBorder: '#5a5a5a',
49
+ geojsonKey: '#9876aa',
50
+ geojsonType: '#6a8759',
51
+ geojsonTypeInvalid: '#ff6b68',
52
+ jsonKeyInvalid: '#ff6b68'
53
+ };
77
54
 
78
55
  // Pre-compiled regex patterns (avoid recompilation on each call)
79
56
  static REGEX = {
@@ -91,7 +68,7 @@ class GeoJsonEditor extends HTMLElement {
91
68
  allNumbers: /\b(-?\d+\.?\d*)\b/g,
92
69
  punctuation: /([{}[\],])/g,
93
70
  // Highlighting detection
94
- colorInLine: /"(\w+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,
71
+ colorInLine: /"([\w-]+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,
95
72
  collapsibleNode: /^(\s*)"(\w+)"\s*:\s*([{\[])/,
96
73
  collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
97
74
  };
@@ -113,6 +90,21 @@ class GeoJsonEditor extends HTMLElement {
113
90
  this.updatePlaceholderContent();
114
91
  }
115
92
 
93
+ disconnectedCallback() {
94
+ // Clean up any open color picker and its global listener
95
+ const colorPicker = document.querySelector('.geojson-color-picker-input');
96
+ if (colorPicker && colorPicker._closeListener) {
97
+ document.removeEventListener('click', colorPicker._closeListener, true);
98
+ colorPicker.remove();
99
+ }
100
+
101
+ // Clear any pending highlight timer
102
+ if (this.highlightTimer) {
103
+ clearTimeout(this.highlightTimer);
104
+ this.highlightTimer = null;
105
+ }
106
+ }
107
+
116
108
  attributeChangedCallback(name, oldValue, newValue) {
117
109
  if (oldValue === newValue) return;
118
110
 
@@ -124,15 +116,6 @@ class GeoJsonEditor extends HTMLElement {
124
116
  this.updatePlaceholderContent();
125
117
  } else if (name === 'dark-selector') {
126
118
  this.updateThemeCSS();
127
- } else if (name === 'feature-collection') {
128
- this.updatePrefixSuffix();
129
- } else if (name === 'auto-format') {
130
- // When auto-format is enabled, format the current content
131
- const textarea = this.shadowRoot?.getElementById('textarea');
132
- if (textarea && textarea.value && this.autoFormat) {
133
- this.autoFormatContent();
134
- this.updateHighlight();
135
- }
136
119
  }
137
120
  }
138
121
 
@@ -150,21 +133,13 @@ class GeoJsonEditor extends HTMLElement {
150
133
  return this.getAttribute('placeholder') || '';
151
134
  }
152
135
 
153
- get autoFormat() {
154
- return this.hasAttribute('auto-format');
155
- }
156
-
157
- get featureCollection() {
158
- return this.hasAttribute('feature-collection');
159
- }
160
-
161
- // Internal getters for prefix/suffix based on feature-collection mode
136
+ // Always in FeatureCollection mode - prefix/suffix are constant
162
137
  get prefix() {
163
- return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_PREFIX : '';
138
+ return '{"type": "FeatureCollection", "features": [';
164
139
  }
165
-
140
+
166
141
  get suffix() {
167
- return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_SUFFIX : '';
142
+ return ']}';
168
143
  }
169
144
 
170
145
  render() {
@@ -194,9 +169,6 @@ class GeoJsonEditor extends HTMLElement {
194
169
  position: relative;
195
170
  width: 100%;
196
171
  height: 400px;
197
- font-family: 'Courier New', Courier, monospace;
198
- font-size: 13px;
199
- line-height: 1.5;
200
172
  border-radius: 4px;
201
173
  overflow: hidden;
202
174
  }
@@ -227,18 +199,15 @@ class GeoJsonEditor extends HTMLElement {
227
199
  position: relative;
228
200
  width: 100%;
229
201
  flex: 1;
230
- background: var(--bg-color);
202
+ background: var(--bg-color, #ffffff);
231
203
  display: flex;
232
- font-family: 'Courier New', Courier, monospace;
233
- font-size: 13px;
234
- line-height: 1.5;
235
204
  }
236
205
 
237
206
  .gutter {
238
207
  width: 24px;
239
208
  height: 100%;
240
- background: var(--gutter-bg);
241
- border-right: 1px solid var(--gutter-border);
209
+ background: var(--gutter-bg, #f0f0f0);
210
+ border-right: 1px solid var(--gutter-border, #e0e0e0);
242
211
  overflow: hidden;
243
212
  flex-shrink: 0;
244
213
  position: relative;
@@ -280,10 +249,10 @@ class GeoJsonEditor extends HTMLElement {
280
249
  .collapse-button {
281
250
  width: 12px;
282
251
  height: 12px;
283
- background: var(--control-bg);
284
- border: 1px solid var(--control-border);
252
+ background: var(--control-bg, #e8e8e8);
253
+ border: 1px solid var(--control-border, #c0c0c0);
285
254
  border-radius: 2px;
286
- color: var(--control-color);
255
+ color: var(--control-color, #000080);
287
256
  font-size: 8px;
288
257
  font-weight: bold;
289
258
  cursor: pointer;
@@ -296,11 +265,42 @@ class GeoJsonEditor extends HTMLElement {
296
265
  }
297
266
 
298
267
  .collapse-button:hover {
299
- background: var(--control-bg);
300
- border-color: var(--control-color);
268
+ background: var(--control-bg, #e8e8e8);
269
+ border-color: var(--control-color, #000080);
301
270
  transform: scale(1.1);
302
271
  }
303
272
 
273
+ .visibility-button {
274
+ width: 14px;
275
+ height: 14px;
276
+ background: transparent;
277
+ border: none;
278
+ cursor: pointer;
279
+ display: flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ transition: all 0.1s;
283
+ flex-shrink: 0;
284
+ opacity: 0.7;
285
+ padding: 0;
286
+ font-size: 11px;
287
+ }
288
+
289
+ .visibility-button:hover {
290
+ opacity: 1;
291
+ transform: scale(1.15);
292
+ }
293
+
294
+ .visibility-button.hidden {
295
+ opacity: 0.35;
296
+ }
297
+
298
+ /* Hidden feature lines - grayed out */
299
+ .line-hidden {
300
+ opacity: 0.35;
301
+ filter: grayscale(50%);
302
+ }
303
+
304
304
  .color-picker-popup {
305
305
  position: absolute;
306
306
  background: #2d2d30;
@@ -331,17 +331,12 @@ class GeoJsonEditor extends HTMLElement {
331
331
  width: 100%;
332
332
  height: 100%;
333
333
  padding: 8px 12px;
334
- font-family: 'Courier New', Courier, monospace;
335
- font-size: 13px;
336
- font-weight: normal;
337
- font-style: normal;
338
- line-height: 1.5;
339
334
  white-space: pre-wrap;
340
335
  word-wrap: break-word;
341
336
  overflow: auto;
342
337
  pointer-events: none;
343
338
  z-index: 1;
344
- color: var(--text-color);
339
+ color: var(--text-color, #000000);
345
340
  }
346
341
 
347
342
  .highlight-layer::-webkit-scrollbar {
@@ -360,18 +355,12 @@ class GeoJsonEditor extends HTMLElement {
360
355
  outline: none;
361
356
  background: transparent;
362
357
  color: transparent;
363
- caret-color: var(--caret-color);
364
- font-family: 'Courier New', Courier, monospace;
365
- font-size: 13px;
366
- font-weight: normal;
367
- font-style: normal;
368
- line-height: 1.5;
358
+ caret-color: var(--caret-color, #000);
369
359
  white-space: pre-wrap;
370
360
  word-wrap: break-word;
371
361
  resize: none;
372
362
  overflow: auto;
373
363
  z-index: 2;
374
- box-sizing: border-box;
375
364
  }
376
365
 
377
366
  textarea::selection {
@@ -389,11 +378,6 @@ class GeoJsonEditor extends HTMLElement {
389
378
  width: 100%;
390
379
  height: 100%;
391
380
  padding: 8px 12px;
392
- font-family: 'Courier New', Courier, monospace;
393
- font-size: 13px;
394
- font-weight: normal;
395
- font-style: normal;
396
- line-height: 1.5;
397
381
  white-space: pre-wrap;
398
382
  word-wrap: break-word;
399
383
  color: #6a6a6a;
@@ -407,74 +391,116 @@ class GeoJsonEditor extends HTMLElement {
407
391
  opacity: 0.6;
408
392
  }
409
393
 
410
- /* Syntax highlighting colors */
394
+ /* Syntax highlighting colors - IntelliJ Light defaults */
411
395
  .json-key {
412
- color: var(--json-key);
396
+ color: var(--json-key, #660e7a);
413
397
  }
414
398
 
415
399
  .json-string {
416
- color: var(--json-string);
400
+ color: var(--json-string, #008000);
417
401
  }
418
402
 
419
403
  .json-number {
420
- color: var(--json-number);
404
+ color: var(--json-number, #0000ff);
421
405
  }
422
406
 
423
407
  .json-boolean {
424
- color: var(--json-boolean);
408
+ color: var(--json-boolean, #000080);
425
409
  }
426
410
 
427
411
  .json-null {
428
- color: var(--json-null);
412
+ color: var(--json-null, #000080);
429
413
  }
430
414
 
431
415
  .json-punctuation {
432
- color: var(--json-punct);
416
+ color: var(--json-punct, #000000);
433
417
  }
434
418
 
435
419
  /* GeoJSON-specific highlighting */
436
420
  .geojson-key {
437
- color: var(--geojson-key);
421
+ color: var(--geojson-key, #660e7a);
438
422
  font-weight: 600;
439
423
  }
440
424
 
441
425
  .geojson-type {
442
- color: var(--geojson-type);
426
+ color: var(--geojson-type, #008000);
443
427
  font-weight: 600;
444
428
  }
445
429
 
446
430
  .geojson-type-invalid {
447
- color: var(--geojson-type-invalid);
431
+ color: var(--geojson-type-invalid, #ff0000);
448
432
  font-weight: 600;
449
433
  }
450
434
 
451
435
  .json-key-invalid {
452
- color: var(--json-key-invalid);
436
+ color: var(--json-key-invalid, #ff0000);
437
+ }
438
+
439
+ /* Prefix and suffix wrapper with gutter */
440
+ .prefix-wrapper,
441
+ .suffix-wrapper {
442
+ display: flex;
443
+ flex-shrink: 0;
444
+ background: var(--bg-color, #ffffff);
445
+ }
446
+
447
+ .prefix-gutter,
448
+ .suffix-gutter {
449
+ width: 24px;
450
+ background: var(--gutter-bg, #f0f0f0);
451
+ border-right: 1px solid var(--gutter-border, #e0e0e0);
452
+ flex-shrink: 0;
453
453
  }
454
454
 
455
- /* Prefix and suffix styling */
456
455
  .editor-prefix,
457
456
  .editor-suffix {
457
+ flex: 1;
458
458
  padding: 4px 12px;
459
- color: var(--text-color);
460
- background: var(--bg-color);
459
+ color: var(--text-color, #000000);
460
+ background: var(--bg-color, #ffffff);
461
461
  user-select: none;
462
462
  white-space: pre-wrap;
463
463
  word-wrap: break-word;
464
- flex-shrink: 0;
465
- font-family: 'Courier New', Courier, monospace;
466
- font-size: 13px;
467
- line-height: 1.5;
468
464
  opacity: 0.6;
469
- border-left: 3px solid rgba(102, 126, 234, 0.5);
470
465
  }
471
466
 
472
- .editor-prefix {
467
+ .prefix-wrapper {
473
468
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
474
469
  }
475
470
 
476
- .editor-suffix {
471
+ .suffix-wrapper {
477
472
  border-top: 1px solid rgba(255, 255, 255, 0.1);
473
+ position: relative;
474
+ }
475
+
476
+ /* Clear button in suffix area */
477
+ .clear-btn {
478
+ position: absolute;
479
+ right: 0.5rem;
480
+ top: 50%;
481
+ transform: translateY(-50%);
482
+ background: transparent;
483
+ border: none;
484
+ color: var(--text-color, #000000);
485
+ opacity: 0.3;
486
+ cursor: pointer;
487
+ font-size: 0.65rem;
488
+ width: 1rem;
489
+ height: 1rem;
490
+ padding: 0.15rem 0 0 0;
491
+ border-radius: 3px;
492
+ display: flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ box-sizing: border-box;
496
+ transition: opacity 0.2s, background 0.2s;
497
+ }
498
+ .clear-btn:hover {
499
+ opacity: 0.7;
500
+ background: rgba(255, 255, 255, 0.1);
501
+ }
502
+ .clear-btn[hidden] {
503
+ display: none;
478
504
  }
479
505
 
480
506
  /* Scrollbar styling - WebKit (Chrome, Safari, Edge) */
@@ -484,28 +510,31 @@ class GeoJsonEditor extends HTMLElement {
484
510
  }
485
511
 
486
512
  textarea::-webkit-scrollbar-track {
487
- background: var(--control-bg);
513
+ background: var(--control-bg, #e8e8e8);
488
514
  }
489
515
 
490
516
  textarea::-webkit-scrollbar-thumb {
491
- background: var(--control-border);
517
+ background: var(--control-border, #c0c0c0);
492
518
  border-radius: 5px;
493
519
  }
494
520
 
495
521
  textarea::-webkit-scrollbar-thumb:hover {
496
- background: var(--control-color);
522
+ background: var(--control-color, #000080);
497
523
  }
498
524
 
499
525
  /* Scrollbar styling - Firefox */
500
526
  textarea {
501
527
  scrollbar-width: thin;
502
- scrollbar-color: var(--control-border) var(--control-bg);
528
+ scrollbar-color: var(--control-border, #c0c0c0) var(--control-bg, #e8e8e8);
503
529
  }
504
530
  </style>
505
531
  `;
506
532
 
507
533
  const template = `
508
- <div class="editor-prefix" id="editorPrefix"></div>
534
+ <div class="prefix-wrapper">
535
+ <div class="prefix-gutter"></div>
536
+ <div class="editor-prefix" id="editorPrefix"></div>
537
+ </div>
509
538
  <div class="editor-wrapper">
510
539
  <div class="gutter">
511
540
  <div class="gutter-content" id="gutterContent"></div>
@@ -522,7 +551,11 @@ class GeoJsonEditor extends HTMLElement {
522
551
  ></textarea>
523
552
  </div>
524
553
  </div>
525
- <div class="editor-suffix" id="editorSuffix"></div>
554
+ <div class="suffix-wrapper">
555
+ <div class="suffix-gutter"></div>
556
+ <div class="editor-suffix" id="editorSuffix"></div>
557
+ <button class="clear-btn" id="clearBtn" title="Clear editor">✕</button>
558
+ </div>
526
559
  `;
527
560
 
528
561
  this.shadowRoot.innerHTML = styles + template;
@@ -546,10 +579,8 @@ class GeoJsonEditor extends HTMLElement {
546
579
 
547
580
  clearTimeout(this.highlightTimer);
548
581
  this.highlightTimer = setTimeout(() => {
549
- // Auto-format if enabled and JSON is valid
550
- if (this.autoFormat) {
551
- this.autoFormatContentWithCursor();
552
- }
582
+ // Auto-format JSON content
583
+ this.autoFormatContentWithCursor();
553
584
  this.updateHighlight();
554
585
  this.emitChange();
555
586
  }, 150);
@@ -563,10 +594,8 @@ class GeoJsonEditor extends HTMLElement {
563
594
  // Use a short delay to let the paste complete
564
595
  setTimeout(() => {
565
596
  this.updatePlaceholderVisibility();
566
- // Auto-format if enabled and JSON is valid
567
- if (this.autoFormat) {
568
- this.autoFormatContentWithCursor();
569
- }
597
+ // Auto-format JSON content
598
+ this.autoFormatContentWithCursor();
570
599
  this.updateHighlight();
571
600
  this.emitChange();
572
601
  // Auto-collapse coordinates after paste
@@ -577,6 +606,14 @@ class GeoJsonEditor extends HTMLElement {
577
606
  // Gutter clicks (color indicators and collapse buttons)
578
607
  const gutterContent = this.shadowRoot.getElementById('gutterContent');
579
608
  gutterContent.addEventListener('click', (e) => {
609
+ // Check for visibility button (may click on SVG inside button)
610
+ const visibilityButton = e.target.closest('.visibility-button');
611
+ if (visibilityButton) {
612
+ const featureKey = visibilityButton.dataset.featureKey;
613
+ this.toggleFeatureVisibility(featureKey);
614
+ return;
615
+ }
616
+
580
617
  if (e.target.classList.contains('color-indicator')) {
581
618
  const line = parseInt(e.target.dataset.line);
582
619
  const color = e.target.dataset.color;
@@ -611,6 +648,12 @@ class GeoJsonEditor extends HTMLElement {
611
648
  this.handleCutWithCollapsedContent(e);
612
649
  });
613
650
 
651
+ // Clear button
652
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
653
+ clearBtn.addEventListener('click', () => {
654
+ this.removeAll();
655
+ });
656
+
614
657
  // Update readonly state
615
658
  this.updateReadonly();
616
659
  }
@@ -625,14 +668,20 @@ class GeoJsonEditor extends HTMLElement {
625
668
  if (textarea) {
626
669
  textarea.disabled = this.readonly;
627
670
  }
671
+ // Hide clear button in readonly mode
672
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
673
+ if (clearBtn) {
674
+ clearBtn.hidden = this.readonly;
675
+ }
628
676
  }
629
677
 
630
678
  escapeHtml(text) {
631
679
  if (!text) return '';
680
+ const R = GeoJsonEditor.REGEX;
632
681
  return text
633
- .replace(/&/g, '&amp;')
634
- .replace(/</g, '&lt;')
635
- .replace(/>/g, '&gt;');
682
+ .replace(R.ampersand, '&amp;')
683
+ .replace(R.lessThan, '&lt;')
684
+ .replace(R.greaterThan, '&gt;');
636
685
  }
637
686
 
638
687
  updatePlaceholderVisibility() {
@@ -656,8 +705,8 @@ class GeoJsonEditor extends HTMLElement {
656
705
  if (textarea && textarea.value !== newValue) {
657
706
  textarea.value = newValue || '';
658
707
 
659
- // Apply auto-format if enabled
660
- if (this.autoFormat && newValue) {
708
+ // Auto-format JSON content
709
+ if (newValue) {
661
710
  try {
662
711
  const prefix = this.prefix;
663
712
  const suffix = this.suffix;
@@ -709,24 +758,13 @@ class GeoJsonEditor extends HTMLElement {
709
758
  const prefixEl = this.shadowRoot.getElementById('editorPrefix');
710
759
  const suffixEl = this.shadowRoot.getElementById('editorSuffix');
711
760
 
761
+ // Always show prefix/suffix (always in FeatureCollection mode)
712
762
  if (prefixEl) {
713
- if (this.prefix) {
714
- prefixEl.textContent = this.prefix;
715
- prefixEl.style.display = 'block';
716
- } else {
717
- prefixEl.textContent = '';
718
- prefixEl.style.display = 'none';
719
- }
763
+ prefixEl.textContent = this.prefix;
720
764
  }
721
765
 
722
766
  if (suffixEl) {
723
- if (this.suffix) {
724
- suffixEl.textContent = this.suffix;
725
- suffixEl.style.display = 'block';
726
- } else {
727
- suffixEl.textContent = '';
728
- suffixEl.style.display = 'none';
729
- }
767
+ suffixEl.textContent = this.suffix;
730
768
  }
731
769
  }
732
770
 
@@ -738,8 +776,14 @@ class GeoJsonEditor extends HTMLElement {
738
776
 
739
777
  const text = textarea.value;
740
778
 
779
+ // Update feature ranges for visibility tracking
780
+ this.updateFeatureRanges();
781
+
782
+ // Get hidden line ranges
783
+ const hiddenRanges = this.getHiddenLineRanges();
784
+
741
785
  // Parse and highlight
742
- const { highlighted, colors, toggles } = this.highlightJSON(text);
786
+ const { highlighted, colors, toggles } = this.highlightJSON(text, hiddenRanges);
743
787
 
744
788
  highlightLayer.innerHTML = highlighted;
745
789
  this.colorPositions = colors;
@@ -749,7 +793,7 @@ class GeoJsonEditor extends HTMLElement {
749
793
  this.updateGutter();
750
794
  }
751
795
 
752
- highlightJSON(text) {
796
+ highlightJSON(text, hiddenRanges = []) {
753
797
  if (!text.trim()) {
754
798
  return { highlighted: '', colors: [], toggles: [] };
755
799
  }
@@ -762,6 +806,11 @@ class GeoJsonEditor extends HTMLElement {
762
806
  // Build context map for validation
763
807
  const contextMap = this.buildContextMap(text);
764
808
 
809
+ // Helper to check if a line is in a hidden range
810
+ const isLineHidden = (lineIndex) => {
811
+ return hiddenRanges.some(range => lineIndex >= range.startLine && lineIndex <= range.endLine);
812
+ };
813
+
765
814
  lines.forEach((line, lineIndex) => {
766
815
  // Detect any hex color (6 digits) in string values
767
816
  const R = GeoJsonEditor.REGEX;
@@ -804,7 +853,14 @@ class GeoJsonEditor extends HTMLElement {
804
853
 
805
854
  // Highlight the line with context
806
855
  const context = contextMap.get(lineIndex);
807
- highlightedLines.push(this.highlightSyntax(line, context));
856
+ let highlightedLine = this.highlightSyntax(line, context);
857
+
858
+ // Wrap hidden lines with .line-hidden class
859
+ if (isLineHidden(lineIndex)) {
860
+ highlightedLine = `<span class="line-hidden">${highlightedLine}</span>`;
861
+ }
862
+
863
+ highlightedLines.push(highlightedLine);
808
864
  });
809
865
 
810
866
  return {
@@ -849,8 +905,8 @@ class GeoJsonEditor extends HTMLElement {
849
905
  const contextStack = []; // Stack of {context, isArray}
850
906
  let pendingContext = null; // Context for next object/array
851
907
 
852
- // Determine root context based on feature-collection mode
853
- const rootContext = this.featureCollection ? 'Feature' : null;
908
+ // Root context is always 'Feature' (always in FeatureCollection mode)
909
+ const rootContext = 'Feature';
854
910
 
855
911
  for (let i = 0; i < lines.length; i++) {
856
912
  const line = lines[i];
@@ -862,37 +918,61 @@ class GeoJsonEditor extends HTMLElement {
862
918
  contextMap.set(i, lineContext);
863
919
 
864
920
  // Process each character to track brackets for subsequent lines
921
+ // Track string state to ignore brackets inside strings
922
+ let inString = false;
923
+ let escape = false;
924
+
865
925
  for (let j = 0; j < line.length; j++) {
866
926
  const char = line[j];
867
927
 
868
- // Check for key that changes context: "keyName":
928
+ // Handle escape sequences
929
+ if (escape) {
930
+ escape = false;
931
+ continue;
932
+ }
933
+ if (char === '\\' && inString) {
934
+ escape = true;
935
+ continue;
936
+ }
937
+
938
+ // Track string boundaries
869
939
  if (char === '"') {
870
- const keyMatch = line.substring(j).match(/^"([^"]+)"\s*:/);
871
- if (keyMatch) {
872
- const keyName = keyMatch[1];
873
- if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
874
- pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
940
+ if (!inString) {
941
+ // Entering string - check for special patterns before toggling
942
+ const keyMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"\s*:/);
943
+ if (keyMatch) {
944
+ const keyName = keyMatch[1];
945
+ if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
946
+ pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
947
+ }
948
+ j += keyMatch[0].length - 1; // Skip past the key
949
+ continue;
875
950
  }
876
- j += keyMatch[0].length - 1; // Skip past the key
877
- continue;
878
- }
879
- }
880
951
 
881
- // Check for type value to refine context: "type": "Point"
882
- if (char === '"' && contextStack.length > 0) {
883
- const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
884
- if (typeMatch) {
885
- const valueMatch = line.substring(j).match(/^"([^"]+)"/);
886
- if (valueMatch && GeoJsonEditor.GEOJSON_TYPES_ALL.includes(valueMatch[1])) {
887
- // Update current context to the specific type
888
- const currentCtx = contextStack[contextStack.length - 1];
889
- if (currentCtx) {
890
- currentCtx.context = valueMatch[1];
952
+ // Check for type value to refine context: "type": "Point"
953
+ if (contextStack.length > 0) {
954
+ const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
955
+ if (typeMatch) {
956
+ const valueMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"/);
957
+ if (valueMatch && GeoJsonEditor.GEOJSON_TYPES_ALL.includes(valueMatch[1])) {
958
+ const currentCtx = contextStack[contextStack.length - 1];
959
+ if (currentCtx) {
960
+ currentCtx.context = valueMatch[1];
961
+ }
962
+ }
963
+ // Skip past this string value
964
+ j += valueMatch ? valueMatch[0].length - 1 : 0;
965
+ continue;
891
966
  }
892
967
  }
893
968
  }
969
+ inString = !inString;
970
+ continue;
894
971
  }
895
972
 
973
+ // Skip everything inside strings (brackets, etc.)
974
+ if (inString) continue;
975
+
896
976
  // Opening bracket - push context
897
977
  if (char === '{' || char === '[') {
898
978
  let newContext;
@@ -900,10 +980,8 @@ class GeoJsonEditor extends HTMLElement {
900
980
  newContext = pendingContext;
901
981
  pendingContext = null;
902
982
  } else if (contextStack.length === 0) {
903
- // Root level
904
983
  newContext = rootContext;
905
984
  } else {
906
- // Inherit from parent if in array
907
985
  const parent = contextStack[contextStack.length - 1];
908
986
  if (parent && parent.isArray) {
909
987
  newContext = parent.context;
@@ -967,7 +1045,7 @@ class GeoJsonEditor extends HTMLElement {
967
1045
  .replace(R.lessThan, '&lt;')
968
1046
  .replace(R.greaterThan, '&gt;')
969
1047
  // All JSON keys - validate against context
970
- .replace(R.jsonKey, (match, key) => {
1048
+ .replace(R.jsonKey, (_, key) => {
971
1049
  // Inside properties - all keys are regular user keys
972
1050
  if (context === 'properties') {
973
1051
  return `<span class="json-key">"${key}"</span>:`;
@@ -984,7 +1062,7 @@ class GeoJsonEditor extends HTMLElement {
984
1062
  }
985
1063
  })
986
1064
  // GeoJSON "type" values - validate based on context
987
- .replace(R.typeValue, (match, typeValue) => {
1065
+ .replace(R.typeValue, (_, typeValue) => {
988
1066
  if (isTypeValid(typeValue)) {
989
1067
  return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
990
1068
  } else {
@@ -1056,48 +1134,9 @@ class GeoJsonEditor extends HTMLElement {
1056
1134
 
1057
1135
  const indent = match[1];
1058
1136
  const openBracket = match[3];
1059
- const closeBracket = openBracket === '{' ? '}' : ']';
1060
-
1061
- // Check if bracket closes on same line - can't collapse
1062
- if (this.bracketClosesOnSameLine(currentLine, openBracket)) return;
1063
1137
 
1064
- // Find closing bracket in following lines
1065
- let depth = 1;
1066
- let endLine = line;
1067
- const content = [];
1068
-
1069
- for (let i = line + 1; i < lines.length; i++) {
1070
- const scanLine = lines[i];
1071
-
1072
- for (const char of scanLine) {
1073
- if (char === openBracket) depth++;
1074
- if (char === closeBracket) depth--;
1075
- }
1076
-
1077
- content.push(scanLine);
1078
-
1079
- if (depth === 0) {
1080
- endLine = i;
1081
- break;
1082
- }
1083
- }
1084
-
1085
- // Store the original data with unique key
1086
- const uniqueKey = `${line}-${nodeKey}`;
1087
- this.collapsedData.set(uniqueKey, {
1088
- originalLine: currentLine,
1089
- content: content,
1090
- indent: indent.length,
1091
- nodeKey: nodeKey // Store nodeKey for later use
1092
- });
1093
-
1094
- // Replace with marker
1095
- const beforeBracket = currentLine.substring(0, currentLine.indexOf(openBracket));
1096
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1097
- lines[line] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1098
-
1099
- // Remove content lines
1100
- lines.splice(line + 1, endLine - line);
1138
+ // Use common collapse helper
1139
+ if (this._performCollapse(lines, line, nodeKey, indent, openBracket) === 0) return;
1101
1140
  }
1102
1141
 
1103
1142
  // Update textarea
@@ -1123,48 +1162,9 @@ class GeoJsonEditor extends HTMLElement {
1123
1162
  if (nodeKey === 'coordinates') {
1124
1163
  const indent = match[1];
1125
1164
  const openBracket = match[3];
1126
- const closeBracket = openBracket === '{' ? '}' : ']';
1127
-
1128
- // Skip if bracket closes on same line
1129
- if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1130
1165
 
1131
- // Find closing bracket in following lines
1132
- let depth = 1;
1133
- let endLine = i;
1134
- const content = [];
1135
-
1136
- for (let j = i + 1; j < lines.length; j++) {
1137
- const scanLine = lines[j];
1138
-
1139
- for (const char of scanLine) {
1140
- if (char === openBracket) depth++;
1141
- if (char === closeBracket) depth--;
1142
- }
1143
-
1144
- content.push(scanLine);
1145
-
1146
- if (depth === 0) {
1147
- endLine = j;
1148
- break;
1149
- }
1150
- }
1151
-
1152
- // Store the original data with unique key
1153
- const uniqueKey = `${i}-${nodeKey}`;
1154
- this.collapsedData.set(uniqueKey, {
1155
- originalLine: line,
1156
- content: content,
1157
- indent: indent.length,
1158
- nodeKey: nodeKey
1159
- });
1160
-
1161
- // Replace with marker
1162
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
1163
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1164
- lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1165
-
1166
- // Remove content lines
1167
- lines.splice(i + 1, endLine - i);
1166
+ // Use common collapse helper
1167
+ this._performCollapse(lines, i, nodeKey, indent, openBracket);
1168
1168
  }
1169
1169
  }
1170
1170
  }
@@ -1193,25 +1193,33 @@ class GeoJsonEditor extends HTMLElement {
1193
1193
  // Clear gutter
1194
1194
  gutterContent.textContent = '';
1195
1195
 
1196
- // Create a map of line -> elements (color, collapse button, or both)
1196
+ // Create a map of line -> elements (color, collapse button, visibility button)
1197
1197
  const lineElements = new Map();
1198
1198
 
1199
- // Add color indicators
1200
- this.colorPositions.forEach(({ line, color, attributeName }) => {
1199
+ // Helper to ensure line entry exists
1200
+ const ensureLine = (line) => {
1201
1201
  if (!lineElements.has(line)) {
1202
- lineElements.set(line, { colors: [], buttons: [] });
1202
+ lineElements.set(line, { colors: [], buttons: [], visibilityButtons: [] });
1203
1203
  }
1204
- lineElements.get(line).colors.push({ color, attributeName });
1204
+ return lineElements.get(line);
1205
+ };
1206
+
1207
+ // Add color indicators
1208
+ this.colorPositions.forEach(({ line, color, attributeName }) => {
1209
+ ensureLine(line).colors.push({ color, attributeName });
1205
1210
  });
1206
1211
 
1207
1212
  // Add collapse buttons
1208
1213
  this.nodeTogglePositions.forEach(({ line, nodeKey, isCollapsed }) => {
1209
- if (!lineElements.has(line)) {
1210
- lineElements.set(line, { colors: [], buttons: [] });
1211
- }
1212
- lineElements.get(line).buttons.push({ nodeKey, isCollapsed });
1214
+ ensureLine(line).buttons.push({ nodeKey, isCollapsed });
1213
1215
  });
1214
1216
 
1217
+ // Add visibility buttons for Features (on the opening brace line)
1218
+ for (const [featureKey, range] of this.featureRanges) {
1219
+ const isHidden = this.hiddenFeatures.has(featureKey);
1220
+ ensureLine(range.startLine).visibilityButtons.push({ featureKey, isHidden });
1221
+ }
1222
+
1215
1223
  // Create gutter lines with DocumentFragment (single DOM update)
1216
1224
  const fragment = document.createDocumentFragment();
1217
1225
 
@@ -1220,6 +1228,16 @@ class GeoJsonEditor extends HTMLElement {
1220
1228
  gutterLine.className = 'gutter-line';
1221
1229
  gutterLine.style.top = `${paddingTop + line * lineHeight}px`;
1222
1230
 
1231
+ // Add visibility buttons first (leftmost)
1232
+ elements.visibilityButtons.forEach(({ featureKey, isHidden }) => {
1233
+ const button = document.createElement('button');
1234
+ button.className = 'visibility-button' + (isHidden ? ' hidden' : '');
1235
+ button.textContent = '👁';
1236
+ button.dataset.featureKey = featureKey;
1237
+ button.title = isHidden ? 'Show feature in events' : 'Hide feature from events';
1238
+ gutterLine.appendChild(button);
1239
+ });
1240
+
1223
1241
  // Add color indicators
1224
1242
  elements.colors.forEach(({ color, attributeName }) => {
1225
1243
  const indicator = document.createElement('div');
@@ -1251,9 +1269,15 @@ class GeoJsonEditor extends HTMLElement {
1251
1269
  }
1252
1270
 
1253
1271
  showColorPicker(indicator, line, currentColor, attributeName) {
1254
- // Remove existing picker
1272
+ // Remove existing picker and clean up its listener
1255
1273
  const existing = document.querySelector('.geojson-color-picker-input');
1256
- if (existing) existing.remove();
1274
+ if (existing) {
1275
+ // Clean up the stored listener before removing
1276
+ if (existing._closeListener) {
1277
+ document.removeEventListener('click', existing._closeListener, true);
1278
+ }
1279
+ existing.remove();
1280
+ }
1257
1281
 
1258
1282
  // Create small color input positioned at the indicator
1259
1283
  const colorInput = document.createElement('input');
@@ -1287,11 +1311,14 @@ class GeoJsonEditor extends HTMLElement {
1287
1311
  // Close picker when clicking anywhere else
1288
1312
  const closeOnClickOutside = (e) => {
1289
1313
  if (e.target !== colorInput && !colorInput.contains(e.target)) {
1290
- colorInput.remove();
1291
1314
  document.removeEventListener('click', closeOnClickOutside, true);
1315
+ colorInput.remove();
1292
1316
  }
1293
1317
  };
1294
1318
 
1319
+ // Store the listener reference on the element for cleanup
1320
+ colorInput._closeListener = closeOnClickOutside;
1321
+
1295
1322
  // Add to document body with fixed positioning
1296
1323
  document.body.appendChild(colorInput);
1297
1324
 
@@ -1353,8 +1380,15 @@ class GeoJsonEditor extends HTMLElement {
1353
1380
  return; // No collapsed content, use default copy behavior
1354
1381
  }
1355
1382
 
1356
- // Replace collapsed markers with real content
1357
- const expandedText = this.expandCollapsedMarkersInText(selectedText, start);
1383
+ let expandedText;
1384
+
1385
+ // If selecting all content, use expandAllCollapsed directly (more reliable)
1386
+ if (start === 0 && end === textarea.value.length) {
1387
+ expandedText = this.expandAllCollapsed(selectedText);
1388
+ } else {
1389
+ // For partial selection, expand using line-by-line matching
1390
+ expandedText = this.expandCollapsedMarkersInText(selectedText, start);
1391
+ }
1358
1392
 
1359
1393
  // Put expanded text in clipboard
1360
1394
  e.preventDefault();
@@ -1365,6 +1399,7 @@ class GeoJsonEditor extends HTMLElement {
1365
1399
  const textarea = this.shadowRoot.getElementById('textarea');
1366
1400
  const beforeSelection = textarea.value.substring(0, startPos);
1367
1401
  const startLineNum = beforeSelection.split('\n').length - 1;
1402
+ const R = GeoJsonEditor.REGEX;
1368
1403
 
1369
1404
  const lines = text.split('\n');
1370
1405
  const expandedLines = [];
@@ -1374,17 +1409,43 @@ class GeoJsonEditor extends HTMLElement {
1374
1409
 
1375
1410
  // Check if this line has a collapsed marker
1376
1411
  if (line.includes('{...}') || line.includes('[...]')) {
1377
- // Find the collapsed node for this line
1412
+ const match = line.match(R.collapsedMarker);
1413
+ if (match) {
1414
+ const nodeKey = match[2]; // Extract nodeKey from the marker
1415
+ const exactKey = `${absoluteLineNum}-${nodeKey}`;
1416
+
1417
+ // Try exact key match first
1418
+ if (this.collapsedData.has(exactKey)) {
1419
+ const collapsed = this.collapsedData.get(exactKey);
1420
+ expandedLines.push(collapsed.originalLine);
1421
+ expandedLines.push(...collapsed.content);
1422
+ return;
1423
+ }
1424
+
1425
+ // Fallback: search by line number and nodeKey
1426
+ let found = false;
1427
+ for (const [key, collapsed] of this.collapsedData.entries()) {
1428
+ if (key.endsWith(`-${nodeKey}`)) {
1429
+ expandedLines.push(collapsed.originalLine);
1430
+ expandedLines.push(...collapsed.content);
1431
+ found = true;
1432
+ break;
1433
+ }
1434
+ }
1435
+ if (found) return;
1436
+ }
1437
+
1438
+ // Fallback: search by line number only
1378
1439
  let found = false;
1379
- this.collapsedData.forEach((collapsed, key) => {
1440
+ for (const [key, collapsed] of this.collapsedData.entries()) {
1380
1441
  const collapsedLineNum = parseInt(key.split('-')[0]);
1381
1442
  if (collapsedLineNum === absoluteLineNum) {
1382
- // Replace with original line and all collapsed content
1383
1443
  expandedLines.push(collapsed.originalLine);
1384
1444
  expandedLines.push(...collapsed.content);
1385
1445
  found = true;
1446
+ break;
1386
1447
  }
1387
- });
1448
+ }
1388
1449
  if (!found) {
1389
1450
  expandedLines.push(line);
1390
1451
  }
@@ -1428,7 +1489,10 @@ class GeoJsonEditor extends HTMLElement {
1428
1489
 
1429
1490
  // Try to parse
1430
1491
  try {
1431
- const parsed = JSON.parse(fullValue);
1492
+ let parsed = JSON.parse(fullValue);
1493
+
1494
+ // Filter out hidden features before emitting
1495
+ parsed = this.filterHiddenFeatures(parsed);
1432
1496
 
1433
1497
  // Validate GeoJSON types
1434
1498
  const validationErrors = this.validateGeoJSON(parsed);
@@ -1467,6 +1531,178 @@ class GeoJsonEditor extends HTMLElement {
1467
1531
  }
1468
1532
  }
1469
1533
 
1534
+ // Filter hidden features from parsed GeoJSON before emitting events
1535
+ filterHiddenFeatures(parsed) {
1536
+ if (!parsed || this.hiddenFeatures.size === 0) return parsed;
1537
+
1538
+ if (parsed.type === 'FeatureCollection' && Array.isArray(parsed.features)) {
1539
+ // Filter features array
1540
+ const visibleFeatures = parsed.features.filter(feature => {
1541
+ const key = this.getFeatureKey(feature);
1542
+ return !this.hiddenFeatures.has(key);
1543
+ });
1544
+ return { ...parsed, features: visibleFeatures };
1545
+ } else if (parsed.type === 'Feature') {
1546
+ // Single feature - check if hidden
1547
+ const key = this.getFeatureKey(parsed);
1548
+ if (this.hiddenFeatures.has(key)) {
1549
+ // Return empty FeatureCollection when single feature is hidden
1550
+ return { type: 'FeatureCollection', features: [] };
1551
+ }
1552
+ }
1553
+
1554
+ return parsed;
1555
+ }
1556
+
1557
+ // ========== Feature Visibility Management ==========
1558
+
1559
+ // Generate a unique key for a Feature to track visibility state
1560
+ getFeatureKey(feature) {
1561
+ if (!feature || typeof feature !== 'object') return null;
1562
+
1563
+ // 1. Use GeoJSON id if present (most stable)
1564
+ if (feature.id !== undefined) return `id:${feature.id}`;
1565
+
1566
+ // 2. Use properties.id if present
1567
+ if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
1568
+
1569
+ // 3. Fallback: hash based on geometry type + first coordinates
1570
+ const geomType = feature.geometry?.type || 'null';
1571
+ const coords = JSON.stringify(feature.geometry?.coordinates || []).slice(0, 100);
1572
+ return `hash:${geomType}:${this.simpleHash(coords)}`;
1573
+ }
1574
+
1575
+ // Simple hash function for string
1576
+ simpleHash(str) {
1577
+ let hash = 0;
1578
+ for (let i = 0; i < str.length; i++) {
1579
+ const char = str.charCodeAt(i);
1580
+ hash = ((hash << 5) - hash) + char;
1581
+ hash = hash & hash; // Convert to 32bit integer
1582
+ }
1583
+ return hash.toString(36);
1584
+ }
1585
+
1586
+ // Toggle feature visibility
1587
+ toggleFeatureVisibility(featureKey) {
1588
+ if (this.hiddenFeatures.has(featureKey)) {
1589
+ this.hiddenFeatures.delete(featureKey);
1590
+ } else {
1591
+ this.hiddenFeatures.add(featureKey);
1592
+ }
1593
+ this.updateHighlight();
1594
+ this.updateGutter();
1595
+ this.emitChange();
1596
+ }
1597
+
1598
+ // Parse JSON and extract feature ranges (line numbers for each Feature)
1599
+ updateFeatureRanges() {
1600
+ const textarea = this.shadowRoot.getElementById('textarea');
1601
+ if (!textarea) return;
1602
+
1603
+ const text = textarea.value;
1604
+ this.featureRanges.clear();
1605
+
1606
+ try {
1607
+ // Expand collapsed content for parsing (collapsed markers like [...] are not valid JSON)
1608
+ const expandedText = this.expandAllCollapsed(text);
1609
+
1610
+ // Try to parse and find Features
1611
+ const prefix = this.prefix;
1612
+ const suffix = this.suffix;
1613
+ const fullValue = prefix + expandedText + suffix;
1614
+ const parsed = JSON.parse(fullValue);
1615
+
1616
+ let features = [];
1617
+ if (parsed.type === 'FeatureCollection' && Array.isArray(parsed.features)) {
1618
+ features = parsed.features;
1619
+ } else if (parsed.type === 'Feature') {
1620
+ features = [parsed];
1621
+ }
1622
+
1623
+ // Now find each feature's line range in the text
1624
+ const lines = text.split('\n');
1625
+ let featureIndex = 0;
1626
+ let braceDepth = 0;
1627
+ let inFeature = false;
1628
+ let featureStartLine = -1;
1629
+ let currentFeatureKey = null;
1630
+
1631
+ for (let i = 0; i < lines.length; i++) {
1632
+ const line = lines[i];
1633
+
1634
+ // Detect start of a Feature object (not FeatureCollection)
1635
+ // Use regex to match exact "Feature" value, not "FeatureCollection"
1636
+ const isFeatureTypeLine = /"type"\s*:\s*"Feature"/.test(line);
1637
+ if (!inFeature && isFeatureTypeLine) {
1638
+ // Find the opening brace for this Feature
1639
+ // Look backwards for the opening brace
1640
+ let startLine = i;
1641
+ for (let j = i; j >= 0; j--) {
1642
+ if (lines[j].includes('{')) {
1643
+ startLine = j;
1644
+ break;
1645
+ }
1646
+ }
1647
+ featureStartLine = startLine;
1648
+ inFeature = true;
1649
+
1650
+ // Start braceDepth at 1 since we're inside the Feature's opening brace
1651
+ // Then count any additional braces from startLine to current line (ignoring strings)
1652
+ braceDepth = 1;
1653
+ for (let k = startLine; k <= i; k++) {
1654
+ const scanLine = lines[k];
1655
+ const counts = this._countBracketsOutsideStrings(scanLine, '{');
1656
+ if (k === startLine) {
1657
+ // Skip the first { we already counted
1658
+ braceDepth += (counts.open - 1) - counts.close;
1659
+ } else {
1660
+ braceDepth += counts.open - counts.close;
1661
+ }
1662
+ }
1663
+
1664
+ // Get the feature key
1665
+ if (featureIndex < features.length) {
1666
+ currentFeatureKey = this.getFeatureKey(features[featureIndex]);
1667
+ }
1668
+ } else if (inFeature) {
1669
+ // Count braces (ignoring those in strings)
1670
+ const counts = this._countBracketsOutsideStrings(line, '{');
1671
+ braceDepth += counts.open - counts.close;
1672
+
1673
+ // Feature ends when braceDepth returns to 0
1674
+ if (braceDepth <= 0) {
1675
+ if (currentFeatureKey) {
1676
+ this.featureRanges.set(currentFeatureKey, {
1677
+ startLine: featureStartLine,
1678
+ endLine: i,
1679
+ featureIndex: featureIndex
1680
+ });
1681
+ }
1682
+ featureIndex++;
1683
+ inFeature = false;
1684
+ currentFeatureKey = null;
1685
+ }
1686
+ }
1687
+ }
1688
+ } catch (e) {
1689
+ // Invalid JSON, can't extract feature ranges
1690
+ }
1691
+ }
1692
+
1693
+ // Get hidden line ranges for highlighting
1694
+ getHiddenLineRanges() {
1695
+ const ranges = [];
1696
+ for (const [featureKey, range] of this.featureRanges) {
1697
+ if (this.hiddenFeatures.has(featureKey)) {
1698
+ ranges.push(range);
1699
+ }
1700
+ }
1701
+ return ranges;
1702
+ }
1703
+
1704
+ // ========== GeoJSON Validation ==========
1705
+
1470
1706
  // Validate GeoJSON structure and types
1471
1707
  // context: 'root' | 'geometry' | 'properties'
1472
1708
  validateGeoJSON(obj, path = '', context = 'root') {
@@ -1520,19 +1756,131 @@ class GeoJsonEditor extends HTMLElement {
1520
1756
  return errors;
1521
1757
  }
1522
1758
 
1523
- // Helper: Check if bracket closes on same line
1524
- bracketClosesOnSameLine(line, openBracket) {
1759
+ // Helper: Count bracket depth change in a line, ignoring brackets inside strings
1760
+ // Returns {open: count, close: count} for the specified bracket type
1761
+ _countBracketsOutsideStrings(line, openBracket) {
1525
1762
  const closeBracket = openBracket === '{' ? '}' : ']';
1763
+ let openCount = 0;
1764
+ let closeCount = 0;
1765
+ let inString = false;
1766
+ let escape = false;
1767
+
1768
+ for (let i = 0; i < line.length; i++) {
1769
+ const char = line[i];
1770
+
1771
+ if (escape) {
1772
+ escape = false;
1773
+ continue;
1774
+ }
1775
+
1776
+ if (char === '\\' && inString) {
1777
+ escape = true;
1778
+ continue;
1779
+ }
1780
+
1781
+ if (char === '"') {
1782
+ inString = !inString;
1783
+ continue;
1784
+ }
1785
+
1786
+ if (!inString) {
1787
+ if (char === openBracket) openCount++;
1788
+ if (char === closeBracket) closeCount++;
1789
+ }
1790
+ }
1791
+
1792
+ return { open: openCount, close: closeCount };
1793
+ }
1794
+
1795
+ // Helper: Check if bracket closes on same line (ignores brackets in strings)
1796
+ bracketClosesOnSameLine(line, openBracket) {
1526
1797
  const bracketPos = line.indexOf(openBracket);
1527
1798
  if (bracketPos === -1) return false;
1799
+
1528
1800
  const restOfLine = line.substring(bracketPos + 1);
1801
+ const counts = this._countBracketsOutsideStrings(restOfLine, openBracket);
1802
+
1803
+ // Depth starts at 1 (we're after the opening bracket)
1804
+ // If closes equal or exceed opens + 1, the bracket closes on this line
1805
+ return counts.close > counts.open;
1806
+ }
1807
+
1808
+ // Helper: Find closing bracket line starting from startLine
1809
+ // Returns { endLine, content: string[] } or null if not found
1810
+ _findClosingBracket(lines, startLine, openBracket) {
1529
1811
  let depth = 1;
1530
- for (const char of restOfLine) {
1531
- if (char === openBracket) depth++;
1532
- if (char === closeBracket) depth--;
1533
- if (depth === 0) return true;
1812
+ const content = [];
1813
+
1814
+ // Count remaining brackets on the start line (after the opening bracket)
1815
+ const startLineContent = lines[startLine];
1816
+ const bracketPos = startLineContent.indexOf(openBracket);
1817
+ if (bracketPos !== -1) {
1818
+ const restOfStartLine = startLineContent.substring(bracketPos + 1);
1819
+ const startCounts = this._countBracketsOutsideStrings(restOfStartLine, openBracket);
1820
+ depth += startCounts.open - startCounts.close;
1821
+ if (depth === 0) {
1822
+ return { endLine: startLine, content: [] };
1823
+ }
1534
1824
  }
1535
- return false;
1825
+
1826
+ for (let i = startLine + 1; i < lines.length; i++) {
1827
+ const scanLine = lines[i];
1828
+ const counts = this._countBracketsOutsideStrings(scanLine, openBracket);
1829
+ depth += counts.open - counts.close;
1830
+
1831
+ content.push(scanLine);
1832
+
1833
+ if (depth === 0) {
1834
+ return { endLine: i, content };
1835
+ }
1836
+ }
1837
+
1838
+ return null; // Not found (malformed JSON)
1839
+ }
1840
+
1841
+ /**
1842
+ * Helper: Perform collapse operation on a node at given line
1843
+ * Stores data in collapsedData, replaces line with marker, removes content lines
1844
+ * @param {string[]} lines - Array of lines (modified in place)
1845
+ * @param {number} lineIndex - Index of line to collapse
1846
+ * @param {string} nodeKey - Key of the node (e.g., 'coordinates')
1847
+ * @param {string} indent - Indentation string
1848
+ * @param {string} openBracket - Opening bracket character ('{' or '[')
1849
+ * @returns {number} Number of lines removed, or 0 if collapse failed
1850
+ * @private
1851
+ */
1852
+ _performCollapse(lines, lineIndex, nodeKey, indent, openBracket) {
1853
+ const line = lines[lineIndex];
1854
+ const closeBracket = openBracket === '{' ? '}' : ']';
1855
+
1856
+ // Skip if bracket closes on same line
1857
+ if (this.bracketClosesOnSameLine(line, openBracket)) return 0;
1858
+
1859
+ // Find closing bracket
1860
+ const result = this._findClosingBracket(lines, lineIndex, openBracket);
1861
+ if (!result) return 0;
1862
+
1863
+ const { endLine, content } = result;
1864
+
1865
+ // Store the original data with unique key
1866
+ const uniqueKey = `${lineIndex}-${nodeKey}`;
1867
+ this.collapsedData.set(uniqueKey, {
1868
+ originalLine: line,
1869
+ content: content,
1870
+ indent: indent.length,
1871
+ nodeKey: nodeKey
1872
+ });
1873
+
1874
+ // Replace with marker
1875
+ const beforeBracket = line.substring(0, line.indexOf(openBracket));
1876
+ const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1877
+ lines[lineIndex] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1878
+
1879
+ // Remove content lines
1880
+ const linesRemoved = endLine - lineIndex;
1881
+ lines.splice(lineIndex + 1, linesRemoved);
1882
+
1883
+ return linesRemoved;
1536
1884
  }
1537
1885
 
1538
1886
  // Helper: Expand all collapsed markers and return expanded content
@@ -1649,34 +1997,6 @@ class GeoJsonEditor extends HTMLElement {
1649
1997
  }
1650
1998
  }
1651
1999
 
1652
- autoFormatContent() {
1653
- const textarea = this.shadowRoot.getElementById('textarea');
1654
-
1655
- // Save collapsed node details
1656
- const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
1657
- nodeKey: data.nodeKey,
1658
- indent: data.indent
1659
- }));
1660
-
1661
- // Expand and format
1662
- const content = this.expandAllCollapsed(textarea.value);
1663
-
1664
- try {
1665
- const formattedContent = this.formatJSONContent(content);
1666
-
1667
- if (formattedContent !== content) {
1668
- this.collapsedData.clear();
1669
- textarea.value = formattedContent;
1670
-
1671
- if (collapsedNodes.length > 0) {
1672
- this.reapplyCollapsed(collapsedNodes);
1673
- }
1674
- }
1675
- } catch (e) {
1676
- // Invalid JSON, don't format
1677
- }
1678
- }
1679
-
1680
2000
  reapplyCollapsed(collapsedNodes) {
1681
2001
  const textarea = this.shadowRoot.getElementById('textarea');
1682
2002
  const lines = textarea.value.split('\n');
@@ -1708,50 +2028,11 @@ class GeoJsonEditor extends HTMLElement {
1708
2028
 
1709
2029
  // Only collapse if this occurrence should be collapsed
1710
2030
  if (currentOccurrence <= collapseMap.get(key)) {
1711
- const indent = match[1];
1712
- const openBracket = match[3];
1713
- const closeBracket = openBracket === '{' ? '}' : ']';
1714
-
1715
- // Skip if closes on same line
1716
- if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1717
-
1718
- // Find closing bracket
1719
- let depth = 1;
1720
- let endLine = i;
1721
- const content = [];
1722
-
1723
- for (let j = i + 1; j < lines.length; j++) {
1724
- const scanLine = lines[j];
2031
+ const indent = match[1];
2032
+ const openBracket = match[3];
1725
2033
 
1726
- for (const char of scanLine) {
1727
- if (char === openBracket) depth++;
1728
- if (char === closeBracket) depth--;
1729
- }
1730
-
1731
- content.push(scanLine);
1732
-
1733
- if (depth === 0) {
1734
- endLine = j;
1735
- break;
1736
- }
1737
- }
1738
-
1739
- // Store with unique key
1740
- const uniqueKey = `${i}-${nodeKey}`;
1741
- this.collapsedData.set(uniqueKey, {
1742
- originalLine: line,
1743
- content: content,
1744
- indent: indent.length,
1745
- nodeKey: nodeKey
1746
- });
1747
-
1748
- // Replace with marker
1749
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
1750
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1751
- lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1752
-
1753
- // Remove content lines
1754
- lines.splice(i + 1, endLine - i);
2034
+ // Use common collapse helper
2035
+ this._performCollapse(lines, i, nodeKey, indent, openBracket);
1755
2036
  }
1756
2037
  }
1757
2038
  }
@@ -1780,77 +2061,41 @@ class GeoJsonEditor extends HTMLElement {
1780
2061
  // Generate and inject theme CSS based on dark selector
1781
2062
  updateThemeCSS() {
1782
2063
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
1783
-
1784
- // Parse selector to create CSS rule for dark theme
1785
2064
  const darkRule = this.parseSelectorToHostRule(darkSelector);
1786
- // Light theme is the default (no selector = light)
1787
- const lightRule = ':host';
1788
2065
 
1789
2066
  // Find or create theme style element
1790
2067
  let themeStyle = this.shadowRoot.getElementById('theme-styles');
1791
2068
  if (!themeStyle) {
1792
2069
  themeStyle = document.createElement('style');
1793
2070
  themeStyle.id = 'theme-styles';
1794
- // Insert at the beginning of shadow root to ensure it's before static styles
1795
2071
  this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
1796
2072
  }
1797
2073
 
1798
- // Generate CSS with theme variables (light first as default, then dark overrides)
1799
- const css = `
1800
- ${lightRule} {
1801
- --bg-color: ${this.themes.light.background};
1802
- --text-color: ${this.themes.light.textColor};
1803
- --caret-color: ${this.themes.light.caretColor};
1804
- --gutter-bg: ${this.themes.light.gutterBackground};
1805
- --gutter-border: ${this.themes.light.gutterBorder};
1806
- --json-key: ${this.themes.light.jsonKey};
1807
- --json-string: ${this.themes.light.jsonString};
1808
- --json-number: ${this.themes.light.jsonNumber};
1809
- --json-boolean: ${this.themes.light.jsonBoolean};
1810
- --json-null: ${this.themes.light.jsonNull};
1811
- --json-punct: ${this.themes.light.jsonPunctuation};
1812
- --control-color: ${this.themes.light.controlColor};
1813
- --control-bg: ${this.themes.light.controlBg};
1814
- --control-border: ${this.themes.light.controlBorder};
1815
- --geojson-key: ${this.themes.light.geojsonKey};
1816
- --geojson-type: ${this.themes.light.geojsonType};
1817
- --geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};
1818
- --json-key-invalid: ${this.themes.light.jsonKeyInvalid};
1819
- }
2074
+ // Helper to generate CSS variables from theme object
2075
+ const generateVars = (themeObj) => {
2076
+ return Object.entries(themeObj || {})
2077
+ .map(([key, value]) => `--${GeoJsonEditor._toKebabCase(key)}: ${value};`)
2078
+ .join('\n ');
2079
+ };
1820
2080
 
1821
- ${darkRule} {
1822
- --bg-color: ${this.themes.dark.background};
1823
- --text-color: ${this.themes.dark.textColor};
1824
- --caret-color: ${this.themes.dark.caretColor};
1825
- --gutter-bg: ${this.themes.dark.gutterBackground};
1826
- --gutter-border: ${this.themes.dark.gutterBorder};
1827
- --json-key: ${this.themes.dark.jsonKey};
1828
- --json-string: ${this.themes.dark.jsonString};
1829
- --json-number: ${this.themes.dark.jsonNumber};
1830
- --json-boolean: ${this.themes.dark.jsonBoolean};
1831
- --json-null: ${this.themes.dark.jsonNull};
1832
- --json-punct: ${this.themes.dark.jsonPunctuation};
1833
- --control-color: ${this.themes.dark.controlColor};
1834
- --control-bg: ${this.themes.dark.controlBg};
1835
- --control-border: ${this.themes.dark.controlBorder};
1836
- --geojson-key: ${this.themes.dark.geojsonKey};
1837
- --geojson-type: ${this.themes.dark.geojsonType};
1838
- --geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};
1839
- --json-key-invalid: ${this.themes.dark.jsonKeyInvalid};
1840
- }
1841
- `;
2081
+ // Light theme: only overrides (defaults are in static CSS)
2082
+ const lightVars = generateVars(this.themes.light);
2083
+
2084
+ // Dark theme: ALWAYS generate with defaults + overrides (selector is dynamic)
2085
+ const darkTheme = { ...GeoJsonEditor.DARK_THEME_DEFAULTS, ...this.themes.dark };
2086
+ const darkVars = generateVars(darkTheme);
2087
+
2088
+ let css = '';
2089
+ if (lightVars) {
2090
+ css += `:host {\n ${lightVars}\n }\n`;
2091
+ }
2092
+ // Dark theme is always generated (selector is configurable)
2093
+ css += `${darkRule} {\n ${darkVars}\n }`;
1842
2094
 
1843
2095
  themeStyle.textContent = css;
1844
2096
  }
1845
2097
 
1846
2098
  // Public API: Theme management
1847
- getTheme() {
1848
- return {
1849
- dark: { ...this.themes.dark },
1850
- light: { ...this.themes.light }
1851
- };
1852
- }
1853
-
1854
2099
  setTheme(theme) {
1855
2100
  if (theme.dark) {
1856
2101
  this.themes.dark = { ...this.themes.dark, ...theme.dark };
@@ -1858,19 +2103,278 @@ class GeoJsonEditor extends HTMLElement {
1858
2103
  if (theme.light) {
1859
2104
  this.themes.light = { ...this.themes.light, ...theme.light };
1860
2105
  }
1861
-
1862
- // Regenerate CSS with new theme values
1863
2106
  this.updateThemeCSS();
1864
2107
  }
1865
2108
 
1866
2109
  resetTheme() {
1867
- // Reset to defaults
1868
- this.themes = {
1869
- dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
1870
- light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
1871
- };
2110
+ this.themes = { dark: {}, light: {} };
1872
2111
  this.updateThemeCSS();
1873
2112
  }
2113
+
2114
+ // ========================================
2115
+ // Features API - Programmatic manipulation
2116
+ // ========================================
2117
+
2118
+ /**
2119
+ * Normalize a Python-style index (supports negative values)
2120
+ * @param {number} index - Index to normalize (negative = from end)
2121
+ * @param {number} length - Length of the array
2122
+ * @param {boolean} clamp - If true, clamp to valid range; if false, return -1 for out of bounds
2123
+ * @returns {number} Normalized index, or -1 if out of bounds (when clamp=false)
2124
+ * @private
2125
+ */
2126
+ _normalizeIndex(index, length, clamp = false) {
2127
+ let idx = index;
2128
+ if (idx < 0) {
2129
+ idx = length + idx;
2130
+ }
2131
+ if (clamp) {
2132
+ return Math.max(0, Math.min(idx, length));
2133
+ }
2134
+ return (idx < 0 || idx >= length) ? -1 : idx;
2135
+ }
2136
+
2137
+ /**
2138
+ * Parse current textarea content into an array of features
2139
+ * @returns {Array} Array of feature objects
2140
+ * @private
2141
+ */
2142
+ _parseFeatures() {
2143
+ const textarea = this.shadowRoot.getElementById('textarea');
2144
+ if (!textarea || !textarea.value.trim()) {
2145
+ return [];
2146
+ }
2147
+
2148
+ try {
2149
+ // Expand collapsed nodes to get full content
2150
+ const content = this.expandAllCollapsed(textarea.value);
2151
+ // Wrap in array brackets and parse
2152
+ const wrapped = '[' + content + ']';
2153
+ return JSON.parse(wrapped);
2154
+ } catch (e) {
2155
+ return [];
2156
+ }
2157
+ }
2158
+
2159
+ /**
2160
+ * Update textarea with features array and trigger all updates
2161
+ * @param {Array} features - Array of feature objects
2162
+ * @private
2163
+ */
2164
+ _setFeatures(features) {
2165
+ const textarea = this.shadowRoot.getElementById('textarea');
2166
+ if (!textarea) return;
2167
+
2168
+ // Clear internal state when replacing features (prevent memory leaks)
2169
+ this.collapsedData.clear();
2170
+ this.hiddenFeatures.clear();
2171
+
2172
+ if (!features || features.length === 0) {
2173
+ textarea.value = '';
2174
+ } else {
2175
+ // Format each feature and join with comma
2176
+ const formatted = features
2177
+ .map(f => JSON.stringify(f, null, 2))
2178
+ .join(',\n');
2179
+
2180
+ textarea.value = formatted;
2181
+ }
2182
+
2183
+ // Trigger all updates
2184
+ this.updateHighlight();
2185
+ this.updatePlaceholderVisibility();
2186
+
2187
+ // Auto-collapse coordinates
2188
+ if (textarea.value) {
2189
+ requestAnimationFrame(() => {
2190
+ this.applyAutoCollapsed();
2191
+ });
2192
+ }
2193
+
2194
+ // Emit change event
2195
+ this.emitChange();
2196
+ }
2197
+
2198
+ /**
2199
+ * Validate a single feature object
2200
+ * @param {Object} feature - Feature object to validate
2201
+ * @returns {string[]} Array of validation error messages (empty if valid)
2202
+ * @private
2203
+ */
2204
+ _validateFeature(feature) {
2205
+ const errors = [];
2206
+
2207
+ if (!feature || typeof feature !== 'object') {
2208
+ errors.push('Feature must be an object');
2209
+ return errors;
2210
+ }
2211
+
2212
+ if (Array.isArray(feature)) {
2213
+ errors.push('Feature cannot be an array');
2214
+ return errors;
2215
+ }
2216
+
2217
+ // Check required type field
2218
+ if (!('type' in feature)) {
2219
+ errors.push('Feature must have a "type" property');
2220
+ } else if (feature.type !== 'Feature') {
2221
+ errors.push(`Feature type must be "Feature", got "${feature.type}"`);
2222
+ }
2223
+
2224
+ // Check geometry field exists (can be null for features without location)
2225
+ if (!('geometry' in feature)) {
2226
+ errors.push('Feature must have a "geometry" property (can be null)');
2227
+ } else if (feature.geometry !== null) {
2228
+ // Validate geometry if not null
2229
+ if (typeof feature.geometry !== 'object' || Array.isArray(feature.geometry)) {
2230
+ errors.push('Feature geometry must be an object or null');
2231
+ } else {
2232
+ // Check geometry has valid type
2233
+ if (!('type' in feature.geometry)) {
2234
+ errors.push('Geometry must have a "type" property');
2235
+ } else if (!GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(feature.geometry.type)) {
2236
+ errors.push(`Invalid geometry type "${feature.geometry.type}" (expected: ${GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.join(', ')})`);
2237
+ }
2238
+
2239
+ // Check geometry has coordinates (except GeometryCollection)
2240
+ if (feature.geometry.type !== 'GeometryCollection' && !('coordinates' in feature.geometry)) {
2241
+ errors.push('Geometry must have a "coordinates" property');
2242
+ }
2243
+
2244
+ // GeometryCollection must have geometries array
2245
+ if (feature.geometry.type === 'GeometryCollection' && !Array.isArray(feature.geometry.geometries)) {
2246
+ errors.push('GeometryCollection must have a "geometries" array');
2247
+ }
2248
+ }
2249
+ }
2250
+
2251
+ // Check properties field exists (can be null)
2252
+ if (!('properties' in feature)) {
2253
+ errors.push('Feature must have a "properties" property (can be null)');
2254
+ } else if (feature.properties !== null && (typeof feature.properties !== 'object' || Array.isArray(feature.properties))) {
2255
+ errors.push('Feature properties must be an object or null');
2256
+ }
2257
+
2258
+ return errors;
2259
+ }
2260
+
2261
+ /**
2262
+ * Replace all features with the given array
2263
+ * @param {Array} features - Array of feature objects to set
2264
+ * @throws {Error} If features is not an array or contains invalid features
2265
+ */
2266
+ set(features) {
2267
+ if (!Array.isArray(features)) {
2268
+ throw new Error('set() expects an array of features');
2269
+ }
2270
+
2271
+ // Validate each feature
2272
+ const allErrors = [];
2273
+ features.forEach((feature, index) => {
2274
+ const errors = this._validateFeature(feature);
2275
+ if (errors.length > 0) {
2276
+ allErrors.push(`Feature[${index}]: ${errors.join(', ')}`);
2277
+ }
2278
+ });
2279
+
2280
+ if (allErrors.length > 0) {
2281
+ throw new Error(`Invalid features: ${allErrors.join('; ')}`);
2282
+ }
2283
+
2284
+ this._setFeatures(features);
2285
+ }
2286
+
2287
+ /**
2288
+ * Add a feature at the end of the list
2289
+ * @param {Object} feature - Feature object to add
2290
+ * @throws {Error} If feature is invalid
2291
+ */
2292
+ add(feature) {
2293
+ const errors = this._validateFeature(feature);
2294
+ if (errors.length > 0) {
2295
+ throw new Error(`Invalid feature: ${errors.join(', ')}`);
2296
+ }
2297
+
2298
+ const features = this._parseFeatures();
2299
+ features.push(feature);
2300
+ this._setFeatures(features);
2301
+ }
2302
+
2303
+ /**
2304
+ * Insert a feature at the specified index
2305
+ * @param {Object} feature - Feature object to insert
2306
+ * @param {number} index - Index to insert at (negative = from end)
2307
+ * @throws {Error} If feature is invalid
2308
+ */
2309
+ insertAt(feature, index) {
2310
+ const errors = this._validateFeature(feature);
2311
+ if (errors.length > 0) {
2312
+ throw new Error(`Invalid feature: ${errors.join(', ')}`);
2313
+ }
2314
+
2315
+ const features = this._parseFeatures();
2316
+ const idx = this._normalizeIndex(index, features.length, true);
2317
+
2318
+ features.splice(idx, 0, feature);
2319
+ this._setFeatures(features);
2320
+ }
2321
+
2322
+ /**
2323
+ * Remove the feature at the specified index
2324
+ * @param {number} index - Index to remove (negative = from end)
2325
+ * @returns {Object|undefined} The removed feature, or undefined if index out of bounds
2326
+ */
2327
+ removeAt(index) {
2328
+ const features = this._parseFeatures();
2329
+ if (features.length === 0) return undefined;
2330
+
2331
+ const idx = this._normalizeIndex(index, features.length);
2332
+ if (idx === -1) return undefined;
2333
+
2334
+ const removed = features.splice(idx, 1)[0];
2335
+ this._setFeatures(features);
2336
+ return removed;
2337
+ }
2338
+
2339
+ /**
2340
+ * Remove all features
2341
+ * @returns {Array} Array of removed features
2342
+ */
2343
+ removeAll() {
2344
+ const removed = this._parseFeatures();
2345
+ this._setFeatures([]);
2346
+ return removed;
2347
+ }
2348
+
2349
+ /**
2350
+ * Get the feature at the specified index
2351
+ * @param {number} index - Index to get (negative = from end)
2352
+ * @returns {Object|undefined} The feature, or undefined if index out of bounds
2353
+ */
2354
+ get(index) {
2355
+ const features = this._parseFeatures();
2356
+ if (features.length === 0) return undefined;
2357
+
2358
+ const idx = this._normalizeIndex(index, features.length);
2359
+ if (idx === -1) return undefined;
2360
+
2361
+ return features[idx];
2362
+ }
2363
+
2364
+ /**
2365
+ * Get all features as an array
2366
+ * @returns {Array} Array of all feature objects
2367
+ */
2368
+ getAll() {
2369
+ return this._parseFeatures();
2370
+ }
2371
+
2372
+ /**
2373
+ * Emit the current document on the change event
2374
+ */
2375
+ emit() {
2376
+ this.emitChange();
2377
+ }
1874
2378
  }
1875
2379
 
1876
2380
  // Register the custom element