@softwarity/geojson-editor 1.0.5 → 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.
@@ -17,68 +17,40 @@ class GeoJsonEditor extends HTMLElement {
17
17
  this._cachedLineHeight = null;
18
18
  this._cachedPaddingTop = null;
19
19
 
20
- // Initialize themes from defaults
21
- this.themes = {
22
- dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
23
- light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
24
- };
20
+ // Custom theme overrides (empty by default, CSS has defaults)
21
+ this.themes = { dark: {}, light: {} };
25
22
  }
26
23
 
27
24
  static get observedAttributes() {
28
- return ['readonly', 'value', 'placeholder', 'dark-selector', 'feature-collection'];
29
- }
30
-
31
-
32
- // Default theme values
33
- static DEFAULT_THEMES = {
34
- dark: {
35
- background: '#1e1e1e',
36
- textColor: '#d4d4d4',
37
- caretColor: '#fff',
38
- gutterBackground: '#252526',
39
- gutterBorder: '#3e3e42',
40
- jsonKey: '#9cdcfe',
41
- jsonString: '#ce9178',
42
- jsonNumber: '#b5cea8',
43
- jsonBoolean: '#569cd6',
44
- jsonNull: '#569cd6',
45
- jsonPunctuation: '#d4d4d4',
46
- controlColor: '#c586c0',
47
- controlBg: '#3e3e42',
48
- controlBorder: '#555',
49
- geojsonKey: '#c586c0',
50
- geojsonType: '#4ec9b0',
51
- geojsonTypeInvalid: '#f44747',
52
- jsonKeyInvalid: '#f44747'
53
- },
54
- light: {
55
- background: '#ffffff',
56
- textColor: '#333333',
57
- caretColor: '#000',
58
- gutterBackground: '#f5f5f5',
59
- gutterBorder: '#ddd',
60
- jsonKey: '#0000ff',
61
- jsonString: '#a31515',
62
- jsonNumber: '#098658',
63
- jsonBoolean: '#0000ff',
64
- jsonNull: '#0000ff',
65
- jsonPunctuation: '#333333',
66
- controlColor: '#a31515',
67
- controlBg: '#e0e0e0',
68
- controlBorder: '#999',
69
- geojsonKey: '#af00db',
70
- geojsonType: '#267f99',
71
- geojsonTypeInvalid: '#d32f2f',
72
- jsonKeyInvalid: '#d32f2f'
73
- }
74
- };
25
+ return ['readonly', 'value', 'placeholder', 'dark-selector'];
26
+ }
75
27
 
76
- // FeatureCollection wrapper constants
77
- static FEATURE_COLLECTION_PREFIX = '{"type": "FeatureCollection", "features": [';
78
- 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
+ }
79
32
 
80
- // SVG icon for visibility toggle (single icon, style changes based on state)
81
- static ICON_EYE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z"/></svg>';
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
+ };
82
54
 
83
55
  // Pre-compiled regex patterns (avoid recompilation on each call)
