@softwarity/geojson-editor 1.0.1

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.
@@ -0,0 +1,1825 @@
1
+ class GeoJsonEditor extends HTMLElement {
2
+ constructor() {
3
+ super();
4
+ this.attachShadow({ mode: 'open' });
5
+
6
+ // Internal state
7
+ this.collapsedData = new Map(); // nodeKey -> {originalLines: string[], indent: number}
8
+ this.colorPositions = []; // {line, color}
9
+ this.nodeTogglePositions = []; // {line, nodeKey, isCollapsed, indent}
10
+
11
+ // Debounce timer for syntax highlighting
12
+ this.highlightTimer = null;
13
+
14
+ // Cached computed styles (avoid repeated getComputedStyle calls)
15
+ this._cachedLineHeight = null;
16
+ this._cachedPaddingTop = null;
17
+
18
+ // Initialize themes from defaults
19
+ this.themes = {
20
+ dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
21
+ light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
22
+ };
23
+ }
24
+
25
+ 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
+ collapseButton: '#c586c0',
45
+ collapseButtonBg: '#3e3e42',
46
+ collapseButtonBorder: '#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
+ collapseButton: '#a31515',
65
+ collapseButtonBg: '#e0e0e0',
66
+ collapseButtonBorder: '#999',
67
+ geojsonKey: '#af00db',
68
+ geojsonType: '#267f99',
69
+ geojsonTypeInvalid: '#d32f2f',
70
+ jsonKeyInvalid: '#d32f2f'
71
+ }
72
+ };
73
+
74
+ // FeatureCollection wrapper constants
75
+ static FEATURE_COLLECTION_PREFIX = '{"type": "FeatureCollection", "features": [';
76
+ static FEATURE_COLLECTION_SUFFIX = ']}';
77
+
78
+ // Pre-compiled regex patterns (avoid recompilation on each call)
79
+ static REGEX = {
80
+ // HTML escaping
81
+ ampersand: /&/g,
82
+ lessThan: /</g,
83
+ greaterThan: />/g,
84
+ // JSON structure
85
+ jsonKey: /"([^"]+)"\s*:/g,
86
+ typeValue: /<span class="geojson-key">"type"<\/span>:\s*"([^"]*)"/g,
87
+ stringValue: /:\s*"([^"]*)"/g,
88
+ numberAfterColon: /:\s*(-?\d+\.?\d*)/g,
89
+ boolean: /:\s*(true|false)/g,
90
+ nullValue: /:\s*(null)/g,
91
+ allNumbers: /\b(-?\d+\.?\d*)\b/g,
92
+ punctuation: /([{}[\],])/g,
93
+ // Highlighting detection
94
+ colorInLine: /"(\w+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,
95
+ collapsibleNode: /^(\s*)"(\w+)"\s*:\s*([{\[])/,
96
+ collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
97
+ };
98
+
99
+ connectedCallback() {
100
+ this.render();
101
+ this.setupEventListeners();
102
+
103
+ // Update prefix/suffix display
104
+ this.updatePrefixSuffix();
105
+
106
+ // Setup theme CSS
107
+ this.updateThemeCSS();
108
+
109
+ // Initial highlight
110
+ if (this.value) {
111
+ this.updateHighlight();
112
+ // Auto-collapse coordinates nodes after initial rendering
113
+ requestAnimationFrame(() => {
114
+ this.applyAutoCollapsed();
115
+ });
116
+ }
117
+ }
118
+
119
+ attributeChangedCallback(name, oldValue, newValue) {
120
+ if (oldValue === newValue) return;
121
+
122
+ if (name === 'value') {
123
+ this.updateValue(newValue);
124
+ } else if (name === 'readonly') {
125
+ this.updateReadonly();
126
+ } else if (name === 'placeholder') {
127
+ const textarea = this.shadowRoot.querySelector('textarea');
128
+ if (textarea) textarea.placeholder = newValue || '';
129
+ } else if (name === 'dark-selector') {
130
+ this.updateThemeCSS();
131
+ } else if (name === 'feature-collection') {
132
+ this.updatePrefixSuffix();
133
+ } else if (name === 'auto-format') {
134
+ // When auto-format is enabled, format the current content
135
+ const textarea = this.shadowRoot?.getElementById('textarea');
136
+ if (textarea && textarea.value && this.autoFormat) {
137
+ this.autoFormatContent();
138
+ this.updateHighlight();
139
+ }
140
+ }
141
+ }
142
+
143
+ // Properties
144
+ get readonly() {
145
+ return this.hasAttribute('readonly');
146
+ }
147
+
148
+
149
+ get value() {
150
+ return this.getAttribute('value') || '';
151
+ }
152
+
153
+ get placeholder() {
154
+ return this.getAttribute('placeholder') || '';
155
+ }
156
+
157
+ get autoFormat() {
158
+ return this.hasAttribute('auto-format');
159
+ }
160
+
161
+ get featureCollection() {
162
+ return this.hasAttribute('feature-collection');
163
+ }
164
+
165
+ // Internal getters for prefix/suffix based on feature-collection mode
166
+ get prefix() {
167
+ return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_PREFIX : '';
168
+ }
169
+
170
+ get suffix() {
171
+ return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_SUFFIX : '';
172
+ }
173
+
174
+ render() {
175
+ const styles = `
176
+ <style>
177
+ /* Global reset with exact values to prevent external CSS interference */
178
+ :host *,
179
+ :host *::before,
180
+ :host *::after {
181
+ box-sizing: border-box;
182
+ font-family: 'Courier New', Courier, monospace;
183
+ font-size: 13px;
184
+ font-weight: normal;
185
+ font-style: normal;
186
+ font-variant: normal;
187
+ line-height: 1.5;
188
+ letter-spacing: 0;
189
+ text-transform: none;
190
+ text-decoration: none;
191
+ text-indent: 0;
192
+ word-spacing: 0;
193
+ }
194
+
195
+ :host {
196
+ display: flex;
197
+ flex-direction: column;
198
+ position: relative;
199
+ width: 100%;
200
+ height: 400px;
201
+ font-family: 'Courier New', Courier, monospace;
202
+ font-size: 13px;
203
+ line-height: 1.5;
204
+ border-radius: 4px;
205
+ overflow: hidden;
206
+ }
207
+
208
+ :host([readonly]) .editor-wrapper::after {
209
+ content: '';
210
+ position: absolute;
211
+ top: 0;
212
+ left: 0;
213
+ right: 0;
214
+ bottom: 0;
215
+ pointer-events: none;
216
+ background: repeating-linear-gradient(
217
+ -45deg,
218
+ rgba(128, 128, 128, 0.08),
219
+ rgba(128, 128, 128, 0.08) 3px,
220
+ transparent 3px,
221
+ transparent 12px
222
+ );
223
+ z-index: 1;
224
+ }
225
+
226
+ :host([readonly]) textarea {
227
+ cursor: text;
228
+ }
229
+
230
+ .editor-wrapper {
231
+ position: relative;
232
+ width: 100%;
233
+ flex: 1;
234
+ background: var(--bg-color);
235
+ display: flex;
236
+ font-family: 'Courier New', Courier, monospace;
237
+ font-size: 13px;
238
+ line-height: 1.5;
239
+ }
240
+
241
+ .gutter {
242
+ width: 24px;
243
+ height: 100%;
244
+ background: var(--gutter-bg);
245
+ border-right: 1px solid var(--gutter-border);
246
+ overflow: hidden;
247
+ flex-shrink: 0;
248
+ position: relative;
249
+ }
250
+
251
+ .gutter-content {
252
+ position: absolute;
253
+ top: 0;
254
+ left: 0;
255
+ width: 100%;
256
+ padding: 8px 4px;
257
+ }
258
+
259
+ .gutter-line {
260
+ position: absolute;
261
+ left: 0;
262
+ width: 100%;
263
+ height: 1.5em;
264
+ display: flex;
265
+ align-items: center;
266
+ justify-content: center;
267
+ }
268
+
269
+ .color-indicator {
270
+ width: 12px;
271
+ height: 12px;
272
+ border-radius: 2px;
273
+ border: 1px solid #555;
274
+ cursor: pointer;
275
+ transition: transform 0.1s;
276
+ flex-shrink: 0;
277
+ }
278
+
279
+ .color-indicator:hover {
280
+ transform: scale(1.2);
281
+ border-color: #fff;
282
+ }
283
+
284
+ .collapse-button {
285
+ width: 12px;
286
+ height: 12px;
287
+ background: var(--collapse-btn-bg);
288
+ border: 1px solid var(--collapse-btn-border);
289
+ border-radius: 2px;
290
+ color: var(--collapse-btn);
291
+ font-size: 8px;
292
+ font-weight: bold;
293
+ cursor: pointer;
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: center;
297
+ transition: all 0.1s;
298
+ flex-shrink: 0;
299
+ user-select: none;
300
+ }
301
+
302
+ .collapse-button:hover {
303
+ background: var(--collapse-btn-bg);
304
+ border-color: var(--collapse-btn);
305
+ transform: scale(1.1);
306
+ }
307
+
308
+ .color-picker-popup {
309
+ position: absolute;
310
+ background: #2d2d30;
311
+ border: 1px solid #555;
312
+ border-radius: 4px;
313
+ padding: 8px;
314
+ z-index: 1000;
315
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
316
+ }
317
+
318
+ .color-picker-popup input[type="color"] {
319
+ width: 150px;
320
+ height: 30px;
321
+ border: none;
322
+ cursor: pointer;
323
+ }
324
+
325
+ .editor-content {
326
+ position: relative;
327
+ flex: 1;
328
+ overflow: hidden;
329
+ }
330
+
331
+ .highlight-layer {
332
+ position: absolute;
333
+ top: 0;
334
+ left: 0;
335
+ width: 100%;
336
+ height: 100%;
337
+ padding: 8px 12px;
338
+ font-family: 'Courier New', Courier, monospace;
339
+ font-size: 13px;
340
+ font-weight: normal;
341
+ font-style: normal;
342
+ line-height: 1.5;
343
+ white-space: pre-wrap;
344
+ word-wrap: break-word;
345
+ overflow: auto;
346
+ pointer-events: none;
347
+ z-index: 1;
348
+ color: var(--text-color);
349
+ }
350
+
351
+ .highlight-layer::-webkit-scrollbar {
352
+ display: none;
353
+ }
354
+
355
+ textarea {
356
+ position: absolute;
357
+ top: 0;
358
+ left: 0;
359
+ width: 100%;
360
+ height: 100%;
361
+ padding: 8px 12px;
362
+ margin: 0;
363
+ border: none;
364
+ outline: none;
365
+ background: transparent;
366
+ color: transparent;
367
+ caret-color: var(--caret-color);
368
+ font-family: 'Courier New', Courier, monospace;
369
+ font-size: 13px;
370
+ font-weight: normal;
371
+ font-style: normal;
372
+ line-height: 1.5;
373
+ white-space: pre-wrap;
374
+ word-wrap: break-word;
375
+ resize: none;
376
+ overflow: auto;
377
+ z-index: 2;
378
+ box-sizing: border-box;
379
+ }
380
+
381
+ textarea::selection {
382
+ background: rgba(51, 153, 255, 0.3);
383
+ }
384
+
385
+ textarea::placeholder {
386
+ color: #6a6a6a;
387
+ font-family: 'Courier New', Courier, monospace;
388
+ font-size: 13px;
389
+ font-weight: normal;
390
+ font-style: normal;
391
+ opacity: 1;
392
+ }
393
+
394
+ textarea:disabled {
395
+ cursor: not-allowed;
396
+ opacity: 0.6;
397
+ }
398
+
399
+ /* Syntax highlighting colors */
400
+ .json-key {
401
+ color: var(--json-key);
402
+ }
403
+
404
+ .json-string {
405
+ color: var(--json-string);
406
+ }
407
+
408
+ .json-number {
409
+ color: var(--json-number);
410
+ }
411
+
412
+ .json-boolean {
413
+ color: var(--json-boolean);
414
+ }
415
+
416
+ .json-null {
417
+ color: var(--json-null);
418
+ }
419
+
420
+ .json-punctuation {
421
+ color: var(--json-punct);
422
+ }
423
+
424
+ /* GeoJSON-specific highlighting */
425
+ .geojson-key {
426
+ color: var(--geojson-key);
427
+ font-weight: 600;
428
+ }
429
+
430
+ .geojson-type {
431
+ color: var(--geojson-type);
432
+ font-weight: 600;
433
+ }
434
+
435
+ .geojson-type-invalid {
436
+ color: var(--geojson-type-invalid);
437
+ font-weight: 600;
438
+ }
439
+
440
+ .json-key-invalid {
441
+ color: var(--json-key-invalid);
442
+ }
443
+
444
+ /* Prefix and suffix styling */
445
+ .editor-prefix,
446
+ .editor-suffix {
447
+ padding: 4px 12px;
448
+ color: var(--text-color);
449
+ background: var(--bg-color);
450
+ user-select: none;
451
+ white-space: pre-wrap;
452
+ word-wrap: break-word;
453
+ flex-shrink: 0;
454
+ font-family: 'Courier New', Courier, monospace;
455
+ font-size: 13px;
456
+ line-height: 1.5;
457
+ opacity: 0.6;
458
+ border-left: 3px solid rgba(102, 126, 234, 0.5);
459
+ }
460
+
461
+ .editor-prefix {
462
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
463
+ }
464
+
465
+ .editor-suffix {
466
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
467
+ }
468
+
469
+ /* Scrollbar styling */
470
+ textarea::-webkit-scrollbar {
471
+ width: 10px;
472
+ height: 10px;
473
+ }
474
+
475
+ textarea::-webkit-scrollbar-track {
476
+ background: #1e1e1e;
477
+ }
478
+
479
+ textarea::-webkit-scrollbar-thumb {
480
+ background: #424242;
481
+ border-radius: 5px;
482
+ }
483
+
484
+ textarea::-webkit-scrollbar-thumb:hover {
485
+ background: #4e4e4e;
486
+ }
487
+ </style>
488
+ `;
489
+
490
+ const template = `
491
+ <div class="editor-prefix" id="editorPrefix"></div>
492
+ <div class="editor-wrapper">
493
+ <div class="gutter">
494
+ <div class="gutter-content" id="gutterContent"></div>
495
+ </div>
496
+ <div class="editor-content">
497
+ <div class="highlight-layer" id="highlightLayer"></div>
498
+ <textarea
499
+ id="textarea"
500
+ spellcheck="false"
501
+ autocomplete="off"
502
+ autocorrect="off"
503
+ autocapitalize="off"
504
+ placeholder="${this.placeholder}"
505
+ ></textarea>
506
+ </div>
507
+ </div>
508
+ <div class="editor-suffix" id="editorSuffix"></div>
509
+ `;
510
+
511
+ this.shadowRoot.innerHTML = styles + template;
512
+ }
513
+
514
+ setupEventListeners() {
515
+ const textarea = this.shadowRoot.getElementById('textarea');
516
+ const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
517
+
518
+ // Sync scroll between textarea and highlight layer
519
+ textarea.addEventListener('scroll', () => {
520
+ highlightLayer.scrollTop = textarea.scrollTop;
521
+ highlightLayer.scrollLeft = textarea.scrollLeft;
522
+ this.syncGutterScroll(textarea.scrollTop);
523
+ });
524
+
525
+ // Input handling with debounced highlight and auto-format
526
+ textarea.addEventListener('input', () => {
527
+ clearTimeout(this.highlightTimer);
528
+ this.highlightTimer = setTimeout(() => {
529
+ // Auto-format if enabled and JSON is valid
530
+ if (this.autoFormat) {
531
+ this.autoFormatContentWithCursor();
532
+ }
533
+ this.updateHighlight();
534
+ this.emitChange();
535
+ }, 150);
536
+ });
537
+
538
+ // Paste handling - trigger immediately without debounce
539
+ textarea.addEventListener('paste', () => {
540
+ // Clear any pending highlight timer to avoid duplicate processing
541
+ clearTimeout(this.highlightTimer);
542
+
543
+ // Use a short delay to let the paste complete
544
+ setTimeout(() => {
545
+ // Auto-format if enabled and JSON is valid
546
+ if (this.autoFormat) {
547
+ this.autoFormatContentWithCursor();
548
+ }
549
+ this.updateHighlight();
550
+ this.emitChange();
551
+ }, 10);
552
+ });
553
+
554
+ // Gutter clicks (color indicators and collapse buttons)
555
+ const gutterContent = this.shadowRoot.getElementById('gutterContent');
556
+ gutterContent.addEventListener('click', (e) => {
557
+ if (e.target.classList.contains('color-indicator')) {
558
+ const line = parseInt(e.target.dataset.line);
559
+ const color = e.target.dataset.color;
560
+ const attributeName = e.target.dataset.attributeName;
561
+ this.showColorPicker(e.target, line, color, attributeName);
562
+ } else if (e.target.classList.contains('collapse-button')) {
563
+ const nodeKey = e.target.dataset.nodeKey;
564
+ const line = parseInt(e.target.dataset.line);
565
+ this.toggleCollapse(nodeKey, line);
566
+ }
567
+ });
568
+
569
+ // Transfer wheel scroll from gutter to textarea
570
+ const gutter = this.shadowRoot.querySelector('.gutter');
571
+ gutter.addEventListener('wheel', (e) => {
572
+ e.preventDefault();
573
+ textarea.scrollTop += e.deltaY;
574
+ });
575
+
576
+ // Block editing in collapsed areas
577
+ textarea.addEventListener('keydown', (e) => {
578
+ this.handleKeydownInCollapsedArea(e);
579
+ });
580
+
581
+ // Handle copy to include collapsed content
582
+ textarea.addEventListener('copy', (e) => {
583
+ this.handleCopyWithCollapsedContent(e);
584
+ });
585
+
586
+ // Handle cut to include collapsed content
587
+ textarea.addEventListener('cut', (e) => {
588
+ this.handleCutWithCollapsedContent(e);
589
+ });
590
+
591
+ // Update readonly state
592
+ this.updateReadonly();
593
+ }
594
+
595
+ syncGutterScroll(scrollTop) {
596
+ const gutterContent = this.shadowRoot.getElementById('gutterContent');
597
+ gutterContent.style.transform = `translateY(-${scrollTop}px)`;
598
+ }
599
+
600
+ updateReadonly() {
601
+ const textarea = this.shadowRoot.getElementById('textarea');
602
+ if (textarea) {
603
+ textarea.disabled = this.readonly;
604
+ }
605
+ }
606
+
607
+ updateValue(newValue) {
608
+ const textarea = this.shadowRoot.getElementById('textarea');
609
+ if (textarea && textarea.value !== newValue) {
610
+ textarea.value = newValue || '';
611
+
612
+ // Apply auto-format if enabled
613
+ if (this.autoFormat && newValue) {
614
+ try {
615
+ const prefix = this.prefix;
616
+ const suffix = this.suffix;
617
+
618
+ // Check if prefix ends with [ and suffix starts with ]
619
+ const prefixEndsWithBracket = prefix.trimEnd().endsWith('[');
620
+ const suffixStartsWithBracket = suffix.trimStart().startsWith(']');
621
+
622
+ if (prefixEndsWithBracket && suffixStartsWithBracket) {
623
+ // Wrap content in array brackets for validation and formatting
624
+ const wrapped = '[' + newValue + ']';
625
+ const parsed = JSON.parse(wrapped);
626
+ const formatted = JSON.stringify(parsed, null, 2);
627
+
628
+ // Remove first [ and last ] from formatted
629
+ const lines = formatted.split('\n');
630
+ if (lines.length > 2) {
631
+ textarea.value = lines.slice(1, -1).join('\n');
632
+ } else {
633
+ textarea.value = '';
634
+ }
635
+ } else if (!prefix && !suffix) {
636
+ // No prefix/suffix - format directly
637
+ const parsed = JSON.parse(newValue);
638
+ textarea.value = JSON.stringify(parsed, null, 2);
639
+ }
640
+ // else: keep as-is for complex cases
641
+ } catch (e) {
642
+ // Invalid JSON, keep as-is
643
+ }
644
+ }
645
+
646
+ this.updateHighlight();
647
+
648
+ // Auto-collapse coordinates nodes after value is set
649
+ if (textarea.value) {
650
+ requestAnimationFrame(() => {
651
+ this.applyAutoCollapsed();
652
+ });
653
+ }
654
+ }
655
+ }
656
+
657
+ updatePrefixSuffix() {
658
+ const prefixEl = this.shadowRoot.getElementById('editorPrefix');
659
+ const suffixEl = this.shadowRoot.getElementById('editorSuffix');
660
+
661
+ if (prefixEl) {
662
+ if (this.prefix) {
663
+ prefixEl.textContent = this.prefix;
664
+ prefixEl.style.display = 'block';
665
+ } else {
666
+ prefixEl.textContent = '';
667
+ prefixEl.style.display = 'none';
668
+ }
669
+ }
670
+
671
+ if (suffixEl) {
672
+ if (this.suffix) {
673
+ suffixEl.textContent = this.suffix;
674
+ suffixEl.style.display = 'block';
675
+ } else {
676
+ suffixEl.textContent = '';
677
+ suffixEl.style.display = 'none';
678
+ }
679
+ }
680
+ }
681
+
682
+ updateHighlight() {
683
+ const textarea = this.shadowRoot.getElementById('textarea');
684
+ const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
685
+
686
+ if (!textarea || !highlightLayer) return;
687
+
688
+ const text = textarea.value;
689
+
690
+ // Parse and highlight
691
+ const { highlighted, colors, toggles } = this.highlightJSON(text);
692
+
693
+ highlightLayer.innerHTML = highlighted;
694
+ this.colorPositions = colors;
695
+ this.nodeTogglePositions = toggles;
696
+
697
+ // Update gutter with color indicators
698
+ this.updateGutter();
699
+ }
700
+
701
+ highlightJSON(text) {
702
+ if (!text.trim()) {
703
+ return { highlighted: '', colors: [], toggles: [] };
704
+ }
705
+
706
+ const lines = text.split('\n');
707
+ const colors = [];
708
+ const toggles = [];
709
+ let highlightedLines = [];
710
+
711
+ // Build context map for validation
712
+ const contextMap = this.buildContextMap(text);
713
+
714
+ lines.forEach((line, lineIndex) => {
715
+ // Detect any hex color (6 digits) in string values
716
+ const R = GeoJsonEditor.REGEX;
717
+ R.colorInLine.lastIndex = 0; // Reset for global regex
718
+ let colorMatch;
719
+ while ((colorMatch = R.colorInLine.exec(line)) !== null) {
720
+ colors.push({
721
+ line: lineIndex,
722
+ color: colorMatch[2], // The hex color
723
+ attributeName: colorMatch[1] // The attribute name
724
+ });
725
+ }
726
+
727
+ // Detect collapsible nodes (all nodes are collapsible)
728
+ const nodeMatch = line.match(R.collapsibleNode);
729
+ if (nodeMatch) {
730
+ const nodeKey = nodeMatch[2];
731
+
732
+ // Check if this is a collapsed marker first
733
+ const isCollapsed = line.includes('{...}') || line.includes('[...]');
734
+
735
+ if (isCollapsed) {
736
+ // It's collapsed, always show button
737
+ toggles.push({
738
+ line: lineIndex,
739
+ nodeKey,
740
+ isCollapsed: true
741
+ });
742
+ } else {
743
+ // Not collapsed - only add toggle button if it doesn't close on same line
744
+ if (!this.bracketClosesOnSameLine(line, nodeMatch[3])) {
745
+ toggles.push({
746
+ line: lineIndex,
747
+ nodeKey,
748
+ isCollapsed: false
749
+ });
750
+ }
751
+ }
752
+ }
753
+
754
+ // Highlight the line with context
755
+ const context = contextMap.get(lineIndex);
756
+ highlightedLines.push(this.highlightSyntax(line, context));
757
+ });
758
+
759
+ return {
760
+ highlighted: highlightedLines.join('\n'),
761
+ colors,
762
+ toggles
763
+ };
764
+ }
765
+
766
+ // GeoJSON type constants
767
+ static GEOJSON_TYPES_FEATURE = ['Feature', 'FeatureCollection'];
768
+ static GEOJSON_TYPES_GEOMETRY = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'];
769
+ static GEOJSON_TYPES_ALL = [...GeoJsonEditor.GEOJSON_TYPES_FEATURE, ...GeoJsonEditor.GEOJSON_TYPES_GEOMETRY];
770
+
771
+ // Valid keys per context (null = any key is valid)
772
+ static VALID_KEYS_BY_CONTEXT = {
773
+ Feature: ['type', 'geometry', 'properties', 'id', 'bbox'],
774
+ FeatureCollection: ['type', 'features', 'bbox', 'properties'],
775
+ Point: ['type', 'coordinates', 'bbox'],
776
+ MultiPoint: ['type', 'coordinates', 'bbox'],
777
+ LineString: ['type', 'coordinates', 'bbox'],
778
+ MultiLineString: ['type', 'coordinates', 'bbox'],
779
+ Polygon: ['type', 'coordinates', 'bbox'],
780
+ MultiPolygon: ['type', 'coordinates', 'bbox'],
781
+ GeometryCollection: ['type', 'geometries', 'bbox'],
782
+ properties: null, // Any key valid in properties
783
+ geometry: ['type', 'coordinates', 'geometries', 'bbox'], // Generic geometry context
784
+ };
785
+
786
+ // Keys that change context for their value
787
+ static CONTEXT_CHANGING_KEYS = {
788
+ geometry: 'geometry',
789
+ properties: 'properties',
790
+ features: 'Feature', // Array of Features
791
+ geometries: 'geometry', // Array of geometries
792
+ };
793
+
794
+ // Build context map for each line by analyzing JSON structure
795
+ buildContextMap(text) {
796
+ const lines = text.split('\n');
797
+ const contextMap = new Map(); // line index -> context
798
+ const contextStack = []; // Stack of {context, isArray}
799
+ let pendingContext = null; // Context for next object/array
800
+
801
+ // Determine root context based on feature-collection mode
802
+ const rootContext = this.featureCollection ? 'Feature' : null;
803
+
804
+ for (let i = 0; i < lines.length; i++) {
805
+ const line = lines[i];
806
+
807
+ // Record context at START of line (for key validation)
808
+ const lineContext = contextStack.length > 0
809
+ ? contextStack[contextStack.length - 1]?.context
810
+ : rootContext;
811
+ contextMap.set(i, lineContext);
812
+
813
+ // Process each character to track brackets for subsequent lines
814
+ for (let j = 0; j < line.length; j++) {
815
+ const char = line[j];
816
+
817
+ // Check for key that changes context: "keyName":
818
+ if (char === '"') {
819
+ const keyMatch = line.substring(j).match(/^"([^"]+)"\s*:/);
820
+ if (keyMatch) {
821
+ const keyName = keyMatch[1];
822
+ if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
823
+ pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
824
+ }
825
+ j += keyMatch[0].length - 1; // Skip past the key
826
+ continue;
827
+ }
828
+ }
829
+
830
+ // Check for type value to refine context: "type": "Point"
831
+ if (char === '"' && contextStack.length > 0) {
832
+ const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
833
+ if (typeMatch) {
834
+ const valueMatch = line.substring(j).match(/^"([^"]+)"/);
835
+ if (valueMatch && GeoJsonEditor.GEOJSON_TYPES_ALL.includes(valueMatch[1])) {
836
+ // Update current context to the specific type
837
+ const currentCtx = contextStack[contextStack.length - 1];
838
+ if (currentCtx) {
839
+ currentCtx.context = valueMatch[1];
840
+ }
841
+ }
842
+ }
843
+ }
844
+
845
+ // Opening bracket - push context
846
+ if (char === '{' || char === '[') {
847
+ let newContext;
848
+ if (pendingContext) {
849
+ newContext = pendingContext;
850
+ pendingContext = null;
851
+ } else if (contextStack.length === 0) {
852
+ // Root level
853
+ newContext = rootContext;
854
+ } else {
855
+ // Inherit from parent if in array
856
+ const parent = contextStack[contextStack.length - 1];
857
+ if (parent && parent.isArray) {
858
+ newContext = parent.context;
859
+ } else {
860
+ newContext = null;
861
+ }
862
+ }
863
+ contextStack.push({ context: newContext, isArray: char === '[' });
864
+ }
865
+
866
+ // Closing bracket - pop context
867
+ if (char === '}' || char === ']') {
868
+ if (contextStack.length > 0) {
869
+ contextStack.pop();
870
+ }
871
+ }
872
+ }
873
+ }
874
+
875
+ return contextMap;
876
+ }
877
+
878
+ // All known GeoJSON structural keys (always valid in GeoJSON)
879
+ static GEOJSON_STRUCTURAL_KEYS = ['type', 'geometry', 'properties', 'features', 'geometries', 'coordinates', 'bbox', 'id', 'crs'];
880
+
881
+ highlightSyntax(text, context) {
882
+ if (!text.trim()) return '';
883
+
884
+ // Get valid keys for current context
885
+ const validKeys = context ? GeoJsonEditor.VALID_KEYS_BY_CONTEXT[context] : null;
886
+
887
+ // Helper to check if a key is valid in current context
888
+ const isKeyValid = (key) => {
889
+ // GeoJSON structural keys are always valid
890
+ if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) return true;
891
+ // No context or null validKeys means all keys are valid
892
+ if (!context || validKeys === null || validKeys === undefined) return true;
893
+ return validKeys.includes(key);
894
+ };
895
+
896
+ // Helper to check if a type value is valid in current context
897
+ const isTypeValid = (typeValue) => {
898
+ // Unknown context - don't validate (could be inside misspelled properties, etc.)
899
+ if (!context) return true;
900
+ if (context === 'properties') return true; // Any type in properties
901
+ if (context === 'geometry' || GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(context)) {
902
+ return GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(typeValue);
903
+ }
904
+ // Only validate as GeoJSON type in known Feature/FeatureCollection context
905
+ if (context === 'Feature' || context === 'FeatureCollection') {
906
+ return GeoJsonEditor.GEOJSON_TYPES_ALL.includes(typeValue);
907
+ }
908
+ return true; // Unknown context - accept any type
909
+ };
910
+
911
+ const R = GeoJsonEditor.REGEX;
912
+
913
+ return text
914
+ // Escape HTML first
915
+ .replace(R.ampersand, '&amp;')
916
+ .replace(R.lessThan, '&lt;')
917
+ .replace(R.greaterThan, '&gt;')
918
+ // All JSON keys - validate against context
919
+ .replace(R.jsonKey, (match, key) => {
920
+ // Inside properties - all keys are regular user keys
921
+ if (context === 'properties') {
922
+ return `<span class="json-key">"${key}"</span>:`;
923
+ }
924
+ // GeoJSON structural keys - highlighted as geojson-key
925
+ if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) {
926
+ return `<span class="geojson-key">"${key}"</span>:`;
927
+ }
928
+ // Regular key - validate against context
929
+ if (isKeyValid(key)) {
930
+ return `<span class="json-key">"${key}"</span>:`;
931
+ } else {
932
+ return `<span class="json-key-invalid">"${key}"</span>:`;
933
+ }
934
+ })
935
+ // GeoJSON "type" values - validate based on context
936
+ .replace(R.typeValue, (match, typeValue) => {
937
+ if (isTypeValid(typeValue)) {
938
+ return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
939
+ } else {
940
+ return `<span class="geojson-key">"type"</span>: <span class="geojson-type-invalid">"${typeValue}"</span>`;
941
+ }
942
+ })
943
+ // Generic string values
944
+ .replace(R.stringValue, (match, value) => {
945
+ // Skip if already highlighted (has span)
946
+ if (match.includes('<span')) return match;
947
+ return `: <span class="json-string">"${value}"</span>`;
948
+ })
949
+ .replace(R.numberAfterColon, ': <span class="json-number">$1</span>')
950
+ .replace(R.boolean, ': <span class="json-boolean">$1</span>')
951
+ .replace(R.nullValue, ': <span class="json-null">$1</span>')
952
+ .replace(R.allNumbers, '<span class="json-number">$1</span>')
953
+ .replace(R.punctuation, '<span class="json-punctuation">$1</span>');
954
+ }
955
+
956
+ toggleCollapse(nodeKey, line) {
957
+ const textarea = this.shadowRoot.getElementById('textarea');
958
+ const lines = textarea.value.split('\n');
959
+ const currentLine = lines[line];
960
+
961
+ // Check if line has collapse marker
962
+ const hasMarker = currentLine.includes('{...}') || currentLine.includes('[...]');
963
+
964
+ if (hasMarker) {
965
+ // Expand: find the correct collapsed data by searching for this nodeKey
966
+ let foundKey = null;
967
+ let foundData = null;
968
+
969
+ // Try exact match first
970
+ const exactKey = `${line}-${nodeKey}`;
971
+ if (this.collapsedData.has(exactKey)) {
972
+ foundKey = exactKey;
973
+ foundData = this.collapsedData.get(exactKey);
974
+ } else {
975
+ // Search for any key with this nodeKey (line numbers may have shifted)
976
+ for (const [key, data] of this.collapsedData.entries()) {
977
+ if (data.nodeKey === nodeKey) {
978
+ // Check indent to distinguish between multiple nodes with same name
979
+ const currentIndent = currentLine.match(/^(\s*)/)[1].length;
980
+ if (data.indent === currentIndent) {
981
+ foundKey = key;
982
+ foundData = data;
983
+ break;
984
+ }
985
+ }
986
+ }
987
+ }
988
+
989
+ if (!foundKey || !foundData) {
990
+ return;
991
+ }
992
+
993
+ const {originalLine, content} = foundData;
994
+
995
+ // Restore original line and content
996
+ lines[line] = originalLine;
997
+ lines.splice(line + 1, 0, ...content);
998
+
999
+ // Remove from storage
1000
+ this.collapsedData.delete(foundKey);
1001
+ } else {
1002
+ // Collapse: read and store content
1003
+ const match = currentLine.match(/^(\s*)"([^"]+)"\s*:\s*([{\[])/);
1004
+ if (!match) return;
1005
+
1006
+ const indent = match[1];
1007
+ const openBracket = match[3];
1008
+ const closeBracket = openBracket === '{' ? '}' : ']';
1009
+
1010
+ // Check if bracket closes on same line - can't collapse
1011
+ if (this.bracketClosesOnSameLine(currentLine, openBracket)) return;
1012
+
1013
+ // Find closing bracket in following lines
1014
+ let depth = 1;
1015
+ let endLine = line;
1016
+ const content = [];
1017
+
1018
+ for (let i = line + 1; i < lines.length; i++) {
1019
+ const scanLine = lines[i];
1020
+
1021
+ for (const char of scanLine) {
1022
+ if (char === openBracket) depth++;
1023
+ if (char === closeBracket) depth--;
1024
+ }
1025
+
1026
+ content.push(scanLine);
1027
+
1028
+ if (depth === 0) {
1029
+ endLine = i;
1030
+ break;
1031
+ }
1032
+ }
1033
+
1034
+ // Store the original data with unique key
1035
+ const uniqueKey = `${line}-${nodeKey}`;
1036
+ this.collapsedData.set(uniqueKey, {
1037
+ originalLine: currentLine,
1038
+ content: content,
1039
+ indent: indent.length,
1040
+ nodeKey: nodeKey // Store nodeKey for later use
1041
+ });
1042
+
1043
+ // Replace with marker
1044
+ const beforeBracket = currentLine.substring(0, currentLine.indexOf(openBracket));
1045
+ const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1046
+ lines[line] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1047
+
1048
+ // Remove content lines
1049
+ lines.splice(line + 1, endLine - line);
1050
+ }
1051
+
1052
+ // Update textarea
1053
+ textarea.value = lines.join('\n');
1054
+ this.updateHighlight();
1055
+ }
1056
+
1057
+ applyAutoCollapsed() {
1058
+ const textarea = this.shadowRoot.getElementById('textarea');
1059
+ if (!textarea || !textarea.value) return;
1060
+
1061
+ const lines = textarea.value.split('\n');
1062
+
1063
+ // Iterate backwards to avoid index issues when collapsing
1064
+ for (let i = lines.length - 1; i >= 0; i--) {
1065
+ const line = lines[i];
1066
+ const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
1067
+
1068
+ if (match) {
1069
+ const nodeKey = match[2];
1070
+
1071
+ // Check if this node should be auto-collapsed (coordinates only)
1072
+ if (nodeKey === 'coordinates') {
1073
+ const indent = match[1];
1074
+ const openBracket = match[3];
1075
+ const closeBracket = openBracket === '{' ? '}' : ']';
1076
+
1077
+ // Skip if bracket closes on same line
1078
+ if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1079
+
1080
+ // Find closing bracket in following lines
1081
+ let depth = 1;
1082
+ let endLine = i;
1083
+ const content = [];
1084
+
1085
+ for (let j = i + 1; j < lines.length; j++) {
1086
+ const scanLine = lines[j];
1087
+
1088
+ for (const char of scanLine) {
1089
+ if (char === openBracket) depth++;
1090
+ if (char === closeBracket) depth--;
1091
+ }
1092
+
1093
+ content.push(scanLine);
1094
+
1095
+ if (depth === 0) {
1096
+ endLine = j;
1097
+ break;
1098
+ }
1099
+ }
1100
+
1101
+ // Store the original data with unique key
1102
+ const uniqueKey = `${i}-${nodeKey}`;
1103
+ this.collapsedData.set(uniqueKey, {
1104
+ originalLine: line,
1105
+ content: content,
1106
+ indent: indent.length,
1107
+ nodeKey: nodeKey
1108
+ });
1109
+
1110
+ // Replace with marker
1111
+ const beforeBracket = line.substring(0, line.indexOf(openBracket));
1112
+ const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1113
+ lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1114
+
1115
+ // Remove content lines
1116
+ lines.splice(i + 1, endLine - i);
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ // Update textarea
1122
+ textarea.value = lines.join('\n');
1123
+ this.updateHighlight();
1124
+ }
1125
+
1126
+
1127
+ updateGutter() {
1128
+ const gutterContent = this.shadowRoot.getElementById('gutterContent');
1129
+ const textarea = this.shadowRoot.getElementById('textarea');
1130
+
1131
+ if (!textarea) return;
1132
+
1133
+ // Use cached computed styles (computed once, reused)
1134
+ if (this._cachedLineHeight === null) {
1135
+ const styles = getComputedStyle(textarea);
1136
+ this._cachedLineHeight = parseFloat(styles.lineHeight);
1137
+ this._cachedPaddingTop = parseFloat(styles.paddingTop);
1138
+ }
1139
+ const lineHeight = this._cachedLineHeight;
1140
+ const paddingTop = this._cachedPaddingTop;
1141
+
1142
+ // Clear gutter
1143
+ gutterContent.textContent = '';
1144
+
1145
+ // Create a map of line -> elements (color, collapse button, or both)
1146
+ const lineElements = new Map();
1147
+
1148
+ // Add color indicators
1149
+ this.colorPositions.forEach(({ line, color, attributeName }) => {
1150
+ if (!lineElements.has(line)) {
1151
+ lineElements.set(line, { colors: [], buttons: [] });
1152
+ }
1153
+ lineElements.get(line).colors.push({ color, attributeName });
1154
+ });
1155
+
1156
+ // Add collapse buttons
1157
+ this.nodeTogglePositions.forEach(({ line, nodeKey, isCollapsed }) => {
1158
+ if (!lineElements.has(line)) {
1159
+ lineElements.set(line, { colors: [], buttons: [] });
1160
+ }
1161
+ lineElements.get(line).buttons.push({ nodeKey, isCollapsed });
1162
+ });
1163
+
1164
+ // Create gutter lines with DocumentFragment (single DOM update)
1165
+ const fragment = document.createDocumentFragment();
1166
+
1167
+ lineElements.forEach((elements, line) => {
1168
+ const gutterLine = document.createElement('div');
1169
+ gutterLine.className = 'gutter-line';
1170
+ gutterLine.style.top = `${paddingTop + line * lineHeight}px`;
1171
+
1172
+ // Add color indicators
1173
+ elements.colors.forEach(({ color, attributeName }) => {
1174
+ const indicator = document.createElement('div');
1175
+ indicator.className = 'color-indicator';
1176
+ indicator.style.backgroundColor = color;
1177
+ indicator.dataset.line = line;
1178
+ indicator.dataset.color = color;
1179
+ indicator.dataset.attributeName = attributeName;
1180
+ indicator.title = `${attributeName}: ${color}`;
1181
+ gutterLine.appendChild(indicator);
1182
+ });
1183
+
1184
+ // Add collapse buttons
1185
+ elements.buttons.forEach(({ nodeKey, isCollapsed }) => {
1186
+ const button = document.createElement('div');
1187
+ button.className = 'collapse-button';
1188
+ button.textContent = isCollapsed ? '+' : '-';
1189
+ button.dataset.line = line;
1190
+ button.dataset.nodeKey = nodeKey;
1191
+ button.title = isCollapsed ? 'Expand' : 'Collapse';
1192
+ gutterLine.appendChild(button);
1193
+ });
1194
+
1195
+ fragment.appendChild(gutterLine);
1196
+ });
1197
+
1198
+ // Single DOM insertion
1199
+ gutterContent.appendChild(fragment);
1200
+ }
1201
+
1202
+ showColorPicker(indicator, line, currentColor, attributeName) {
1203
+ // Remove existing picker
1204
+ const existing = document.querySelector('.geojson-color-picker-input');
1205
+ if (existing) existing.remove();
1206
+
1207
+ // Create small color input positioned at the indicator
1208
+ const colorInput = document.createElement('input');
1209
+ colorInput.type = 'color';
1210
+ colorInput.value = currentColor;
1211
+ colorInput.className = 'geojson-color-picker-input';
1212
+
1213
+ // Get indicator position in viewport
1214
+ const rect = indicator.getBoundingClientRect();
1215
+
1216
+ colorInput.style.position = 'fixed';
1217
+ colorInput.style.left = `${rect.left}px`;
1218
+ colorInput.style.top = `${rect.top}px`;
1219
+ colorInput.style.width = '12px';
1220
+ colorInput.style.height = '12px';
1221
+ colorInput.style.opacity = '0.01';
1222
+ colorInput.style.border = 'none';
1223
+ colorInput.style.padding = '0';
1224
+ colorInput.style.zIndex = '9999';
1225
+
1226
+ colorInput.addEventListener('input', (e) => {
1227
+ // User is actively changing the color - update in real-time
1228
+ this.updateColorValue(line, e.target.value, attributeName);
1229
+ });
1230
+
1231
+ colorInput.addEventListener('change', (e) => {
1232
+ // Picker closed with validation
1233
+ this.updateColorValue(line, e.target.value, attributeName);
1234
+ });
1235
+
1236
+ // Close picker when clicking anywhere else
1237
+ const closeOnClickOutside = (e) => {
1238
+ if (e.target !== colorInput && !colorInput.contains(e.target)) {
1239
+ colorInput.remove();
1240
+ document.removeEventListener('click', closeOnClickOutside, true);
1241
+ }
1242
+ };
1243
+
1244
+ // Add to document body with fixed positioning
1245
+ document.body.appendChild(colorInput);
1246
+
1247
+ // Add click listener after a short delay to avoid immediate close
1248
+ setTimeout(() => {
1249
+ document.addEventListener('click', closeOnClickOutside, true);
1250
+ }, 100);
1251
+
1252
+ // Open the picker and focus it
1253
+ colorInput.focus();
1254
+ colorInput.click();
1255
+ }
1256
+
1257
+ updateColorValue(line, newColor, attributeName) {
1258
+ const textarea = this.shadowRoot.getElementById('textarea');
1259
+ const lines = textarea.value.split('\n');
1260
+
1261
+ // Replace color value on the specified line for the specific attribute
1262
+ const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#[0-9a-fA-F]{6}"`);
1263
+ lines[line] = lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
1264
+
1265
+ textarea.value = lines.join('\n');
1266
+ this.updateHighlight();
1267
+ this.emitChange();
1268
+ }
1269
+
1270
+ handleKeydownInCollapsedArea(e) {
1271
+ // Allow navigation keys
1272
+ const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Tab'];
1273
+ if (navigationKeys.includes(e.key)) return;
1274
+
1275
+ // Allow copy/cut/paste (handled separately)
1276
+ if (e.ctrlKey || e.metaKey) return;
1277
+
1278
+ const textarea = this.shadowRoot.getElementById('textarea');
1279
+ const cursorPos = textarea.selectionStart;
1280
+ const textBeforeCursor = textarea.value.substring(0, cursorPos);
1281
+ const currentLineNum = textBeforeCursor.split('\n').length - 1;
1282
+ const lines = textarea.value.split('\n');
1283
+ const currentLine = lines[currentLineNum];
1284
+
1285
+ // Check if current line is collapsed (contains {...} or [...])
1286
+ if (currentLine && (currentLine.includes('{...}') || currentLine.includes('[...]'))) {
1287
+ e.preventDefault();
1288
+ }
1289
+ }
1290
+
1291
+ handleCopyWithCollapsedContent(e) {
1292
+ const textarea = this.shadowRoot.getElementById('textarea');
1293
+ const start = textarea.selectionStart;
1294
+ const end = textarea.selectionEnd;
1295
+
1296
+ if (start === end) return; // No selection
1297
+
1298
+ const selectedText = textarea.value.substring(start, end);
1299
+
1300
+ // Check if selection contains collapsed content
1301
+ if (!selectedText.includes('{...}') && !selectedText.includes('[...]')) {
1302
+ return; // No collapsed content, use default copy behavior
1303
+ }
1304
+
1305
+ // Replace collapsed markers with real content
1306
+ const expandedText = this.expandCollapsedMarkersInText(selectedText, start);
1307
+
1308
+ // Put expanded text in clipboard
1309
+ e.preventDefault();
1310
+ e.clipboardData.setData('text/plain', expandedText);
1311
+ }
1312
+
1313
+ expandCollapsedMarkersInText(text, startPos) {
1314
+ const textarea = this.shadowRoot.getElementById('textarea');
1315
+ const beforeSelection = textarea.value.substring(0, startPos);
1316
+ const startLineNum = beforeSelection.split('\n').length - 1;
1317
+
1318
+ const lines = text.split('\n');
1319
+ const expandedLines = [];
1320
+
1321
+ lines.forEach((line, relativeLineNum) => {
1322
+ const absoluteLineNum = startLineNum + relativeLineNum;
1323
+
1324
+ // Check if this line has a collapsed marker
1325
+ if (line.includes('{...}') || line.includes('[...]')) {
1326
+ // Find the collapsed node for this line
1327
+ let found = false;
1328
+ this.collapsedData.forEach((collapsed, key) => {
1329
+ const collapsedLineNum = parseInt(key.split('-')[0]);
1330
+ if (collapsedLineNum === absoluteLineNum) {
1331
+ // Replace with original line and all collapsed content
1332
+ expandedLines.push(collapsed.originalLine);
1333
+ expandedLines.push(...collapsed.content);
1334
+ found = true;
1335
+ }
1336
+ });
1337
+ if (!found) {
1338
+ expandedLines.push(line);
1339
+ }
1340
+ } else {
1341
+ expandedLines.push(line);
1342
+ }
1343
+ });
1344
+
1345
+ return expandedLines.join('\n');
1346
+ }
1347
+
1348
+ handleCutWithCollapsedContent(e) {
1349
+ // First copy with expanded content
1350
+ this.handleCopyWithCollapsedContent(e);
1351
+
1352
+ // Then delete the selection normally
1353
+ const textarea = this.shadowRoot.getElementById('textarea');
1354
+ const start = textarea.selectionStart;
1355
+ const end = textarea.selectionEnd;
1356
+
1357
+ if (start !== end) {
1358
+ const value = textarea.value;
1359
+ textarea.value = value.substring(0, start) + value.substring(end);
1360
+ textarea.selectionStart = textarea.selectionEnd = start;
1361
+ this.updateHighlight();
1362
+ this.emitChange();
1363
+ }
1364
+ }
1365
+
1366
+ emitChange() {
1367
+ const textarea = this.shadowRoot.getElementById('textarea');
1368
+
1369
+ // Expand ALL collapsed nodes to get full content
1370
+ const editorContent = this.expandAllCollapsed(textarea.value);
1371
+
1372
+ // Build complete value with prefix/suffix
1373
+ const prefix = this.prefix;
1374
+ const suffix = this.suffix;
1375
+ const fullValue = prefix + editorContent + suffix;
1376
+
1377
+ // Try to parse
1378
+ try {
1379
+ const parsed = JSON.parse(fullValue);
1380
+
1381
+ // Validate GeoJSON types
1382
+ const validationErrors = this.validateGeoJSON(parsed);
1383
+
1384
+ if (validationErrors.length > 0) {
1385
+ // Emit error event for GeoJSON validation errors
1386
+ this.dispatchEvent(new CustomEvent('error', {
1387
+ detail: {
1388
+ timestamp: new Date().toISOString(),
1389
+ error: `GeoJSON validation: ${validationErrors.join('; ')}`,
1390
+ errors: validationErrors,
1391
+ content: editorContent
1392
+ },
1393
+ bubbles: true,
1394
+ composed: true
1395
+ }));
1396
+ } else {
1397
+ // Emit change event with parsed GeoJSON directly
1398
+ this.dispatchEvent(new CustomEvent('change', {
1399
+ detail: parsed,
1400
+ bubbles: true,
1401
+ composed: true
1402
+ }));
1403
+ }
1404
+ } catch (e) {
1405
+ // Emit error event for invalid JSON
1406
+ this.dispatchEvent(new CustomEvent('error', {
1407
+ detail: {
1408
+ timestamp: new Date().toISOString(),
1409
+ error: e.message,
1410
+ content: editorContent // Raw content for debugging
1411
+ },
1412
+ bubbles: true,
1413
+ composed: true
1414
+ }));
1415
+ }
1416
+ }
1417
+
1418
+ // Validate GeoJSON structure and types
1419
+ // context: 'root' | 'geometry' | 'properties'
1420
+ validateGeoJSON(obj, path = '', context = 'root') {
1421
+ const errors = [];
1422
+
1423
+ if (!obj || typeof obj !== 'object') {
1424
+ return errors;
1425
+ }
1426
+
1427
+ // Check for invalid type values based on context
1428
+ if (context !== 'properties' && obj.type !== undefined) {
1429
+ const typeValue = obj.type;
1430
+ if (typeof typeValue === 'string') {
1431
+ if (context === 'geometry') {
1432
+ // In geometry: must be a geometry type
1433
+ if (!GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(typeValue)) {
1434
+ errors.push(`Invalid geometry type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.join(', ')})`);
1435
+ }
1436
+ } else {
1437
+ // At root or in features: must be Feature or FeatureCollection
1438
+ if (!GeoJsonEditor.GEOJSON_TYPES_FEATURE.includes(typeValue)) {
1439
+ errors.push(`Invalid type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON_TYPES_FEATURE.join(', ')})`);
1440
+ }
1441
+ }
1442
+ }
1443
+ }
1444
+
1445
+ // Recursively validate nested objects
1446
+ if (Array.isArray(obj)) {
1447
+ obj.forEach((item, index) => {
1448
+ errors.push(...this.validateGeoJSON(item, `${path}[${index}]`, context));
1449
+ });
1450
+ } else {
1451
+ for (const [key, value] of Object.entries(obj)) {
1452
+ if (typeof value === 'object' && value !== null) {
1453
+ const newPath = path ? `${path}.${key}` : key;
1454
+ // Determine context for nested objects
1455
+ let newContext = context;
1456
+ if (key === 'properties') {
1457
+ newContext = 'properties';
1458
+ } else if (key === 'geometry' || key === 'geometries') {
1459
+ newContext = 'geometry';
1460
+ } else if (key === 'features') {
1461
+ newContext = 'root'; // features contains Feature objects
1462
+ }
1463
+ errors.push(...this.validateGeoJSON(value, newPath, newContext));
1464
+ }
1465
+ }
1466
+ }
1467
+
1468
+ return errors;
1469
+ }
1470
+
1471
+ // Helper: Check if bracket closes on same line
1472
+ bracketClosesOnSameLine(line, openBracket) {
1473
+ const closeBracket = openBracket === '{' ? '}' : ']';
1474
+ const bracketPos = line.indexOf(openBracket);
1475
+ if (bracketPos === -1) return false;
1476
+ const restOfLine = line.substring(bracketPos + 1);
1477
+ let depth = 1;
1478
+ for (const char of restOfLine) {
1479
+ if (char === openBracket) depth++;
1480
+ if (char === closeBracket) depth--;
1481
+ if (depth === 0) return true;
1482
+ }
1483
+ return false;
1484
+ }
1485
+
1486
+ // Helper: Expand all collapsed markers and return expanded content
1487
+ expandAllCollapsed(content) {
1488
+ const R = GeoJsonEditor.REGEX;
1489
+
1490
+ while (content.includes('{...}') || content.includes('[...]')) {
1491
+ const lines = content.split('\n');
1492
+ let expanded = false;
1493
+
1494
+ for (let i = 0; i < lines.length; i++) {
1495
+ const line = lines[i];
1496
+ if (!line.includes('{...}') && !line.includes('[...]')) continue;
1497
+
1498
+ const match = line.match(R.collapsedMarker);
1499
+ if (!match) continue;
1500
+
1501
+ const nodeKey = match[2];
1502
+ const currentIndent = match[1].length;
1503
+ const exactKey = `${i}-${nodeKey}`;
1504
+
1505
+ let foundKey = this.collapsedData.has(exactKey) ? exactKey : null;
1506
+ if (!foundKey) {
1507
+ for (const [key, data] of this.collapsedData.entries()) {
1508
+ if (data.nodeKey === nodeKey && data.indent === currentIndent) {
1509
+ foundKey = key;
1510
+ break;
1511
+ }
1512
+ }
1513
+ }
1514
+
1515
+ if (foundKey) {
1516
+ const {originalLine, content: nodeContent} = this.collapsedData.get(foundKey);
1517
+ lines[i] = originalLine;
1518
+ lines.splice(i + 1, 0, ...nodeContent);
1519
+ expanded = true;
1520
+ break;
1521
+ }
1522
+ }
1523
+
1524
+ if (!expanded) break;
1525
+ content = lines.join('\n');
1526
+ }
1527
+ return content;
1528
+ }
1529
+
1530
+ // Helper: Format JSON content respecting prefix/suffix
1531
+ formatJSONContent(content) {
1532
+ const prefix = this.prefix;
1533
+ const suffix = this.suffix;
1534
+ const prefixEndsWithBracket = prefix.trimEnd().endsWith('[');
1535
+ const suffixStartsWithBracket = suffix.trimStart().startsWith(']');
1536
+
1537
+ if (prefixEndsWithBracket && suffixStartsWithBracket) {
1538
+ const wrapped = '[' + content + ']';
1539
+ const parsed = JSON.parse(wrapped);
1540
+ const formatted = JSON.stringify(parsed, null, 2);
1541
+ const lines = formatted.split('\n');
1542
+ return lines.length > 2 ? lines.slice(1, -1).join('\n') : '';
1543
+ } else if (!prefix && !suffix) {
1544
+ const parsed = JSON.parse(content);
1545
+ return JSON.stringify(parsed, null, 2);
1546
+ } else {
1547
+ const fullValue = prefix + content + suffix;
1548
+ JSON.parse(fullValue); // Validate only
1549
+ return content;
1550
+ }
1551
+ }
1552
+
1553
+ autoFormatContentWithCursor() {
1554
+ const textarea = this.shadowRoot.getElementById('textarea');
1555
+
1556
+ // Save cursor position
1557
+ const cursorPos = textarea.selectionStart;
1558
+ const textBeforeCursor = textarea.value.substring(0, cursorPos);
1559
+ const linesBeforeCursor = textBeforeCursor.split('\n');
1560
+ const cursorLine = linesBeforeCursor.length - 1;
1561
+ const cursorColumn = linesBeforeCursor[linesBeforeCursor.length - 1].length;
1562
+
1563
+ // Save collapsed node details
1564
+ const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
1565
+ nodeKey: data.nodeKey,
1566
+ indent: data.indent
1567
+ }));
1568
+
1569
+ // Expand and format
1570
+ const content = this.expandAllCollapsed(textarea.value);
1571
+
1572
+ try {
1573
+ const formattedContent = this.formatJSONContent(content);
1574
+
1575
+ if (formattedContent !== content) {
1576
+ this.collapsedData.clear();
1577
+ textarea.value = formattedContent;
1578
+
1579
+ if (collapsedNodes.length > 0) {
1580
+ this.reapplyCollapsed(collapsedNodes);
1581
+ }
1582
+
1583
+ // Restore cursor position
1584
+ const newLines = textarea.value.split('\n');
1585
+ if (cursorLine < newLines.length) {
1586
+ const newColumn = Math.min(cursorColumn, newLines[cursorLine].length);
1587
+ let newPos = 0;
1588
+ for (let i = 0; i < cursorLine; i++) {
1589
+ newPos += newLines[i].length + 1;
1590
+ }
1591
+ newPos += newColumn;
1592
+ textarea.setSelectionRange(newPos, newPos);
1593
+ }
1594
+ }
1595
+ } catch (e) {
1596
+ // Invalid JSON, don't format
1597
+ }
1598
+ }
1599
+
1600
+ autoFormatContent() {
1601
+ const textarea = this.shadowRoot.getElementById('textarea');
1602
+
1603
+ // Save collapsed node details
1604
+ const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
1605
+ nodeKey: data.nodeKey,
1606
+ indent: data.indent
1607
+ }));
1608
+
1609
+ // Expand and format
1610
+ const content = this.expandAllCollapsed(textarea.value);
1611
+
1612
+ try {
1613
+ const formattedContent = this.formatJSONContent(content);
1614
+
1615
+ if (formattedContent !== content) {
1616
+ this.collapsedData.clear();
1617
+ textarea.value = formattedContent;
1618
+
1619
+ if (collapsedNodes.length > 0) {
1620
+ this.reapplyCollapsed(collapsedNodes);
1621
+ }
1622
+ }
1623
+ } catch (e) {
1624
+ // Invalid JSON, don't format
1625
+ }
1626
+ }
1627
+
1628
+ reapplyCollapsed(collapsedNodes) {
1629
+ const textarea = this.shadowRoot.getElementById('textarea');
1630
+ const lines = textarea.value.split('\n');
1631
+
1632
+ // Group collapsed nodes by nodeKey+indent and count occurrences
1633
+ const collapseMap = new Map();
1634
+ collapsedNodes.forEach(({nodeKey, indent}) => {
1635
+ const key = `${nodeKey}-${indent}`;
1636
+ collapseMap.set(key, (collapseMap.get(key) || 0) + 1);
1637
+ });
1638
+
1639
+ // Track occurrences as we iterate
1640
+ const occurrenceCount = new Map();
1641
+
1642
+ // Iterate backwards to avoid index issues
1643
+ for (let i = lines.length - 1; i >= 0; i--) {
1644
+ const line = lines[i];
1645
+ const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
1646
+
1647
+ if (match) {
1648
+ const nodeKey = match[2];
1649
+ const currentIndent = match[1].length;
1650
+ const key = `${nodeKey}-${currentIndent}`;
1651
+
1652
+ if (collapseMap.has(key)) {
1653
+ // Count this occurrence
1654
+ occurrenceCount.set(key, (occurrenceCount.get(key) || 0) + 1);
1655
+ const currentOccurrence = occurrenceCount.get(key);
1656
+
1657
+ // Only collapse if this occurrence should be collapsed
1658
+ if (currentOccurrence <= collapseMap.get(key)) {
1659
+ const indent = match[1];
1660
+ const openBracket = match[3];
1661
+ const closeBracket = openBracket === '{' ? '}' : ']';
1662
+
1663
+ // Skip if closes on same line
1664
+ if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1665
+
1666
+ // Find closing bracket
1667
+ let depth = 1;
1668
+ let endLine = i;
1669
+ const content = [];
1670
+
1671
+ for (let j = i + 1; j < lines.length; j++) {
1672
+ const scanLine = lines[j];
1673
+
1674
+ for (const char of scanLine) {
1675
+ if (char === openBracket) depth++;
1676
+ if (char === closeBracket) depth--;
1677
+ }
1678
+
1679
+ content.push(scanLine);
1680
+
1681
+ if (depth === 0) {
1682
+ endLine = j;
1683
+ break;
1684
+ }
1685
+ }
1686
+
1687
+ // Store with unique key
1688
+ const uniqueKey = `${i}-${nodeKey}`;
1689
+ this.collapsedData.set(uniqueKey, {
1690
+ originalLine: line,
1691
+ content: content,
1692
+ indent: indent.length,
1693
+ nodeKey: nodeKey
1694
+ });
1695
+
1696
+ // Replace with marker
1697
+ const beforeBracket = line.substring(0, line.indexOf(openBracket));
1698
+ const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1699
+ lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1700
+
1701
+ // Remove content lines
1702
+ lines.splice(i + 1, endLine - i);
1703
+ }
1704
+ }
1705
+ }
1706
+ }
1707
+
1708
+ textarea.value = lines.join('\n');
1709
+ }
1710
+
1711
+
1712
+ // Parse selector and generate CSS rule for dark theme
1713
+ parseSelectorToHostRule(selector) {
1714
+ if (!selector || selector === '') {
1715
+ // Fallback: use data attribute on host element
1716
+ return ':host([data-color-scheme="dark"])';
1717
+ }
1718
+
1719
+ // Check if it's a simple class on host (.dark)
1720
+ if (selector.startsWith('.') && !selector.includes(' ')) {
1721
+ return `:host(${selector})`;
1722
+ }
1723
+
1724
+ // Complex selector - use :host-context for parent elements
1725
+ return `:host-context(${selector})`;
1726
+ }
1727
+
1728
+ // Generate and inject theme CSS based on dark selector
1729
+ updateThemeCSS() {
1730
+ const darkSelector = this.getAttribute('dark-selector') || '.dark';
1731
+
1732
+ // Parse selector to create CSS rule for dark theme
1733
+ const darkRule = this.parseSelectorToHostRule(darkSelector);
1734
+ // Light theme is the default (no selector = light)
1735
+ const lightRule = ':host';
1736
+
1737
+ // Find or create theme style element
1738
+ let themeStyle = this.shadowRoot.getElementById('theme-styles');
1739
+ if (!themeStyle) {
1740
+ themeStyle = document.createElement('style');
1741
+ themeStyle.id = 'theme-styles';
1742
+ // Insert at the beginning of shadow root to ensure it's before static styles
1743
+ this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
1744
+ }
1745
+
1746
+ // Generate CSS with theme variables (light first as default, then dark overrides)
1747
+ const css = `
1748
+ ${lightRule} {
1749
+ --bg-color: ${this.themes.light.background};
1750
+ --text-color: ${this.themes.light.textColor};
1751
+ --caret-color: ${this.themes.light.caretColor};
1752
+ --gutter-bg: ${this.themes.light.gutterBackground};
1753
+ --gutter-border: ${this.themes.light.gutterBorder};
1754
+ --json-key: ${this.themes.light.jsonKey};
1755
+ --json-string: ${this.themes.light.jsonString};
1756
+ --json-number: ${this.themes.light.jsonNumber};
1757
+ --json-boolean: ${this.themes.light.jsonBoolean};
1758
+ --json-null: ${this.themes.light.jsonNull};
1759
+ --json-punct: ${this.themes.light.jsonPunctuation};
1760
+ --collapse-btn: ${this.themes.light.collapseButton};
1761
+ --collapse-btn-bg: ${this.themes.light.collapseButtonBg};
1762
+ --collapse-btn-border: ${this.themes.light.collapseButtonBorder};
1763
+ --geojson-key: ${this.themes.light.geojsonKey};
1764
+ --geojson-type: ${this.themes.light.geojsonType};
1765
+ --geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};
1766
+ --json-key-invalid: ${this.themes.light.jsonKeyInvalid};
1767
+ }
1768
+
1769
+ ${darkRule} {
1770
+ --bg-color: ${this.themes.dark.background};
1771
+ --text-color: ${this.themes.dark.textColor};
1772
+ --caret-color: ${this.themes.dark.caretColor};
1773
+ --gutter-bg: ${this.themes.dark.gutterBackground};
1774
+ --gutter-border: ${this.themes.dark.gutterBorder};
1775
+ --json-key: ${this.themes.dark.jsonKey};
1776
+ --json-string: ${this.themes.dark.jsonString};
1777
+ --json-number: ${this.themes.dark.jsonNumber};
1778
+ --json-boolean: ${this.themes.dark.jsonBoolean};
1779
+ --json-null: ${this.themes.dark.jsonNull};
1780
+ --json-punct: ${this.themes.dark.jsonPunctuation};
1781
+ --collapse-btn: ${this.themes.dark.collapseButton};
1782
+ --collapse-btn-bg: ${this.themes.dark.collapseButtonBg};
1783
+ --collapse-btn-border: ${this.themes.dark.collapseButtonBorder};
1784
+ --geojson-key: ${this.themes.dark.geojsonKey};
1785
+ --geojson-type: ${this.themes.dark.geojsonType};
1786
+ --geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};
1787
+ --json-key-invalid: ${this.themes.dark.jsonKeyInvalid};
1788
+ }
1789
+ `;
1790
+
1791
+ themeStyle.textContent = css;
1792
+ }
1793
+
1794
+ // Public API: Theme management
1795
+ getTheme() {
1796
+ return {
1797
+ dark: { ...this.themes.dark },
1798
+ light: { ...this.themes.light }
1799
+ };
1800
+ }
1801
+
1802
+ setTheme(theme) {
1803
+ if (theme.dark) {
1804
+ this.themes.dark = { ...this.themes.dark, ...theme.dark };
1805
+ }
1806
+ if (theme.light) {
1807
+ this.themes.light = { ...this.themes.light, ...theme.light };
1808
+ }
1809
+
1810
+ // Regenerate CSS with new theme values
1811
+ this.updateThemeCSS();
1812
+ }
1813
+
1814
+ resetTheme() {
1815
+ // Reset to defaults
1816
+ this.themes = {
1817
+ dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
1818
+ light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
1819
+ };
1820
+ this.updateThemeCSS();
1821
+ }
1822
+ }
1823
+
1824
+ // Register the custom element
1825
+ customElements.define('geojson-editor', GeoJsonEditor);