@softwarity/geojson-editor 1.0.21 → 1.0.23

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.
@@ -42,7 +42,7 @@ import {
42
42
  RE_CLOSE_BRACKET
43
43
  } from './constants.js';
44
44
 
45
- import { createElement, getFeatureKey, countBrackets, parseSelectorToHostRule } from './utils.js';
45
+ import { createElement, countBrackets, parseSelectorToHostRule } from './utils.js';
46
46
  import { validateGeoJSON, normalizeToFeatures } from './validation.js';
47
47
  import { highlightSyntax, namedColorToHex, isNamedColor } from './syntax-highlighter.js';
48
48
 
@@ -60,7 +60,7 @@ class GeoJsonEditor extends HTMLElement {
60
60
  // ========== Model (Source of Truth) ==========
61
61
  lines: string[] = [];
62
62
  collapsedNodes: Set<string> = new Set();
63
- hiddenFeatures: Set<string> = new Set();
63
+ hiddenFeatures: Set<number> = new Set(); // Feature indices that are hidden
64
64
 
65
65
  // ========== Node ID Management ==========
66
66
  private _nodeIdCounter: number = 0;
@@ -71,7 +71,7 @@ class GeoJsonEditor extends HTMLElement {
71
71
  // ========== Derived State (computed from model) ==========
72
72
  visibleLines: VisibleLine[] = [];
73
73
  lineMetadata: Map<number, LineMeta> = new Map();
74
- featureRanges: Map<string, FeatureRange> = new Map();
74
+ featureRanges: Map<number, FeatureRange> = new Map(); // featureIndex -> range
75
75
 
76
76
  // ========== View State ==========
77
77
  viewportHeight: number = 0;
@@ -621,34 +621,75 @@ class GeoJsonEditor extends HTMLElement {
621
621
  this.scheduleRender();
622
622
  });
623
623
 
624
- // Mouse move for drag selection
625
- viewport.addEventListener('mousemove', (e: MouseEvent) => {
624
+ // Auto-scroll interval for drag selection outside editor
625
+ let autoScrollInterval: ReturnType<typeof setInterval> | null = null;
626
+
627
+ const stopAutoScroll = () => {
628
+ if (autoScrollInterval) {
629
+ clearInterval(autoScrollInterval);
630
+ autoScrollInterval = null;
631
+ }
632
+ };
633
+
634
+ const startAutoScroll = (direction: 'up' | 'down') => {
635
+ stopAutoScroll();
636
+ const scrollSpeed = 20;
637
+ autoScrollInterval = setInterval(() => {
638
+ if (!this._isSelecting) {
639
+ stopAutoScroll();
640
+ return;
641
+ }
642
+ if (direction === 'up') {
643
+ viewport.scrollTop -= scrollSpeed;
644
+ } else {
645
+ viewport.scrollTop += scrollSpeed;
646
+ }
647
+ // Update selection based on scroll position
648
+ this._updateSelectionFromScroll(direction);
649
+ this._invalidateRenderCache();
650
+ this.scheduleRender();
651
+ }, 50);
652
+ };
653
+
654
+ // Mouse move for drag selection - listen on document to handle drag outside editor
655
+ document.addEventListener('mousemove', (e: MouseEvent) => {
626
656
  if (!this._isSelecting) return;
627
- const pos = this._getPositionFromClick(e);
628
- this.selectionEnd = pos;
629
- this.cursorLine = pos.line;
630
- this.cursorColumn = pos.column;
631
657
 
632
- // Auto-scroll when near edges
633
658
  const rect = viewport.getBoundingClientRect();
634
- const scrollMargin = 30; // pixels from edge to start scrolling
635
- const scrollSpeed = 20; // pixels to scroll per frame
659
+ const scrollMargin = 30;
660
+ const scrollSpeed = 20;
661
+
662
+ // Check if mouse is outside the viewport
663
+ if (e.clientY < rect.top) {
664
+ // Mouse above viewport - start continuous scroll up
665
+ startAutoScroll('up');
666
+ } else if (e.clientY > rect.bottom) {
667
+ // Mouse below viewport - start continuous scroll down
668
+ startAutoScroll('down');
669
+ } else {
670
+ // Mouse inside viewport - stop auto-scroll and update normally
671
+ stopAutoScroll();
672
+ const pos = this._getPositionFromClick(e);
673
+ this.selectionEnd = pos;
674
+ this.cursorLine = pos.line;
675
+ this.cursorColumn = pos.column;
636
676
 
637
- if (e.clientY < rect.top + scrollMargin) {
638
- // Near top edge, scroll up
639
- viewport.scrollTop -= scrollSpeed;
640
- } else if (e.clientY > rect.bottom - scrollMargin) {
641
- // Near bottom edge, scroll down
642
- viewport.scrollTop += scrollSpeed;
643
- }
677
+ // Auto-scroll when near edges (inside viewport)
678
+ if (e.clientY < rect.top + scrollMargin) {
679
+ viewport.scrollTop -= scrollSpeed;
680
+ } else if (e.clientY > rect.bottom - scrollMargin) {
681
+ viewport.scrollTop += scrollSpeed;
682
+ }
644
683
 
645
- this._invalidateRenderCache();
646
- this.scheduleRender();
684
+ this._invalidateRenderCache();
685
+ this.scheduleRender();
686
+ }
647
687
  });
648
-
688
+
649
689
  // Mouse up to end selection
650
690
  document.addEventListener('mouseup', () => {
651
691
  this._isSelecting = false;
692
+ stopAutoScroll();
652
693
  });
653
694
 
654
695
  // Focus/blur handling to show/hide cursor
@@ -739,6 +780,20 @@ class GeoJsonEditor extends HTMLElement {
739
780
  this.removeAll();
740
781
  });
741
782
 
783
+ // Info button - toggle popup
784
+ const infoBtn = this._id('infoBtn');
785
+ const infoPopup = this._id('infoPopup');
786
+ if (infoBtn && infoPopup) {
787
+ infoBtn.addEventListener('click', (e: MouseEvent) => {
788
+ e.stopPropagation();
789
+ infoPopup.classList.toggle('visible');
790
+ });
791
+ // Close popup when clicking outside
792
+ document.addEventListener('click', () => {
793
+ infoPopup.classList.remove('visible');
794
+ });
795
+ }
796
+
742
797
  // Error navigation buttons
743
798
  this._prevErrorBtn?.addEventListener('click', () => {
744
799
  this.goToPrevError();
@@ -840,23 +895,22 @@ class GeoJsonEditor extends HTMLElement {
840
895
  */
841
896
  computeFeatureRanges() {
842
897
  this.featureRanges.clear();
843
-
898
+
844
899
  try {
845
900
  const content = this.lines.join('\n');
846
901
  const fullValue = this.prefix + content + this.suffix;
847
902
  const parsed = JSON.parse(fullValue);
848
-
903
+
849
904
  if (!parsed.features) return;
850
-
905
+
851
906
  let featureIndex = 0;
852
907
  let braceDepth = 0;
853
908
  let inFeature = false;
854
909
  let featureStartLine = -1;
855
- let currentFeatureKey = null;
856
-
910
+
857
911
  for (let i = 0; i < this.lines.length; i++) {
858
912
  const line = this.lines[i];
859
-
913
+
860
914
  if (!inFeature && RE_IS_FEATURE.test(line)) {
861
915
  // Find opening brace
862
916
  let startLine = i;
@@ -870,7 +924,7 @@ class GeoJsonEditor extends HTMLElement {
870
924
  featureStartLine = startLine;
871
925
  inFeature = true;
872
926
  braceDepth = 1;
873
-
927
+
874
928
  // Count braces from start to current line
875
929
  for (let k = startLine; k <= i; k++) {
876
930
  const counts = countBrackets(this.lines[k], '{');
@@ -880,25 +934,19 @@ class GeoJsonEditor extends HTMLElement {
880
934
  braceDepth += counts.open - counts.close;
881
935
  }
882
936
  }
883
-
884
- if (featureIndex < parsed.features.length) {
885
- currentFeatureKey = getFeatureKey(parsed.features[featureIndex]);
886
- }
887
937
  } else if (inFeature) {
888
938
  const counts = countBrackets(line, '{');
889
939
  braceDepth += counts.open - counts.close;
890
-
940
+
891
941
  if (braceDepth <= 0) {
892
- if (currentFeatureKey) {
893
- this.featureRanges.set(currentFeatureKey, {
894
- startLine: featureStartLine,
895
- endLine: i,
896
- featureIndex
897
- });
898
- }
942
+ // Store by featureIndex instead of hash key
943
+ this.featureRanges.set(featureIndex, {
944
+ startLine: featureStartLine,
945
+ endLine: i,
946
+ featureIndex
947
+ });
899
948
  featureIndex++;
900
949
  inFeature = false;
901
- currentFeatureKey = null;
902
950
  }
903
951
  }
904
952
  }
@@ -927,7 +975,7 @@ class GeoJsonEditor extends HTMLElement {
927
975
  visibilityButton: null,
928
976
  isHidden: false,
929
977
  isCollapsed: false,
930
- featureKey: null,
978
+ featureIndex: null,
931
979
  hasError: errorLines.has(i)
932
980
  };
933
981
 
@@ -970,17 +1018,17 @@ class GeoJsonEditor extends HTMLElement {
970
1018
  }
971
1019
 
972
1020
  // Check if line belongs to a hidden feature
973
- for (const [featureKey, range] of this.featureRanges) {
1021
+ for (const [featureIndex, range] of this.featureRanges) {
974
1022
  if (i >= range.startLine && i <= range.endLine) {
975
- meta.featureKey = featureKey;
976
- if (this.hiddenFeatures.has(featureKey)) {
1023
+ meta.featureIndex = featureIndex;
1024
+ if (this.hiddenFeatures.has(featureIndex)) {
977
1025
  meta.isHidden = true;
978
1026
  }
979
1027
  // Add visibility button only on feature start line
980
1028
  if (i === range.startLine) {
981
1029
  meta.visibilityButton = {
982
- featureKey,
983
- isHidden: this.hiddenFeatures.has(featureKey)
1030
+ featureIndex,
1031
+ isHidden: this.hiddenFeatures.has(featureIndex)
984
1032
  };
985
1033
  }
986
1034
  break;
@@ -1244,7 +1292,7 @@ class GeoJsonEditor extends HTMLElement {
1244
1292
  // Add visibility button on line (uses ::before pseudo-element)
1245
1293
  if (lineData.meta?.visibilityButton) {
1246
1294
  lineEl.classList.add('has-visibility');
1247
- lineEl.dataset.featureKey = lineData.meta.visibilityButton.featureKey;
1295
+ lineEl.dataset.featureIndex = String(lineData.meta.visibilityButton.featureIndex);
1248
1296
  if (lineData.meta.visibilityButton.isHidden) {
1249
1297
  lineEl.classList.add('feature-hidden');
1250
1298
  }
@@ -1529,8 +1577,8 @@ class GeoJsonEditor extends HTMLElement {
1529
1577
  'ArrowDown': () => this._handleArrowKey(1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
1530
1578
  'ArrowLeft': () => this._handleArrowKey(0, -1, e.shiftKey, e.ctrlKey || e.metaKey),
1531
1579
  'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1532
- 'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1533
- 'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1580
+ 'Home': () => this._handleHomeEnd('home', e.shiftKey, e.ctrlKey || e.metaKey, ctx.onClosingLine),
1581
+ 'End': () => this._handleHomeEnd('end', e.shiftKey, e.ctrlKey || e.metaKey, ctx.onClosingLine),
1534
1582
  'PageUp': () => this._handlePageUpDown('up', e.shiftKey),
1535
1583
  'PageDown': () => this._handlePageUpDown('down', e.shiftKey),
1536
1584
  'Tab': () => this._handleTab(e.shiftKey, ctx),
@@ -2366,34 +2414,38 @@ class GeoJsonEditor extends HTMLElement {
2366
2414
 
2367
2415
  /**
2368
2416
  * Handle Home/End with optional selection
2417
+ * @param key - 'home' or 'end'
2418
+ * @param isShift - Shift key pressed (for selection)
2419
+ * @param isCtrl - Ctrl/Cmd key pressed (for document start/end)
2420
+ * @param onClosingLine - Collapsed node info if on closing line
2369
2421
  */
2370
- private _handleHomeEnd(key: string, isShift: boolean, onClosingLine: CollapsedNodeInfo | null): void {
2422
+ private _handleHomeEnd(key: string, isShift: boolean, isCtrl: boolean, onClosingLine: CollapsedNodeInfo | null): void {
2371
2423
  // Start selection if shift is pressed and no selection exists
2372
2424
  if (isShift && !this.selectionStart) {
2373
2425
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
2374
2426
  }
2375
2427
 
2376
2428
  if (key === 'home') {
2377
- if (onClosingLine) {
2429
+ if (isCtrl) {
2430
+ // Ctrl+Home: go to start of document
2431
+ this.cursorLine = 0;
2432
+ this.cursorColumn = 0;
2433
+ } else if (onClosingLine) {
2378
2434
  // On closing line of collapsed node: go to start line
2379
2435
  this.cursorLine = onClosingLine.startLine;
2380
2436
  this.cursorColumn = 0;
2381
- } else if (this.cursorColumn === 0) {
2382
- // Already at start of line: go to start of document
2383
- this.cursorLine = 0;
2384
- this.cursorColumn = 0;
2385
2437
  } else {
2386
2438
  // Go to start of line
2387
2439
  this.cursorColumn = 0;
2388
2440
  }
2389
2441
  } else {
2390
- const lineLength = this.lines[this.cursorLine]?.length || 0;
2391
- if (this.cursorColumn === lineLength) {
2392
- // Already at end of line: go to end of document
2442
+ if (isCtrl) {
2443
+ // Ctrl+End: go to end of document
2393
2444
  this.cursorLine = this.lines.length - 1;
2394
2445
  this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
2395
2446
  } else {
2396
2447
  // Go to end of line
2448
+ const lineLength = this.lines[this.cursorLine]?.length || 0;
2397
2449
  this.cursorColumn = lineLength;
2398
2450
  }
2399
2451
  }
@@ -2663,9 +2715,25 @@ class GeoJsonEditor extends HTMLElement {
2663
2715
  this.cursorLine = textLines.length - 1;
2664
2716
  this.cursorColumn = textLines[textLines.length - 1].length;
2665
2717
  } else if (this.cursorLine < this.lines.length) {
2718
+ const textLines = text.split('\n');
2666
2719
  const line = this.lines[this.cursorLine];
2667
- this.lines[this.cursorLine] = line.substring(0, this.cursorColumn) + text + line.substring(this.cursorColumn);
2668
- this.cursorColumn += text.length;
2720
+ const before = line.substring(0, this.cursorColumn);
2721
+ const after = line.substring(this.cursorColumn);
2722
+
2723
+ if (textLines.length === 1) {
2724
+ // Single line insertion
2725
+ this.lines[this.cursorLine] = before + text + after;
2726
+ this.cursorColumn += text.length;
2727
+ } else {
2728
+ // Multi-line insertion
2729
+ const firstLine = before + textLines[0];
2730
+ const lastLine = textLines[textLines.length - 1] + after;
2731
+ const middleLines = textLines.slice(1, -1);
2732
+
2733
+ this.lines.splice(this.cursorLine, 1, firstLine, ...middleLines, lastLine);
2734
+ this.cursorLine += textLines.length - 1;
2735
+ this.cursorColumn = textLines[textLines.length - 1].length;
2736
+ }
2669
2737
  }
2670
2738
  this.formatAndUpdate();
2671
2739
  }
@@ -2675,12 +2743,23 @@ class GeoJsonEditor extends HTMLElement {
2675
2743
  const text = e.clipboardData?.getData('text/plain');
2676
2744
  if (!text) return;
2677
2745
 
2678
- const wasEmpty = this.lines.length === 0;
2746
+ // Save collapsed state of existing features before paste
2747
+ const existingCollapsedKeys = new Set<string>();
2748
+ for (const nodeId of this.collapsedNodes) {
2749
+ const nodeInfo = this._nodeIdToLines.get(nodeId);
2750
+ if (nodeInfo?.nodeKey) {
2751
+ const featureIndex = this._getFeatureIndexForLine(nodeInfo.startLine);
2752
+ existingCollapsedKeys.add(`${featureIndex}:${nodeInfo.nodeKey}`);
2753
+ }
2754
+ }
2755
+ const existingFeatureCount = this._parseFeatures().length;
2679
2756
 
2680
2757
  // Try to parse as GeoJSON and normalize
2758
+ let pastedFeatureCount = 0;
2681
2759
  try {
2682
2760
  const parsed = JSON.parse(text);
2683
2761
  const features = normalizeToFeatures(parsed);
2762
+ pastedFeatureCount = features.length;
2684
2763
  // Valid GeoJSON - insert formatted features
2685
2764
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2686
2765
  this.insertText(formatted);
@@ -2695,9 +2774,35 @@ class GeoJsonEditor extends HTMLElement {
2695
2774
  this.renderTimer = undefined;
2696
2775
  }
2697
2776
 
2698
- // Auto-collapse coordinates after pasting into empty editor
2699
- if (wasEmpty && this.lines.length > 0) {
2700
- this.autoCollapseCoordinates();
2777
+ // Auto-collapse coordinates for pasted features (if valid GeoJSON was pasted)
2778
+ // Note: We collapse even if there are errors (e.g., missing comma) because
2779
+ // the user will fix them and we want the coordinates already collapsed
2780
+ if (pastedFeatureCount > 0) {
2781
+ // Restore collapsed state for existing features and collapse new features' coordinates
2782
+ const ranges = this._findCollapsibleRanges();
2783
+ const featureRanges = ranges.filter(r => r.isRootFeature);
2784
+
2785
+ for (const range of ranges) {
2786
+ // Find which feature this range belongs to
2787
+ const featureIndex = featureRanges.findIndex(fr =>
2788
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
2789
+ );
2790
+
2791
+ if (featureIndex < existingFeatureCount) {
2792
+ // Existing feature - restore collapsed state
2793
+ const key = `${featureIndex}:${range.nodeKey}`;
2794
+ if (existingCollapsedKeys.has(key)) {
2795
+ this.collapsedNodes.add(range.nodeId);
2796
+ }
2797
+ } else {
2798
+ // New feature - collapse coordinates
2799
+ if (range.nodeKey === 'coordinates') {
2800
+ this.collapsedNodes.add(range.nodeId);
2801
+ }
2802
+ }
2803
+ }
2804
+
2805
+ this.updateView();
2701
2806
  }
2702
2807
 
2703
2808
  // Expand any collapsed nodes that contain errors
@@ -2782,6 +2887,28 @@ class GeoJsonEditor extends HTMLElement {
2782
2887
  return { line, column };
2783
2888
  }
2784
2889
 
2890
+ /**
2891
+ * Update selection during auto-scroll when dragging outside editor
2892
+ */
2893
+ private _updateSelectionFromScroll(direction: 'up' | 'down'): void {
2894
+ if (!this.visibleLines.length) return;
2895
+
2896
+ if (direction === 'up') {
2897
+ // Scrolling up - select to first visible line
2898
+ const firstVisible = this.visibleLines[0];
2899
+ this.selectionEnd = { line: firstVisible.index, column: 0 };
2900
+ this.cursorLine = firstVisible.index;
2901
+ this.cursorColumn = 0;
2902
+ } else {
2903
+ // Scrolling down - select to last visible line
2904
+ const lastVisible = this.visibleLines[this.visibleLines.length - 1];
2905
+ const lineLength = lastVisible.content?.length || 0;
2906
+ this.selectionEnd = { line: lastVisible.index, column: lineLength };
2907
+ this.cursorLine = lastVisible.index;
2908
+ this.cursorColumn = lineLength;
2909
+ }
2910
+ }
2911
+
2785
2912
  // ========== Gutter Interactions ==========
2786
2913
 
2787
2914
  handleGutterClick(e: MouseEvent): void {
@@ -2790,8 +2917,8 @@ class GeoJsonEditor extends HTMLElement {
2790
2917
 
2791
2918
  // Visibility button in gutter
2792
2919
  const visBtn = target.closest('.visibility-button') as HTMLElement | null;
2793
- if (visBtn) {
2794
- this.toggleFeatureVisibility(visBtn.dataset.featureKey);
2920
+ if (visBtn && visBtn.dataset.featureIndex !== undefined) {
2921
+ this.toggleFeatureVisibility(parseInt(visBtn.dataset.featureIndex, 10));
2795
2922
  return;
2796
2923
  }
2797
2924
 
@@ -2818,9 +2945,9 @@ class GeoJsonEditor extends HTMLElement {
2818
2945
  if (clickX < 14) {
2819
2946
  e.preventDefault();
2820
2947
  e.stopPropagation();
2821
- const featureKey = lineEl.dataset.featureKey;
2822
- if (featureKey) {
2823
- this.toggleFeatureVisibility(featureKey);
2948
+ const featureIndexStr = lineEl.dataset.featureIndex;
2949
+ if (featureIndexStr !== undefined) {
2950
+ this.toggleFeatureVisibility(parseInt(featureIndexStr, 10));
2824
2951
  }
2825
2952
  return;
2826
2953
  }
@@ -2993,12 +3120,12 @@ class GeoJsonEditor extends HTMLElement {
2993
3120
 
2994
3121
  // ========== Feature Visibility ==========
2995
3122
 
2996
- toggleFeatureVisibility(featureKey: string | undefined): void {
2997
- if (!featureKey) return;
2998
- if (this.hiddenFeatures.has(featureKey)) {
2999
- this.hiddenFeatures.delete(featureKey);
3123
+ toggleFeatureVisibility(featureIndex: number | undefined): void {
3124
+ if (featureIndex === undefined) return;
3125
+ if (this.hiddenFeatures.has(featureIndex)) {
3126
+ this.hiddenFeatures.delete(featureIndex);
3000
3127
  } else {
3001
- this.hiddenFeatures.add(featureKey);
3128
+ this.hiddenFeatures.add(featureIndex);
3002
3129
  }
3003
3130
 
3004
3131
  // Use updateView - content didn't change, just visibility
@@ -3254,6 +3381,10 @@ class GeoJsonEditor extends HTMLElement {
3254
3381
  const oldCursorColumn = this.cursorColumn;
3255
3382
  const oldContent = this.lines.join('\n');
3256
3383
 
3384
+ // Save feature count before modification (for index adjustment)
3385
+ const oldFeatureCount = this._countFeatures(oldContent);
3386
+ const cursorFeatureIndex = this._getFeatureIndexForLine(oldCursorLine);
3387
+
3257
3388
  try {
3258
3389
  const wrapped = '[' + oldContent + ']';
3259
3390
  const parsed = JSON.parse(wrapped);
@@ -3299,23 +3430,43 @@ class GeoJsonEditor extends HTMLElement {
3299
3430
  // operation (insertText, insertNewline, etc.) BEFORE formatAndUpdate was called.
3300
3431
  // We need to adjust for indentation changes while keeping the logical position.
3301
3432
 
3302
- // If cursor is at column 0 (e.g., after newline), keep it there
3303
- // This preserves expected behavior for newline insertion
3304
- if (this.cursorColumn === 0) {
3305
- // Just keep line, column 0 - indentation will be handled by auto-indent
3433
+ // Special case: cursor at column 0 means we're at start of a line (after newline)
3434
+ // Keep both line and column as set by the calling operation
3435
+ if (oldCursorColumn === 0) {
3436
+ // cursorLine was already set by the calling operation, keep column at 0
3437
+ this.cursorColumn = 0;
3306
3438
  } else {
3307
- // For other cases, try to maintain position relative to content (not indentation)
3439
+ // Calculate character offset in old content (ignoring whitespace for comparison)
3308
3440
  const oldLines = oldContent.split('\n');
3441
+ let oldCharOffset = 0;
3442
+ for (let i = 0; i < oldCursorLine && i < oldLines.length; i++) {
3443
+ oldCharOffset += oldLines[i].replace(/\s/g, '').length;
3444
+ }
3309
3445
  const oldLineContent = oldLines[oldCursorLine] || '';
3310
- const oldLeadingSpaces = oldLineContent.length - oldLineContent.trimStart().length;
3311
- const oldColumnInContent = Math.max(0, oldCursorColumn - oldLeadingSpaces);
3312
-
3313
- // Apply same offset to new line's indentation
3314
- if (this.cursorLine < this.lines.length) {
3315
- const newLineContent = this.lines[this.cursorLine];
3316
- const newLeadingSpaces = newLineContent.length - newLineContent.trimStart().length;
3317
- this.cursorColumn = newLeadingSpaces + oldColumnInContent;
3446
+ const oldLineUpToCursor = oldLineContent.substring(0, oldCursorColumn);
3447
+ oldCharOffset += oldLineUpToCursor.replace(/\s/g, '').length;
3448
+
3449
+ // Find corresponding position in new content
3450
+ let charCount = 0;
3451
+ let newLine = 0;
3452
+ let newCol = 0;
3453
+ for (let i = 0; i < this.lines.length; i++) {
3454
+ const lineContent = this.lines[i];
3455
+ for (let j = 0; j <= lineContent.length; j++) {
3456
+ if (charCount >= oldCharOffset) {
3457
+ newLine = i;
3458
+ newCol = j;
3459
+ break;
3460
+ }
3461
+ if (j < lineContent.length && !/\s/.test(lineContent[j])) {
3462
+ charCount++;
3463
+ }
3464
+ }
3465
+ if (charCount >= oldCharOffset) break;
3318
3466
  }
3467
+
3468
+ this.cursorLine = newLine;
3469
+ this.cursorColumn = newCol;
3319
3470
  }
3320
3471
  }
3321
3472
 
@@ -3323,6 +3474,17 @@ class GeoJsonEditor extends HTMLElement {
3323
3474
  this.cursorLine = Math.min(this.cursorLine, Math.max(0, this.lines.length - 1));
3324
3475
  this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
3325
3476
 
3477
+ // Adjust hidden feature indices if feature count changed
3478
+ const finalContent = this.lines.join('\n');
3479
+ const newFeatureCount = this._countFeatures(finalContent);
3480
+ if (oldFeatureCount >= 0 && newFeatureCount >= 0 && oldFeatureCount !== newFeatureCount) {
3481
+ const delta = newFeatureCount - oldFeatureCount;
3482
+ // Use cursor position to determine insertion point
3483
+ // If cursor was inside a feature, changes happened at/after that feature
3484
+ const insertionIndex = cursorFeatureIndex >= 0 ? cursorFeatureIndex : 0;
3485
+ this._adjustHiddenIndices(insertionIndex, delta);
3486
+ }
3487
+
3326
3488
  this.updateModel();
3327
3489
 
3328
3490
  // Expand any nodes that contain errors (prevents closing edited nodes with typos)
@@ -3345,9 +3507,8 @@ class GeoJsonEditor extends HTMLElement {
3345
3507
 
3346
3508
  // Filter hidden features
3347
3509
  if (this.hiddenFeatures.size > 0) {
3348
- parsed.features = parsed.features.filter((feature: Feature) => {
3349
- const key = getFeatureKey(feature);
3350
- return key ? !this.hiddenFeatures.has(key) : true;
3510
+ parsed.features = parsed.features.filter((_feature: Feature, index: number) => {
3511
+ return !this.hiddenFeatures.has(index);
3351
3512
  });
3352
3513
  }
3353
3514
 
@@ -3627,8 +3788,10 @@ class GeoJsonEditor extends HTMLElement {
3627
3788
  */
3628
3789
  add(input: FeatureInput, options: SetOptions = {}): void {
3629
3790
  const newFeatures = normalizeToFeatures(input);
3791
+ const existingCount = this._parseFeatures().length;
3630
3792
  const allFeatures = [...this._parseFeatures(), ...newFeatures];
3631
- this._setFeaturesInternal(allFeatures, options);
3793
+ // Preserve collapsed state for existing features, apply options only to new ones
3794
+ this._setFeaturesInternalPreserving(allFeatures, options, existingCount);
3632
3795
  }
3633
3796
 
3634
3797
  /**
@@ -3644,8 +3807,15 @@ class GeoJsonEditor extends HTMLElement {
3644
3807
  const newFeatures = normalizeToFeatures(input);
3645
3808
  const features = this._parseFeatures();
3646
3809
  const idx = index < 0 ? features.length + index : index;
3647
- features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
3648
- this._setFeaturesInternal(features, options);
3810
+ const insertIdx = Math.max(0, Math.min(idx, features.length));
3811
+
3812
+ // Adjust hidden feature indices before insertion
3813
+ // Features at or after insertIdx need to shift by newFeatures.length
3814
+ this._adjustHiddenIndices(insertIdx, newFeatures.length);
3815
+
3816
+ features.splice(insertIdx, 0, ...newFeatures);
3817
+ // Preserve collapsed state, apply options only to inserted features
3818
+ this._setFeaturesInternalPreserving(features, options, insertIdx, newFeatures.length);
3649
3819
  }
3650
3820
 
3651
3821
  /**
@@ -3657,12 +3827,241 @@ class GeoJsonEditor extends HTMLElement {
3657
3827
  this._applyCollapsedFromOptions(options, features);
3658
3828
  }
3659
3829
 
3830
+ /**
3831
+ * Internal method to set features while preserving collapsed state of existing features
3832
+ * @param features All features (existing + new)
3833
+ * @param options Collapse options for new features
3834
+ * @param newStartIndex Index where new features start (for add) or were inserted (for insertAt)
3835
+ * @param newCount Number of new features (optional, defaults to features from newStartIndex to end)
3836
+ */
3837
+ private _setFeaturesInternalPreserving(
3838
+ features: Feature[],
3839
+ options: SetOptions,
3840
+ newStartIndex: number,
3841
+ newCount?: number
3842
+ ): void {
3843
+ // Save collapsed state by nodeKey before modification
3844
+ const collapsedKeys = new Set<string>();
3845
+ for (const nodeId of this.collapsedNodes) {
3846
+ const nodeInfo = this._nodeIdToLines.get(nodeId);
3847
+ if (nodeInfo?.nodeKey) {
3848
+ // Include feature index in key to handle multiple features with same structure
3849
+ const featureIndex = this._getFeatureIndexForLine(nodeInfo.startLine);
3850
+ collapsedKeys.add(`${featureIndex}:${nodeInfo.nodeKey}`);
3851
+ }
3852
+ }
3853
+
3854
+ // Save hidden features (already adjusted by caller for insert/remove)
3855
+ const savedHiddenFeatures = new Set(this.hiddenFeatures);
3856
+
3857
+ // Format and set content
3858
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
3859
+ this.setValue(formatted, false);
3860
+
3861
+ // Restore hidden features
3862
+ this.hiddenFeatures = savedHiddenFeatures;
3863
+
3864
+ // Restore collapsed state for existing features
3865
+ const ranges = this._findCollapsibleRanges();
3866
+ const featureRanges = ranges.filter(r => r.isRootFeature);
3867
+ const actualNewCount = newCount !== undefined ? newCount : features.length - newStartIndex;
3868
+
3869
+ for (const range of ranges) {
3870
+ // Find which feature this range belongs to
3871
+ const featureIndex = featureRanges.findIndex(fr =>
3872
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
3873
+ );
3874
+
3875
+ // Determine if this is an existing feature (adjust index for insertAt case)
3876
+ let originalFeatureIndex = featureIndex;
3877
+ if (featureIndex >= newStartIndex && featureIndex < newStartIndex + actualNewCount) {
3878
+ // This is a new feature - apply options
3879
+ continue;
3880
+ } else if (featureIndex >= newStartIndex + actualNewCount) {
3881
+ // Feature was shifted by insertion - adjust index
3882
+ originalFeatureIndex = featureIndex - actualNewCount;
3883
+ }
3884
+
3885
+ // Check if this node was collapsed before
3886
+ const key = `${originalFeatureIndex}:${range.nodeKey}`;
3887
+ if (collapsedKeys.has(key)) {
3888
+ this.collapsedNodes.add(range.nodeId);
3889
+ }
3890
+ }
3891
+
3892
+ // Apply collapse options to new features only
3893
+ this._applyCollapsedToNewFeatures(options, features, newStartIndex, actualNewCount);
3894
+
3895
+ // Use updateView instead of updateModel since setValue already rebuilt mappings
3896
+ // and we just need to recompute visible lines with new collapsed state
3897
+ this.updateView();
3898
+ this.scheduleRender();
3899
+ }
3900
+
3901
+ /**
3902
+ * Apply collapsed options only to specific new features
3903
+ */
3904
+ private _applyCollapsedToNewFeatures(
3905
+ options: SetOptions,
3906
+ features: Feature[],
3907
+ startIndex: number,
3908
+ count: number
3909
+ ): void {
3910
+ const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
3911
+ if (!collapsed || (Array.isArray(collapsed) && collapsed.length === 0)) return;
3912
+
3913
+ const ranges = this._findCollapsibleRanges();
3914
+ const featureRanges = ranges.filter(r => r.isRootFeature);
3915
+
3916
+ for (const range of ranges) {
3917
+ // Find which feature this range belongs to
3918
+ const featureIndex = featureRanges.findIndex(fr =>
3919
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
3920
+ );
3921
+
3922
+ // Only process new features
3923
+ if (featureIndex < startIndex || featureIndex >= startIndex + count) continue;
3924
+
3925
+ let shouldCollapse = false;
3926
+ if (typeof collapsed === 'function') {
3927
+ const feature = features[featureIndex];
3928
+ const collapsedAttrs = collapsed(feature, featureIndex);
3929
+ shouldCollapse = range.isRootFeature
3930
+ ? collapsedAttrs.includes('$root')
3931
+ : collapsedAttrs.includes(range.nodeKey);
3932
+ } else if (Array.isArray(collapsed)) {
3933
+ shouldCollapse = range.isRootFeature
3934
+ ? collapsed.includes('$root')
3935
+ : collapsed.includes(range.nodeKey);
3936
+ }
3937
+
3938
+ if (shouldCollapse) {
3939
+ this.collapsedNodes.add(range.nodeId);
3940
+ }
3941
+ }
3942
+ }
3943
+
3944
+ /**
3945
+ * Get feature index for a given line
3946
+ */
3947
+ /**
3948
+ * Count features in content (returns -1 if JSON invalid)
3949
+ */
3950
+ private _countFeatures(content: string): number {
3951
+ try {
3952
+ const wrapped = '[' + content + ']';
3953
+ const parsed = JSON.parse(wrapped);
3954
+ return Array.isArray(parsed) ? parsed.length : -1;
3955
+ } catch {
3956
+ return -1;
3957
+ }
3958
+ }
3959
+
3960
+ /**
3961
+ * Adjust hiddenFeatures indices when features are inserted or removed
3962
+ * @param insertionIndex - Index where features were inserted (or removed from)
3963
+ * @param delta - Number of features added (positive) or removed (negative)
3964
+ */
3965
+ private _adjustHiddenIndices(insertionIndex: number, delta: number): void {
3966
+ if (delta === 0 || this.hiddenFeatures.size === 0) return;
3967
+
3968
+ const newHiddenFeatures = new Set<number>();
3969
+ for (const idx of this.hiddenFeatures) {
3970
+ if (idx < insertionIndex) {
3971
+ // Before insertion point - keep same index
3972
+ newHiddenFeatures.add(idx);
3973
+ } else {
3974
+ // At or after insertion point - shift by delta
3975
+ const newIdx = idx + delta;
3976
+ if (newIdx >= 0) {
3977
+ newHiddenFeatures.add(newIdx);
3978
+ }
3979
+ // If newIdx < 0, the feature was removed, so we don't add it
3980
+ }
3981
+ }
3982
+ this.hiddenFeatures = newHiddenFeatures;
3983
+ }
3984
+
3985
+ /**
3986
+ * Remove a hidden index and shift all indices after it by -1
3987
+ * Used when removing a feature via API
3988
+ */
3989
+ private _removeAndShiftHiddenIndex(removedIndex: number): void {
3990
+ if (this.hiddenFeatures.size === 0) return;
3991
+
3992
+ const newHiddenFeatures = new Set<number>();
3993
+ for (const idx of this.hiddenFeatures) {
3994
+ if (idx < removedIndex) {
3995
+ // Before removed index - keep same
3996
+ newHiddenFeatures.add(idx);
3997
+ } else if (idx > removedIndex) {
3998
+ // After removed index - shift by -1
3999
+ newHiddenFeatures.add(idx - 1);
4000
+ }
4001
+ // idx === removedIndex is dropped (feature was removed)
4002
+ }
4003
+ this.hiddenFeatures = newHiddenFeatures;
4004
+ }
4005
+
4006
+ private _getFeatureIndexForLine(line: number): number {
4007
+ for (const [, range] of this.featureRanges) {
4008
+ if (line >= range.startLine && line <= range.endLine) {
4009
+ return range.featureIndex;
4010
+ }
4011
+ }
4012
+ return -1;
4013
+ }
4014
+
3660
4015
  removeAt(index: number): Feature | undefined {
3661
4016
  const features = this._parseFeatures();
3662
4017
  const idx = index < 0 ? features.length + index : index;
3663
4018
  if (idx >= 0 && idx < features.length) {
4019
+ // Save collapsed state by nodeKey before modification
4020
+ const collapsedKeys = new Set<string>();
4021
+ for (const nodeId of this.collapsedNodes) {
4022
+ const nodeInfo = this._nodeIdToLines.get(nodeId);
4023
+ if (nodeInfo?.nodeKey) {
4024
+ const featureIndex = this._getFeatureIndexForLine(nodeInfo.startLine);
4025
+ // Skip the feature being removed, adjust indices for features after it
4026
+ if (featureIndex === idx) continue;
4027
+ const adjustedIndex = featureIndex > idx ? featureIndex - 1 : featureIndex;
4028
+ collapsedKeys.add(`${adjustedIndex}:${nodeInfo.nodeKey}`);
4029
+ }
4030
+ }
4031
+
4032
+ // Adjust hidden feature indices: remove idx, shift indices after idx by -1
4033
+ this._removeAndShiftHiddenIndex(idx);
4034
+
4035
+ // Save hidden features before setValue (which clears them)
4036
+ const savedHiddenFeatures = new Set(this.hiddenFeatures);
4037
+
3664
4038
  const removed = features.splice(idx, 1)[0];
3665
- this.set(features);
4039
+
4040
+ // Format and set content
4041
+ const formatted = features.map((f: Feature) => JSON.stringify(f, null, 2)).join(',\n');
4042
+ this.setValue(formatted, false);
4043
+
4044
+ // Restore hidden features
4045
+ this.hiddenFeatures = savedHiddenFeatures;
4046
+
4047
+ // Restore collapsed state for remaining features
4048
+ const ranges = this._findCollapsibleRanges();
4049
+ const featureRanges = ranges.filter(r => r.isRootFeature);
4050
+
4051
+ for (const range of ranges) {
4052
+ const featureIndex = featureRanges.findIndex(fr =>
4053
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
4054
+ );
4055
+ const key = `${featureIndex}:${range.nodeKey}`;
4056
+ if (collapsedKeys.has(key)) {
4057
+ this.collapsedNodes.add(range.nodeId);
4058
+ }
4059
+ }
4060
+
4061
+ // Use updateView instead of updateModel since setValue already rebuilt mappings
4062
+ this.updateView();
4063
+ this.scheduleRender();
4064
+ this.emitChange();
3666
4065
  return removed;
3667
4066
  }
3668
4067
  return undefined;