84
56
  static REGEX = {
@@ -118,6 +90,21 @@ class GeoJsonEditor extends HTMLElement {
118
90
  this.updatePlaceholderContent();
119
91
  }
120
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
+
121
108
  attributeChangedCallback(name, oldValue, newValue) {
122
109
  if (oldValue === newValue) return;
123
110
 
@@ -129,8 +116,6 @@ class GeoJsonEditor extends HTMLElement {
129
116
  this.updatePlaceholderContent();
130
117
  } else if (name === 'dark-selector') {
131
118
  this.updateThemeCSS();
132
- } else if (name === 'feature-collection') {
133
- this.updatePrefixSuffix();
134
119
  }
135
120
  }
136
121
 
@@ -148,17 +133,13 @@ class GeoJsonEditor extends HTMLElement {
148
133
  return this.getAttribute('placeholder') || '';
149
134
  }
150
135
 
151
- get featureCollection() {
152
- return this.hasAttribute('feature-collection');
153
- }
154
-
155
- // Internal getters for prefix/suffix based on feature-collection mode
136
+ // Always in FeatureCollection mode - prefix/suffix are constant
156
137
  get prefix() {
157
- return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_PREFIX : '';
138
+ return '{"type": "FeatureCollection", "features": [';
158
139
  }
159
-
140
+
160
141
  get suffix() {
161
- return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_SUFFIX : '';
142
+ return ']}';
162
143
  }
163
144
 
164
145
  render() {
@@ -188,9 +169,6 @@ class GeoJsonEditor extends HTMLElement {
188
169
  position: relative;
189
170
  width: 100%;
190
171
  height: 400px;
191
- font-family: 'Courier New', Courier, monospace;
192
- font-size: 13px;
193
- line-height: 1.5;
194
172
  border-radius: 4px;
195
173
  overflow: hidden;
196
174
  }
@@ -221,18 +199,15 @@ class GeoJsonEditor extends HTMLElement {
221
199
  position: relative;
222
200
  width: 100%;
223
201
  flex: 1;
224
- background: var(--bg-color);
202
+ background: var(--bg-color, #ffffff);
225
203
  display: flex;
226
- font-family: 'Courier New', Courier, monospace;
227
- font-size: 13px;
228
- line-height: 1.5;
229
204
  }
230
205
 
231
206
  .gutter {
232
207
  width: 24px;
233
208
  height: 100%;
234
- background: var(--gutter-bg);
235
- border-right: 1px solid var(--gutter-border);
209
+ background: var(--gutter-bg, #f0f0f0);
210
+ border-right: 1px solid var(--gutter-border, #e0e0e0);
236
211
  overflow: hidden;
237
212
  flex-shrink: 0;
238
213
  position: relative;
@@ -274,10 +249,10 @@ class GeoJsonEditor extends HTMLElement {
274
249
  .collapse-button {
275
250
  width: 12px;
276
251
  height: 12px;
277
- background: var(--control-bg);
278
- border: 1px solid var(--control-border);
252
+ background: var(--control-bg, #e8e8e8);
253
+ border: 1px solid var(--control-border, #c0c0c0);
279
254
  border-radius: 2px;
280
- color: var(--control-color);
255
+ color: var(--control-color, #000080);
281
256
  font-size: 8px;
282
257
  font-weight: bold;
283
258
  cursor: pointer;
@@ -290,8 +265,8 @@ class GeoJsonEditor extends HTMLElement {
290
265
  }
291
266
 
292
267
  .collapse-button:hover {
293
- background: var(--control-bg);
294
- border-color: var(--control-color);
268
+ background: var(--control-bg, #e8e8e8);
269
+ border-color: var(--control-color, #000080);
295
270
  transform: scale(1.1);
296
271
  }
297
272
 
@@ -300,7 +275,6 @@ class GeoJsonEditor extends HTMLElement {
300
275
  height: 14px;
301
276
  background: transparent;
302
277
  border: none;
303
- color: var(--control-color);
304
278
  cursor: pointer;
305
279
  display: flex;
306
280
  align-items: center;
@@ -309,21 +283,16 @@ class GeoJsonEditor extends HTMLElement {
309
283
  flex-shrink: 0;
310
284
  opacity: 0.7;
311
285
  padding: 0;
286
+ font-size: 11px;
312
287
  }
313
288
 
314
289
  .visibility-button:hover {
315
290
  opacity: 1;
316
- transform: scale(1.1);
291
+ transform: scale(1.15);
317
292
  }
318
293
 
319
294
  .visibility-button.hidden {
320
- opacity: 0.4;
321
- }
322
-
323
- .visibility-button svg {
324
- width: 12px;
325
- height: 12px;
326
- fill: currentColor;
295
+ opacity: 0.35;
327
296
  }
328
297
 
329
298
  /* Hidden feature lines - grayed out */
@@ -362,17 +331,12 @@ class GeoJsonEditor extends HTMLElement {
362
331
  width: 100%;
363
332
  height: 100%;
364
333
  padding: 8px 12px;
365
- font-family: 'Courier New', Courier, monospace;
366
- font-size: 13px;
367
- font-weight: normal;
368
- font-style: normal;
369
- line-height: 1.5;
370
334
  white-space: pre-wrap;
371
335
  word-wrap: break-word;
372
336
  overflow: auto;
373
337
  pointer-events: none;
374
338
  z-index: 1;
375
- color: var(--text-color);
339
+ color: var(--text-color, #000000);
376
340
  }
377
341
 
378
342
  .highlight-layer::-webkit-scrollbar {
@@ -391,18 +355,12 @@ class GeoJsonEditor extends HTMLElement {
391
355
  outline: none;
392
356
  background: transparent;
393
357
  color: transparent;
394
- caret-color: var(--caret-color);
395
- font-family: 'Courier New', Courier, monospace;
396
- font-size: 13px;
397
- font-weight: normal;
398
- font-style: normal;
399
- line-height: 1.5;
358
+ caret-color: var(--caret-color, #000);
400
359
  white-space: pre-wrap;
401
360
  word-wrap: break-word;
402
361
  resize: none;
403
362
  overflow: auto;
404
363
  z-index: 2;
405
- box-sizing: border-box;
406
364
  }
407
365
 
408
366
  textarea::selection {
@@ -420,11 +378,6 @@ class GeoJsonEditor extends HTMLElement {
420
378
  width: 100%;
421
379
  height: 100%;
422
380
  padding: 8px 12px;
423
- font-family: 'Courier New', Courier, monospace;
424
- font-size: 13px;
425
- font-weight: normal;
426
- font-style: normal;
427
- line-height: 1.5;
428
381
  white-space: pre-wrap;
429
382
  word-wrap: break-word;
430
383
  color: #6a6a6a;
@@ -438,74 +391,116 @@ class GeoJsonEditor extends HTMLElement {
438
391
  opacity: 0.6;
439
392
  }
440
393
 
441
- /* Syntax highlighting colors */
394
+ /* Syntax highlighting colors - IntelliJ Light defaults */
442
395
  .json-key {
443
- color: var(--json-key);
396
+ color: var(--json-key, #660e7a);
444
397
  }
445
398
 
446
399
  .json-string {
447
- color: var(--json-string);
400
+ color: var(--json-string, #008000);
448
401
  }
449
402
 
450
403
  .json-number {
451
- color: var(--json-number);
404
+ color: var(--json-number, #0000ff);
452
405
  }
453
406
 
454
407
  .json-boolean {
455
- color: var(--json-boolean);
408
+ color: var(--json-boolean, #000080);
456
409
  }
457
410
 
458
411
  .json-null {
459
- color: var(--json-null);
412
+ color: var(--json-null, #000080);
460
413
  }
461
414
 
462
415
  .json-punctuation {
463
- color: var(--json-punct);
416
+ color: var(--json-punct, #000000);
464
417
  }
465
418
 
466
419
  /* GeoJSON-specific highlighting */
467
420
  .geojson-key {
468
- color: var(--geojson-key);
421
+ color: var(--geojson-key, #660e7a);
469
422
  font-weight: 600;
470
423
  }
471
424
 
472
425
  .geojson-type {
473
- color: var(--geojson-type);
426
+ color: var(--geojson-type, #008000);
474
427
  font-weight: 600;
475
428
  }
476
429
 
477
430
  .geojson-type-invalid {
478
- color: var(--geojson-type-invalid);
431
+ color: var(--geojson-type-invalid, #ff0000);
479
432
  font-weight: 600;
480
433
  }
481
434
 
482
435
  .json-key-invalid {
483
- 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;
484
453
  }
485
454
 
486
- /* Prefix and suffix styling */
487
455
  .editor-prefix,
488
456
  .editor-suffix {
457
+ flex: 1;
489
458
  padding: 4px 12px;
490
- color: var(--text-color);
491
- background: var(--bg-color);
459
+ color: var(--text-color, #000000);
460
+ background: var(--bg-color, #ffffff);
492
461
  user-select: none;
493
462
  white-space: pre-wrap;
494
463
  word-wrap: break-word;
495
- flex-shrink: 0;
496
- font-family: 'Courier New', Courier, monospace;
497
- font-size: 13px;
498
- line-height: 1.5;
499
464
  opacity: 0.6;
500
- border-left: 3px solid rgba(102, 126, 234, 0.5);
501
465
  }
502
466
 
503
- .editor-prefix {
467
+ .prefix-wrapper {
504
468
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
505
469
  }
506
470
 
507
- .editor-suffix {
471
+ .suffix-wrapper {
508
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;
509
504
  }
510
505
 
511
506
  /* Scrollbar styling - WebKit (Chrome, Safari, Edge) */
@@ -515,28 +510,31 @@ class GeoJsonEditor extends HTMLElement {
515
510
  }
516
511
 
517
512
  textarea::-webkit-scrollbar-track {
518
- background: var(--control-bg);
513
+ background: var(--control-bg, #e8e8e8);
519
514
  }
520
515
 
521
516
  textarea::-webkit-scrollbar-thumb {
522
- background: var(--control-border);
517
+ background: var(--control-border, #c0c0c0);
523
518
  border-radius: 5px;
524
519
  }
525
520
 
526
521
  textarea::-webkit-scrollbar-thumb:hover {
527
- background: var(--control-color);
522
+ background: var(--control-color, #000080);
528
523
  }
529
524
 
530
525
  /* Scrollbar styling - Firefox */
531
526
  textarea {
532
527
  scrollbar-width: thin;
533
- scrollbar-color: var(--control-border) var(--control-bg);
528
+ scrollbar-color: var(--control-border, #c0c0c0) var(--control-bg, #e8e8e8);
534
529
  }
535
530
  </style>
536
531
  `;
537
532
 
538
533
  const template = `
539
- <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>
540
538
  <div class="editor-wrapper">
541
539
  <div class="gutter">
542
540
  <div class="gutter-content" id="gutterContent"></div>
@@ -553,7 +551,11 @@ class GeoJsonEditor extends HTMLElement {
553
551
  ></textarea>
554
552
  </div>
555
553
  </div>
556
- <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>
557
559
  `;
558
560
 
559
561
  this.shadowRoot.innerHTML = styles + template;
@@ -646,6 +648,12 @@ class GeoJsonEditor extends HTMLElement {
646
648
  this.handleCutWithCollapsedContent(e);
647
649
  });
648
650
 
651
+ // Clear button
652
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
653
+ clearBtn.addEventListener('click', () => {
654
+ this.removeAll();
655
+ });
656
+
649
657
  // Update readonly state
650
658
  this.updateReadonly();
651
659
  }
@@ -660,14 +668,20 @@ class GeoJsonEditor extends HTMLElement {
660
668
  if (textarea) {
661
669
  textarea.disabled = this.readonly;
662
670
  }
671
+ // Hide clear button in readonly mode
672
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
673
+ if (clearBtn) {
674
+ clearBtn.hidden = this.readonly;
675
+ }
663
676
  }
664
677
 
665
678
  escapeHtml(text) {
666
679
  if (!text) return '';
680
+ const R = GeoJsonEditor.REGEX;
667
681
  return text
668
- .replace(/&/g, '&amp;')
669
- .replace(/</g, '&lt;')
670
- .replace(/>/g, '&gt;');
682
+ .replace(R.ampersand, '&amp;')
683
+ .replace(R.lessThan, '&lt;')
684
+ .replace(R.greaterThan, '&gt;');
671
685
  }
672
686
 
673
687
  updatePlaceholderVisibility() {
@@ -744,24 +758,13 @@ class GeoJsonEditor extends HTMLElement {
744
758
  const prefixEl = this.shadowRoot.getElementById('editorPrefix');
745
759
  const suffixEl = this.shadowRoot.getElementById('editorSuffix');
746
760
 
761
+ // Always show prefix/suffix (always in FeatureCollection mode)
747
762
  if (prefixEl) {
748
- if (this.prefix) {
749
- prefixEl.textContent = this.prefix;
750
- prefixEl.style.display = 'block';
751
- } else {
752
- prefixEl.textContent = '';
753
- prefixEl.style.display = 'none';
754
- }
763
+ prefixEl.textContent = this.prefix;
755
764
  }
756
765
 
757
766
  if (suffixEl) {
758
- if (this.suffix) {
759
- suffixEl.textContent = this.suffix;
760
- suffixEl.style.display = 'block';
761
- } else {
762
- suffixEl.textContent = '';
763
- suffixEl.style.display = 'none';
764
- }
767
+ suffixEl.textContent = this.suffix;
765
768
  }
766
769
  }
767
770
 
@@ -902,8 +905,8 @@ class GeoJsonEditor extends HTMLElement {
902
905
  const contextStack = []; // Stack of {context, isArray}
903
906
  let pendingContext = null; // Context for next object/array
904
907
 
905
- // Determine root context based on feature-collection mode
906
- const rootContext = this.featureCollection ? 'Feature' : null;
908
+ // Root context is always 'Feature' (always in FeatureCollection mode)
909
+ const rootContext = 'Feature';
907
910
 
908
911
  for (let i = 0; i < lines.length; i++) {
909
912
  const line = lines[i];
@@ -915,37 +918,61 @@ class GeoJsonEditor extends HTMLElement {
915
918
  contextMap.set(i, lineContext);
916
919
 
917
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
+
918
925
  for (let j = 0; j < line.length; j++) {
919
926
  const char = line[j];
920
927
 
921
- // 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
922
939
  if (char === '"') {
923
- const keyMatch = line.substring(j).match(/^"([^"]+)"\s*:/);
924
- if (keyMatch) {
925
- const keyName = keyMatch[1];
926
- if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
927
- 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;
928
950
  }
929
- j += keyMatch[0].length - 1; // Skip past the key
930
- continue;
931
- }
932
- }
933
951
 
934
- // Check for type value to refine context: "type": "Point"
935
- if (char === '"' && contextStack.length > 0) {
936
- const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
937
- if (typeMatch) {
938
- const valueMatch = line.substring(j).match(/^"([^"]+)"/);
939
- if (valueMatch && GeoJsonEditor.GEOJSON_TYPES_ALL.includes(valueMatch[1])) {
940
- // Update current context to the specific type
941
- const currentCtx = contextStack[contextStack.length - 1];
942
- if (currentCtx) {
943
- 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;
944
966
  }
945
967
  }
946
968
  }
969
+ inString = !inString;
970
+ continue;
947
971
  }
948
972
 
973
+ // Skip everything inside strings (brackets, etc.)
974
+ if (inString) continue;
975
+
949
976
  // Opening bracket - push context
950
977
  if (char === '{' || char === '[') {
951
978
  let newContext;
@@ -953,10 +980,8 @@ class GeoJsonEditor extends HTMLElement {
953
980
  newContext = pendingContext;
954
981
  pendingContext = null;
955
982
  } else if (contextStack.length === 0) {
956
- // Root level
957
983
  newContext = rootContext;
958
984
  } else {
959
- // Inherit from parent if in array
960
985
  const parent = contextStack[contextStack.length - 1];
961
986
  if (parent && parent.isArray) {
962
987
  newContext = parent.context;
@@ -1020,7 +1045,7 @@ class GeoJsonEditor extends HTMLElement {
1020
1045
  .replace(R.lessThan, '&lt;')
1021
1046
  .replace(R.greaterThan, '&gt;')
1022
1047
  // All JSON keys - validate against context
1023
- .replace(R.jsonKey, (match, key) => {
1048
+ .replace(R.jsonKey, (_, key) => {
1024
1049
  // Inside properties - all keys are regular user keys
1025
1050
  if (context === 'properties') {
1026
1051
  return `<span class="json-key">"${key}"</span>:`;
@@ -1037,7 +1062,7 @@ class GeoJsonEditor extends HTMLElement {
1037
1062
  }
1038
1063
  })
1039
1064
  // GeoJSON "type" values - validate based on context
1040
- .replace(R.typeValue, (match, typeValue) => {
1065
+ .replace(R.typeValue, (_, typeValue) => {
1041
1066
  if (isTypeValid(typeValue)) {
1042
1067
  return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
1043
1068
  } else {
@@ -1109,48 +1134,9 @@ class GeoJsonEditor extends HTMLElement {
1109
1134
 
1110
1135
  const indent = match[1];
1111
1136
  const openBracket = match[3];
1112
- const closeBracket = openBracket === '{' ? '}' : ']';
1113
-
1114
- // Check if bracket closes on same line - can't collapse
1115
- if (this.bracketClosesOnSameLine(currentLine, openBracket)) return;
1116
-
1117
- // Find closing bracket in following lines
1118
- let depth = 1;
1119
- let endLine = line;
1120
- const content = [];
1121
-
1122
- for (let i = line + 1; i < lines.length; i++) {
1123
- const scanLine = lines[i];
1124
-
1125
- for (const char of scanLine) {
1126
- if (char === openBracket) depth++;
1127
- if (char === closeBracket) depth--;
1128
- }
1129
-
1130
- content.push(scanLine);
1131
-
1132
- if (depth === 0) {
1133
- endLine = i;
1134
- break;
1135
- }
1136
- }
1137
1137
 
1138
- // Store the original data with unique key
1139
- const uniqueKey = `${line}-${nodeKey}`;
1140
- this.collapsedData.set(uniqueKey, {
1141
- originalLine: currentLine,
1142
- content: content,
1143
- indent: indent.length,
1144
- nodeKey: nodeKey // Store nodeKey for later use
1145
- });
1146
-
1147
- // Replace with marker
1148
- const beforeBracket = currentLine.substring(0, currentLine.indexOf(openBracket));
1149
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1150
- lines[line] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1151
-
1152
- // Remove content lines
1153
- lines.splice(line + 1, endLine - line);
1138
+ // Use common collapse helper
1139
+ if (this._performCollapse(lines, line, nodeKey, indent, openBracket) === 0) return;
1154
1140
  }
1155
1141
 
1156
1142
  // Update textarea
@@ -1176,48 +1162,9 @@ class GeoJsonEditor extends HTMLElement {
1176
1162
  if (nodeKey === 'coordinates') {
1177
1163
  const indent = match[1];
1178
1164
  const openBracket = match[3];
1179
- const closeBracket = openBracket === '{' ? '}' : ']';
1180
-
1181
- // Skip if bracket closes on same line
1182
- if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1183
-
1184
- // Find closing bracket in following lines
1185
- let depth = 1;
1186
- let endLine = i;
1187
- const content = [];
1188
-
1189
- for (let j = i + 1; j < lines.length; j++) {
1190
- const scanLine = lines[j];
1191
-
1192
- for (const char of scanLine) {
1193
- if (char === openBracket) depth++;
1194
- if (char === closeBracket) depth--;
1195
- }
1196
-
1197
- content.push(scanLine);
1198
-
1199
- if (depth === 0) {
1200
- endLine = j;
1201
- break;
1202
- }
1203
- }
1204
-
1205
- // Store the original data with unique key
1206
- const uniqueKey = `${i}-${nodeKey}`;
1207
- this.collapsedData.set(uniqueKey, {
1208
- originalLine: line,
1209
- content: content,
1210
- indent: indent.length,
1211
- nodeKey: nodeKey
1212
- });
1213
1165
 
1214
- // Replace with marker
1215
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
1216
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1217
- lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1218
-
1219
- // Remove content lines
1220
- lines.splice(i + 1, endLine - i);
1166
+ // Use common collapse helper
1167
+ this._performCollapse(lines, i, nodeKey, indent, openBracket);
1221
1168
  }
1222
1169
  }
1223
1170
  }
@@ -1285,7 +1232,7 @@ class GeoJsonEditor extends HTMLElement {
1285
1232
  elements.visibilityButtons.forEach(({ featureKey, isHidden }) => {
1286
1233
  const button = document.createElement('button');
1287
1234
  button.className = 'visibility-button' + (isHidden ? ' hidden' : '');
1288
- button.innerHTML = GeoJsonEditor.ICON_EYE;
1235
+ button.textContent = '👁';
1289
1236
  button.dataset.featureKey = featureKey;
1290
1237
  button.title = isHidden ? 'Show feature in events' : 'Hide feature from events';
1291
1238
  gutterLine.appendChild(button);
@@ -1322,9 +1269,15 @@ class GeoJsonEditor extends HTMLElement {
1322
1269
  }
1323
1270
 
1324
1271
  showColorPicker(indicator, line, currentColor, attributeName) {
1325
- // Remove existing picker
1272
+ // Remove existing picker and clean up its listener
1326
1273
  const existing = document.querySelector('.geojson-color-picker-input');
1327
- 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
+ }
1328
1281
 
1329
1282
  // Create small color input positioned at the indicator
1330
1283
  const colorInput = document.createElement('input');
@@ -1358,11 +1311,14 @@ class GeoJsonEditor extends HTMLElement {
1358
1311
  // Close picker when clicking anywhere else
1359
1312
  const closeOnClickOutside = (e) => {
1360
1313
  if (e.target !== colorInput && !colorInput.contains(e.target)) {
1361
- colorInput.remove();
1362
1314
  document.removeEventListener('click', closeOnClickOutside, true);
1315
+ colorInput.remove();
1363
1316
  }
1364
1317
  };
1365
1318
 
1319
+ // Store the listener reference on the element for cleanup
1320
+ colorInput._closeListener = closeOnClickOutside;
1321
+
1366
1322
  // Add to document body with fixed positioning
1367
1323
  document.body.appendChild(colorInput);
1368
1324
 
@@ -1424,8 +1380,15 @@ class GeoJsonEditor extends HTMLElement {
1424
1380
  return; // No collapsed content, use default copy behavior
1425
1381
  }
1426
1382
 
1427
- // Replace collapsed markers with real content
1428
- 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
+ }
1429
1392
 
1430
1393
  // Put expanded text in clipboard
1431
1394
  e.preventDefault();
@@ -1436,6 +1399,7 @@ class GeoJsonEditor extends HTMLElement {
1436
1399
  const textarea = this.shadowRoot.getElementById('textarea');
1437
1400
  const beforeSelection = textarea.value.substring(0, startPos);
1438
1401
  const startLineNum = beforeSelection.split('\n').length - 1;
1402
+ const R = GeoJsonEditor.REGEX;
1439
1403
 
1440
1404
  const lines = text.split('\n');
1441
1405
  const expandedLines = [];
@@ -1445,17 +1409,43 @@ class GeoJsonEditor extends HTMLElement {
1445
1409
 
1446
1410
  // Check if this line has a collapsed marker
1447
1411
  if (line.includes('{...}') || line.includes('[...]')) {
1448
- // 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
1449
1439
  let found = false;
1450
- this.collapsedData.forEach((collapsed, key) => {
1440
+ for (const [key, collapsed] of this.collapsedData.entries()) {
1451
1441
  const collapsedLineNum = parseInt(key.split('-')[0]);
1452
1442
  if (collapsedLineNum === absoluteLineNum) {
1453
- // Replace with original line and all collapsed content
1454
1443
  expandedLines.push(collapsed.originalLine);
1455
1444
  expandedLines.push(...collapsed.content);
1456
1445
  found = true;
1446
+ break;
1457
1447
  }
1458
- });
1448
+ }
1459
1449
  if (!found) {
1460
1450
  expandedLines.push(line);
1461
1451
  }
@@ -1605,11 +1595,6 @@ class GeoJsonEditor extends HTMLElement {
1605
1595
  this.emitChange();
1606
1596
  }
1607
1597
 
1608
- // Check if a feature is hidden
1609
- isFeatureHidden(featureKey) {
1610
- return this.hiddenFeatures.has(featureKey);
1611
- }
1612
-
1613
1598
  // Parse JSON and extract feature ranges (line numbers for each Feature)
1614
1599
  updateFeatureRanges() {
1615
1600
  const textarea = this.shadowRoot.getElementById('textarea');
@@ -1663,22 +1648,16 @@ class GeoJsonEditor extends HTMLElement {
1663
1648
  inFeature = true;
1664
1649
 
1665
1650
  // Start braceDepth at 1 since we're inside the Feature's opening brace
1666
- // Then count any additional braces from startLine to current line
1651
+ // Then count any additional braces from startLine to current line (ignoring strings)
1667
1652
  braceDepth = 1;
1668
1653
  for (let k = startLine; k <= i; k++) {
1669
1654
  const scanLine = lines[k];
1670
- // Skip the first { we already counted
1671
- let skipFirst = (k === startLine);
1672
- for (const char of scanLine) {
1673
- if (char === '{') {
1674
- if (skipFirst) {
1675
- skipFirst = false;
1676
- } else {
1677
- braceDepth++;
1678
- }
1679
- } else if (char === '}') {
1680
- braceDepth--;
1681
- }
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;
1682
1661
  }
1683
1662
  }
1684
1663
 
@@ -1687,11 +1666,9 @@ class GeoJsonEditor extends HTMLElement {
1687
1666
  currentFeatureKey = this.getFeatureKey(features[featureIndex]);
1688
1667
  }
1689
1668
  } else if (inFeature) {
1690
- // Count braces
1691
- for (const char of line) {
1692
- if (char === '{') braceDepth++;
1693
- else if (char === '}') braceDepth--;
1694
- }
1669
+ // Count braces (ignoring those in strings)
1670
+ const counts = this._countBracketsOutsideStrings(line, '{');
1671
+ braceDepth += counts.open - counts.close;
1695
1672
 
1696
1673
  // Feature ends when braceDepth returns to 0
1697
1674
  if (braceDepth <= 0) {
@@ -1779,19 +1756,131 @@ class GeoJsonEditor extends HTMLElement {
1779
1756
  return errors;
1780
1757
  }
1781
1758
 
1782
- // Helper: Check if bracket closes on same line
1783
- 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) {
1784
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) {
1785
1797
  const bracketPos = line.indexOf(openBracket);
1786
1798
  if (bracketPos === -1) return false;
1799
+
1787
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) {
1788
1811
  let depth = 1;
1789
- for (const char of restOfLine) {
1790
- if (char === openBracket) depth++;
1791
- if (char === closeBracket) depth--;
1792
- 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
+ }
1793
1824
  }
1794
- 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;
1795
1884
  }
1796
1885
 
1797
1886
  // Helper: Expand all collapsed markers and return expanded content
@@ -1908,34 +1997,6 @@ class GeoJsonEditor extends HTMLElement {
1908
1997
  }
1909
1998
  }
1910
1999
 
1911
- autoFormatContent() {
1912
- const textarea = this.shadowRoot.getElementById('textarea');
1913
-
1914
- // Save collapsed node details
1915
- const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
1916
- nodeKey: data.nodeKey,
1917
- indent: data.indent
1918
- }));
1919
-
1920
- // Expand and format
1921
- const content = this.expandAllCollapsed(textarea.value);
1922
-
1923
- try {
1924
- const formattedContent = this.formatJSONContent(content);
1925
-
1926
- if (formattedContent !== content) {
1927
- this.collapsedData.clear();
1928
- textarea.value = formattedContent;
1929
-
1930
- if (collapsedNodes.length > 0) {
1931
- this.reapplyCollapsed(collapsedNodes);
1932
- }
1933
- }
1934
- } catch (e) {
1935
- // Invalid JSON, don't format
1936
- }
1937
- }
1938
-
1939
2000
  reapplyCollapsed(collapsedNodes) {
1940
2001
  const textarea = this.shadowRoot.getElementById('textarea');
1941
2002
  const lines = textarea.value.split('\n');
@@ -1967,50 +2028,11 @@ class GeoJsonEditor extends HTMLElement {
1967
2028
 
1968
2029
  // Only collapse if this occurrence should be collapsed
1969
2030
  if (currentOccurrence <= collapseMap.get(key)) {
1970
- const indent = match[1];
1971
- const openBracket = match[3];
1972
- const closeBracket = openBracket === '{' ? '}' : ']';
1973
-
1974
- // Skip if closes on same line
1975
- if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1976
-
1977
- // Find closing bracket
1978
- let depth = 1;
1979
- let endLine = i;
1980
- const content = [];
2031
+ const indent = match[1];
2032
+ const openBracket = match[3];
1981
2033
 
1982
- for (let j = i + 1; j < lines.length; j++) {
1983
- const scanLine = lines[j];
1984
-
1985
- for (const char of scanLine) {
1986
- if (char === openBracket) depth++;
1987
- if (char === closeBracket) depth--;
1988
- }
1989
-
1990
- content.push(scanLine);
1991
-
1992
- if (depth === 0) {
1993
- endLine = j;
1994
- break;
1995
- }
1996
- }
1997
-
1998
- // Store with unique key
1999
- const uniqueKey = `${i}-${nodeKey}`;
2000
- this.collapsedData.set(uniqueKey, {
2001
- originalLine: line,
2002
- content: content,
2003
- indent: indent.length,
2004
- nodeKey: nodeKey
2005
- });
2006
-
2007
- // Replace with marker
2008
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
2009
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
2010
- lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
2011
-
2012
- // Remove content lines
2013
- lines.splice(i + 1, endLine - i);
2034
+ // Use common collapse helper
2035
+ this._performCollapse(lines, i, nodeKey, indent, openBracket);
2014
2036
  }
2015
2037
  }
2016
2038
  }
@@ -2039,77 +2061,41 @@ class GeoJsonEditor extends HTMLElement {
2039
2061
  // Generate and inject theme CSS based on dark selector
2040
2062
  updateThemeCSS() {
2041
2063
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2042
-
2043
- // Parse selector to create CSS rule for dark theme
2044
2064
  const darkRule = this.parseSelectorToHostRule(darkSelector);
2045
- // Light theme is the default (no selector = light)
2046
- const lightRule = ':host';
2047
2065
 
2048
2066
  // Find or create theme style element
2049
2067
  let themeStyle = this.shadowRoot.getElementById('theme-styles');
2050
2068
  if (!themeStyle) {
2051
2069
  themeStyle = document.createElement('style');
2052
2070
  themeStyle.id = 'theme-styles';
2053
- // Insert at the beginning of shadow root to ensure it's before static styles
2054
2071
  this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
2055
2072
  }
2056
2073
 
2057
- // Generate CSS with theme variables (light first as default, then dark overrides)
2058
- const css = `
2059
- ${lightRule} {
2060
- --bg-color: ${this.themes.light.background};
2061
- --text-color: ${this.themes.light.textColor};
2062
- --caret-color: ${this.themes.light.caretColor};
2063
- --gutter-bg: ${this.themes.light.gutterBackground};
2064
- --gutter-border: ${this.themes.light.gutterBorder};
2065
- --json-key: ${this.themes.light.jsonKey};
2066
- --json-string: ${this.themes.light.jsonString};
2067
- --json-number: ${this.themes.light.jsonNumber};
2068
- --json-boolean: ${this.themes.light.jsonBoolean};
2069
- --json-null: ${this.themes.light.jsonNull};
2070
- --json-punct: ${this.themes.light.jsonPunctuation};
2071
- --control-color: ${this.themes.light.controlColor};
2072
- --control-bg: ${this.themes.light.controlBg};
2073
- --control-border: ${this.themes.light.controlBorder};
2074
- --geojson-key: ${this.themes.light.geojsonKey};
2075
- --geojson-type: ${this.themes.light.geojsonType};
2076
- --geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};
2077
- --json-key-invalid: ${this.themes.light.jsonKeyInvalid};
2078
- }
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
+ };
2079
2080
 
2080
- ${darkRule} {
2081
- --bg-color: ${this.themes.dark.background};
2082
- --text-color: ${this.themes.dark.textColor};
2083
- --caret-color: ${this.themes.dark.caretColor};
2084
- --gutter-bg: ${this.themes.dark.gutterBackground};
2085
- --gutter-border: ${this.themes.dark.gutterBorder};
2086
- --json-key: ${this.themes.dark.jsonKey};
2087
- --json-string: ${this.themes.dark.jsonString};
2088
- --json-number: ${this.themes.dark.jsonNumber};
2089
- --json-boolean: ${this.themes.dark.jsonBoolean};
2090
- --json-null: ${this.themes.dark.jsonNull};
2091
- --json-punct: ${this.themes.dark.jsonPunctuation};
2092
- --control-color: ${this.themes.dark.controlColor};
2093
- --control-bg: ${this.themes.dark.controlBg};
2094
- --control-border: ${this.themes.dark.controlBorder};
2095
- --geojson-key: ${this.themes.dark.geojsonKey};
2096
- --geojson-type: ${this.themes.dark.geojsonType};
2097
- --geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};
2098
- --json-key-invalid: ${this.themes.dark.jsonKeyInvalid};
2099
- }
2100
- `;
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 }`;
2101
2094
 
2102
2095
  themeStyle.textContent = css;
2103
2096
  }
2104
2097
 
2105
2098
  // Public API: Theme management
2106
- getTheme() {
2107
- return {
2108
- dark: { ...this.themes.dark },
2109
- light: { ...this.themes.light }
2110
- };
2111
- }
2112
-
2113
2099
  setTheme(theme) {
2114
2100
  if (theme.dark) {
2115
2101
  this.themes.dark = { ...this.themes.dark, ...theme.dark };
@@ -2117,19 +2103,278 @@ class GeoJsonEditor extends HTMLElement {
2117
2103
  if (theme.light) {
2118
2104
  this.themes.light = { ...this.themes.light, ...theme.light };
2119
2105
  }
2120
-
2121
- // Regenerate CSS with new theme values
2122
2106
  this.updateThemeCSS();
2123
2107
  }
2124
2108
 
2125
2109
  resetTheme() {
2126
- // Reset to defaults
2127
- this.themes = {
2128
- dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
2129
- light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
2130
- };
2110
+ this.themes = { dark: {}, light: {} };
2131
2111
  this.updateThemeCSS();
2132
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
+ }
2133
2378
  }
2134
2379
 
2135
2380
  // Register the custom element