@softwarity/geojson-editor 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,154 @@
1
1
  import styles from './geojson-editor.css?inline';
2
2
  import { getTemplate } from './geojson-editor.template.js';
3
+ import type { Feature, FeatureCollection } from 'geojson';
4
+
5
+ // ========== Type Definitions ==========
6
+
7
+ /** Geometry type names */
8
+ export type GeometryType = 'Point' | 'MultiPoint' | 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon';
9
+
10
+ /** Position in the editor (line and column) */
11
+ export interface CursorPosition {
12
+ line: number;
13
+ column: number;
14
+ }
15
+
16
+ /** Options for set/add/insertAt/open methods */
17
+ export interface SetOptions {
18
+ /**
19
+ * Attributes to collapse after loading.
20
+ * - string[]: List of attribute names (e.g., ['coordinates', 'geometry'])
21
+ * - function: Dynamic function (feature, index) => string[]
22
+ * - '$root': Special keyword to collapse entire features
23
+ * - Empty array: No auto-collapse
24
+ * @default ['coordinates']
25
+ */
26
+ collapsed?: string[] | ((feature: Feature, index: number) => string[]);
27
+ }
28
+
29
+ /** Theme configuration */
30
+ export interface ThemeConfig {
31
+ bgColor?: string;
32
+ textColor?: string;
33
+ caretColor?: string;
34
+ gutterBg?: string;
35
+ gutterBorder?: string;
36
+ gutterText?: string;
37
+ jsonKey?: string;
38
+ jsonString?: string;
39
+ jsonNumber?: string;
40
+ jsonBoolean?: string;
41
+ jsonNull?: string;
42
+ jsonPunct?: string;
43
+ jsonError?: string;
44
+ controlColor?: string;
45
+ controlBg?: string;
46
+ controlBorder?: string;
47
+ geojsonKey?: string;
48
+ geojsonType?: string;
49
+ geojsonTypeInvalid?: string;
50
+ jsonKeyInvalid?: string;
51
+ }
52
+
53
+ /** Theme settings for dark and light modes */
54
+ export interface ThemeSettings {
55
+ dark?: ThemeConfig;
56
+ light?: ThemeConfig;
57
+ }
58
+
59
+ /** Color metadata for a line */
60
+ interface ColorMeta {
61
+ attributeName: string;
62
+ color: string;
63
+ }
64
+
65
+ /** Boolean metadata for a line */
66
+ interface BooleanMeta {
67
+ attributeName: string;
68
+ value: boolean;
69
+ }
70
+
71
+ /** Collapse button metadata */
72
+ interface CollapseButtonMeta {
73
+ nodeKey: string;
74
+ nodeId: string;
75
+ isCollapsed: boolean;
76
+ }
77
+
78
+ /** Visibility button metadata */
79
+ interface VisibilityButtonMeta {
80
+ featureKey: string;
81
+ isHidden: boolean;
82
+ }
83
+
84
+ /** Line metadata */
85
+ interface LineMeta {
86
+ colors: ColorMeta[];
87
+ booleans: BooleanMeta[];
88
+ collapseButton: CollapseButtonMeta | null;
89
+ visibilityButton: VisibilityButtonMeta | null;
90
+ isHidden: boolean;
91
+ isCollapsed: boolean;
92
+ featureKey: string | null;
93
+ }
94
+
95
+ /** Visible line data */
96
+ interface VisibleLine {
97
+ index: number;
98
+ content: string;
99
+ meta: LineMeta | undefined;
100
+ }
101
+
102
+ /** Feature range in the editor */
103
+ interface FeatureRange {
104
+ startLine: number;
105
+ endLine: number;
106
+ featureIndex: number;
107
+ }
108
+
109
+ /** Node range info */
110
+ interface NodeRangeInfo {
111
+ startLine: number;
112
+ endLine: number;
113
+ nodeKey?: string;
114
+ isRootFeature?: boolean;
115
+ }
116
+
117
+ /** Collapsible range info */
118
+ interface CollapsibleRange extends NodeRangeInfo {
119
+ nodeId: string;
120
+ openBracket: string;
121
+ }
122
+
123
+ /** Editor state snapshot for undo/redo */
124
+ interface EditorSnapshot {
125
+ lines: string[];
126
+ cursorLine: number;
127
+ cursorColumn: number;
128
+ timestamp: number;
129
+ }
130
+
131
+ /** Bracket count result */
132
+ interface BracketCount {
133
+ open: number;
134
+ close: number;
135
+ }
136
+
137
+ /** Context stack item */
138
+ interface ContextStackItem {
139
+ context: string;
140
+ isArray: boolean;
141
+ }
142
+
143
+ /** Input types accepted by API methods */
144
+ export type FeatureInput = Feature | Feature[] | FeatureCollection;
3
145
 
4
146
  // Version injected by Vite build from package.json
5
147
  const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'dev';
6
148
 
7
149
  // GeoJSON constants
8
- const GEOJSON_KEYS = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
9
- const GEOMETRY_TYPES = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
150
+ const GEOJSON_KEYS: string[] = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
151
+ const GEOMETRY_TYPES: GeometryType[] = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
10
152
 
11
153
  // Pre-compiled regex patterns for performance (avoid re-creation on each call)
12
154
  const RE_CONTEXT_GEOMETRY = /"geometry"\s*:/;
@@ -36,56 +178,66 @@ const RE_WHITESPACE_SPLIT = /(\s+)/;
36
178
  * Monaco-like architecture with virtualized line rendering
37
179
  */
38
180
  class GeoJsonEditor extends HTMLElement {
181
+ // ========== Model (Source of Truth) ==========
182
+ lines: string[] = [];
183
+ collapsedNodes: Set<string> = new Set();
184
+ hiddenFeatures: Set<string> = new Set();
185
+
186
+ // ========== Node ID Management ==========
187
+ private _nodeIdCounter: number = 0;
188
+ private _lineToNodeId: Map<number, string> = new Map();
189
+ private _nodeIdToLines: Map<string, NodeRangeInfo> = new Map();
190
+
191
+ // ========== Derived State (computed from model) ==========
192
+ visibleLines: VisibleLine[] = [];
193
+ lineMetadata: Map<number, LineMeta> = new Map();
194
+ featureRanges: Map<string, FeatureRange> = new Map();
195
+
196
+ // ========== View State ==========
197
+ viewportHeight: number = 0;
198
+ lineHeight: number = 19.5;
199
+ bufferLines: number = 5;
200
+
201
+ // ========== Render Cache ==========
202
+ private _lastStartIndex: number = -1;
203
+ private _lastEndIndex: number = -1;
204
+ private _lastTotalLines: number = -1;
205
+ private _scrollRaf: number | null = null;
206
+
207
+ // ========== Cursor/Selection ==========
208
+ cursorLine: number = 0;
209
+ cursorColumn: number = 0;
210
+ selectionStart: CursorPosition | null = null;
211
+ selectionEnd: CursorPosition | null = null;
212
+
213
+ // ========== Debounce ==========
214
+ private renderTimer: number | null = null;
215
+ private inputTimer: number | null = null;
216
+
217
+ // ========== Theme ==========
218
+ themes: ThemeSettings = { dark: {}, light: {} };
219
+
220
+ // ========== Undo/Redo History ==========
221
+ private _undoStack: EditorSnapshot[] = [];
222
+ private _redoStack: EditorSnapshot[] = [];
223
+ private _maxHistorySize: number = 100;
224
+ private _lastActionTime: number = 0;
225
+ private _lastActionType: string | null = null;
226
+ private _groupingDelay: number = 500;
227
+
228
+ // ========== Internal State ==========
229
+ private _isSelecting: boolean = false;
230
+ private _isComposing: boolean = false;
231
+ private _blockRender: boolean = false;
232
+ private _charWidth: number | null = null;
233
+ private _contextMapCache: Map<number, string> | null = null;
234
+ private _contextMapLinesLength: number = 0;
235
+ private _contextMapFirstLine: string | undefined = undefined;
236
+ private _contextMapLastLine: string | undefined = undefined;
237
+
39
238
  constructor() {
40
239
  super();
41
240
  this.attachShadow({ mode: 'open' });
42
-
43
- // ========== Model (Source of Truth) ==========
44
- this.lines = []; // Array of line strings
45
- this.collapsedNodes = new Set(); // Set of unique node IDs that are collapsed
46
- this.hiddenFeatures = new Set(); // Set of feature keys hidden from events
47
-
48
- // ========== Node ID Management ==========
49
- this._nodeIdCounter = 0; // Counter for generating unique node IDs
50
- this._lineToNodeId = new Map(); // lineIndex -> nodeId (for collapsible lines)
51
- this._nodeIdToLines = new Map(); // nodeId -> {startLine, endLine} (range of collapsed content)
52
-
53
- // ========== Derived State (computed from model) ==========
54
- this.visibleLines = []; // Lines to render (after collapse filter)
55
- this.lineMetadata = new Map(); // lineIndex -> {colors, booleans, collapse, visibility, hidden, featureKey}
56
- this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
57
-
58
- // ========== View State ==========
59
- this.viewportHeight = 0;
60
- this.lineHeight = 19.5; // CSS: line-height * font-size = 1.5 * 13px
61
- this.bufferLines = 5; // Extra lines to render above/below viewport
62
-
63
- // ========== Render Cache ==========
64
- this._lastStartIndex = -1;
65
- this._lastEndIndex = -1;
66
- this._lastTotalLines = -1;
67
- this._scrollRaf = null;
68
-
69
- // ========== Cursor/Selection ==========
70
- this.cursorLine = 0;
71
- this.cursorColumn = 0;
72
- this.selectionStart = null; // {line, column}
73
- this.selectionEnd = null; // {line, column}
74
-
75
- // ========== Debounce ==========
76
- this.renderTimer = null;
77
- this.inputTimer = null;
78
-
79
- // ========== Theme ==========
80
- this.themes = { dark: {}, light: {} };
81
-
82
- // ========== Undo/Redo History ==========
83
- this._undoStack = []; // Stack of previous states
84
- this._redoStack = []; // Stack of undone states
85
- this._maxHistorySize = 100; // Maximum history entries
86
- this._lastActionTime = 0; // Timestamp of last action (for grouping)
87
- this._lastActionType = null; // Type of last action (for grouping)
88
- this._groupingDelay = 500; // ms - actions within this delay are grouped
89
241
  }
90
242
 
91
243
  // ========== Render Cache ==========
@@ -435,12 +587,12 @@ class GeoJsonEditor extends HTMLElement {
435
587
  this.updatePlaceholderVisibility();
436
588
  }
437
589
 
438
- disconnectedCallback() {
590
+ disconnectedCallback(): void {
439
591
  if (this.renderTimer) clearTimeout(this.renderTimer);
440
592
  if (this.inputTimer) clearTimeout(this.inputTimer);
441
-
593
+
442
594
  // Cleanup color picker
443
- const colorPicker = document.querySelector('.geojson-color-picker-input');
595
+ const colorPicker = document.querySelector('.geojson-color-picker-input') as HTMLInputElement & { _closeListener?: EventListener };
444
596
  if (colorPicker) {
445
597
  if (colorPicker._closeListener) {
446
598
  document.removeEventListener('click', colorPicker._closeListener, true);
@@ -509,9 +661,10 @@ class GeoJsonEditor extends HTMLElement {
509
661
  this.handleEditorClick(e);
510
662
  }, true);
511
663
 
512
- viewport.addEventListener('mousedown', (e) => {
664
+ viewport.addEventListener('mousedown', (e: MouseEvent) => {
665
+ const target = e.target as HTMLElement;
513
666
  // Skip if clicking on visibility pseudo-element (line-level)
514
- const lineEl = e.target.closest('.line.has-visibility');
667
+ const lineEl = target.closest('.line.has-visibility');
515
668
  if (lineEl) {
516
669
  const rect = lineEl.getBoundingClientRect();
517
670
  const clickX = e.clientX - rect.left;
@@ -523,9 +676,9 @@ class GeoJsonEditor extends HTMLElement {
523
676
  }
524
677
 
525
678
  // Skip if clicking on an inline control pseudo-element (positioned with negative left)
526
- if (e.target.classList.contains('json-color') ||
527
- e.target.classList.contains('json-boolean')) {
528
- const rect = e.target.getBoundingClientRect();
679
+ if (target.classList.contains('json-color') ||
680
+ target.classList.contains('json-boolean')) {
681
+ const rect = target.getBoundingClientRect();
529
682
  const clickX = e.clientX - rect.left;
530
683
  // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
531
684
  if (clickX < 0 && clickX >= -8) {
@@ -670,7 +823,7 @@ class GeoJsonEditor extends HTMLElement {
670
823
  });
671
824
 
672
825
  // Wheel on gutter -> scroll viewport
673
- gutter.addEventListener('wheel', (e) => {
826
+ gutter.addEventListener('wheel', (e: WheelEvent) => {
674
827
  e.preventDefault();
675
828
  viewport.scrollTop += e.deltaY;
676
829
  });
@@ -689,7 +842,7 @@ class GeoJsonEditor extends HTMLElement {
689
842
  /**
690
843
  * Set the editor content from a string value
691
844
  */
692
- setValue(value) {
845
+ setValue(value, autoCollapse = true) {
693
846
  // Save to history only if there's existing content
694
847
  if (this.lines.length > 0) {
695
848
  this._saveToHistory('setValue');
@@ -719,18 +872,18 @@ class GeoJsonEditor extends HTMLElement {
719
872
  this._nodeIdToLines.clear();
720
873
  this.cursorLine = 0;
721
874
  this.cursorColumn = 0;
722
-
875
+
723
876
  this.updateModel();
724
877
  this.scheduleRender();
725
878
  this.updatePlaceholderVisibility();
726
-
727
- // Auto-collapse coordinates
728
- if (this.lines.length > 0) {
879
+
880
+ // Auto-collapse coordinates (unless disabled)
881
+ if (autoCollapse && this.lines.length > 0) {
729
882
  requestAnimationFrame(() => {
730
883
  this.autoCollapseCoordinates();
731
884
  });
732
885
  }
733
-
886
+
734
887
  this.emitChange();
735
888
  }
736
889
 
@@ -1018,7 +1171,7 @@ class GeoJsonEditor extends HTMLElement {
1018
1171
 
1019
1172
  const lineEl = document.createElement('div');
1020
1173
  lineEl.className = 'line';
1021
- lineEl.dataset.lineIndex = lineData.index;
1174
+ lineEl.dataset.lineIndex = String(lineData.index);
1022
1175
 
1023
1176
  // Add visibility button on line (uses ::before pseudo-element)
1024
1177
  if (lineData.meta?.visibilityButton) {
@@ -1153,7 +1306,7 @@ class GeoJsonEditor extends HTMLElement {
1153
1306
  // Line number first
1154
1307
  const lineNum = document.createElement('span');
1155
1308
  lineNum.className = 'line-number';
1156
- lineNum.textContent = lineData.index + 1;
1309
+ lineNum.textContent = String(lineData.index + 1);
1157
1310
  gutterLine.appendChild(lineNum);
1158
1311
 
1159
1312
  // Collapse column (always present for alignment)
@@ -1163,7 +1316,7 @@ class GeoJsonEditor extends HTMLElement {
1163
1316
  const btn = document.createElement('div');
1164
1317
  btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
1165
1318
  btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
1166
- btn.dataset.line = lineData.index;
1319
+ btn.dataset.line = String(lineData.index);
1167
1320
  btn.dataset.nodeId = meta.collapseButton.nodeId;
1168
1321
  btn.title = meta.collapseButton.isCollapsed ? 'Expand' : 'Collapse';
1169
1322
  collapseCol.appendChild(btn);
@@ -1187,9 +1340,9 @@ class GeoJsonEditor extends HTMLElement {
1187
1340
  }
1188
1341
 
1189
1342
  // ========== Input Handling ==========
1190
-
1191
- handleInput() {
1192
- const textarea = this.shadowRoot.getElementById('hiddenTextarea');
1343
+
1344
+ handleInput(): void {
1345
+ const textarea = this.shadowRoot!.getElementById('hiddenTextarea') as HTMLTextAreaElement;
1193
1346
  const inputValue = textarea.value;
1194
1347
 
1195
1348
  if (!inputValue) return;
@@ -1270,81 +1423,40 @@ class GeoJsonEditor extends HTMLElement {
1270
1423
  onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
1271
1424
  };
1272
1425
 
1273
- switch (e.key) {
1274
- case 'Enter':
1275
- e.preventDefault();
1276
- this._handleEnter(ctx);
1277
- break;
1278
- case 'Backspace':
1279
- e.preventDefault();
1280
- this._handleBackspace(ctx);
1281
- break;
1282
- case 'Delete':
1283
- e.preventDefault();
1284
- this._handleDelete(ctx);
1285
- break;
1286
- case 'ArrowUp':
1287
- e.preventDefault();
1288
- this._handleArrowKey(-1, 0, e.shiftKey);
1289
- break;
1290
- case 'ArrowDown':
1291
- e.preventDefault();
1292
- this._handleArrowKey(1, 0, e.shiftKey);
1293
- break;
1294
- case 'ArrowLeft':
1295
- e.preventDefault();
1296
- this._handleArrowKey(0, -1, e.shiftKey);
1297
- break;
1298
- case 'ArrowRight':
1299
- e.preventDefault();
1300
- this._handleArrowKey(0, 1, e.shiftKey);
1301
- break;
1302
- case 'Home':
1303
- e.preventDefault();
1304
- this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine);
1305
- break;
1306
- case 'End':
1307
- e.preventDefault();
1308
- this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine);
1309
- break;
1310
- case 'a':
1311
- if (e.ctrlKey || e.metaKey) {
1312
- e.preventDefault();
1313
- this._selectAll();
1314
- }
1315
- break;
1316
- case 'z':
1317
- if (e.ctrlKey || e.metaKey) {
1318
- e.preventDefault();
1319
- if (e.shiftKey) {
1320
- this.redo();
1321
- } else {
1322
- this.undo();
1323
- }
1324
- }
1325
- break;
1326
- case 'y':
1327
- if (e.ctrlKey || e.metaKey) {
1328
- e.preventDefault();
1329
- this.redo();
1330
- }
1331
- break;
1332
- case 's':
1333
- if (e.ctrlKey || e.metaKey) {
1334
- e.preventDefault();
1335
- this.save();
1336
- }
1337
- break;
1338
- case 'o':
1339
- if ((e.ctrlKey || e.metaKey) && !this.hasAttribute('readonly')) {
1340
- e.preventDefault();
1341
- this.open();
1342
- }
1343
- break;
1344
- case 'Tab':
1345
- e.preventDefault();
1346
- this._handleTab(e.shiftKey, ctx);
1347
- break;
1426
+ // Lookup table for key handlers
1427
+ const keyHandlers = {
1428
+ 'Enter': () => this._handleEnter(ctx),
1429
+ 'Backspace': () => this._handleBackspace(ctx),
1430
+ 'Delete': () => this._handleDelete(ctx),
1431
+ 'ArrowUp': () => this._handleArrowKey(-1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
1432
+ 'ArrowDown': () => this._handleArrowKey(1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
1433
+ 'ArrowLeft': () => this._handleArrowKey(0, -1, e.shiftKey, e.ctrlKey || e.metaKey),
1434
+ 'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
1435
+ 'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
1436
+ 'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
1437
+ 'Tab': () => this._handleTab(e.shiftKey, ctx)
1438
+ };
1439
+
1440
+ // Modifier key handlers (Ctrl/Cmd)
1441
+ const modifierHandlers = {
1442
+ 'a': () => this._selectAll(),
1443
+ 'z': () => e.shiftKey ? this.redo() : this.undo(),
1444
+ 'y': () => this.redo(),
1445
+ 's': () => this.save(),
1446
+ 'o': () => !this.hasAttribute('readonly') && this.open()
1447
+ };
1448
+
1449
+ // Check for direct key match
1450
+ if (keyHandlers[e.key]) {
1451
+ e.preventDefault();
1452
+ keyHandlers[e.key]();
1453
+ return;
1454
+ }
1455
+
1456
+ // Check for modifier key combinations
1457
+ if ((e.ctrlKey || e.metaKey) && modifierHandlers[e.key]) {
1458
+ e.preventDefault();
1459
+ modifierHandlers[e.key]();
1348
1460
  }
1349
1461
  }
1350
1462
 
@@ -1674,21 +1786,26 @@ class GeoJsonEditor extends HTMLElement {
1674
1786
  }
1675
1787
 
1676
1788
  /**
1677
- * Handle arrow key with optional selection
1789
+ * Handle arrow key with optional selection and word jump
1678
1790
  */
1679
- _handleArrowKey(deltaLine, deltaCol, isShift) {
1791
+ _handleArrowKey(deltaLine, deltaCol, isShift, isCtrl = false) {
1680
1792
  // Start selection if shift is pressed and no selection exists
1681
1793
  if (isShift && !this.selectionStart) {
1682
1794
  this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
1683
1795
  }
1684
-
1796
+
1685
1797
  // Move cursor
1686
1798
  if (deltaLine !== 0) {
1687
1799
  this.moveCursorSkipCollapsed(deltaLine);
1688
1800
  } else if (deltaCol !== 0) {
1689
- this.moveCursorHorizontal(deltaCol);
1801
+ if (isCtrl) {
1802
+ // Word-by-word movement
1803
+ this._moveCursorByWord(deltaCol);
1804
+ } else {
1805
+ this.moveCursorHorizontal(deltaCol);
1806
+ }
1690
1807
  }
1691
-
1808
+
1692
1809
  // Update selection end if shift is pressed
1693
1810
  if (isShift) {
1694
1811
  this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
@@ -1699,6 +1816,118 @@ class GeoJsonEditor extends HTMLElement {
1699
1816
  }
1700
1817
  }
1701
1818
 
1819
+ /**
1820
+ * Move cursor by word (Ctrl+Arrow)
1821
+ * Behavior matches VSCode/Monaco:
1822
+ * - Ctrl+Right: move to end of current word, or start of next word
1823
+ * - Ctrl+Left: move to start of current word, or start of previous word
1824
+ */
1825
+ _moveCursorByWord(direction) {
1826
+ const line = this.lines[this.cursorLine] || '';
1827
+ // Word character: alphanumeric, underscore, or hyphen (for kebab-case identifiers)
1828
+ const isWordChar = (ch) => /[\w-]/.test(ch);
1829
+
1830
+ // Check if we're on a collapsed node's opening line
1831
+ const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
1832
+
1833
+ if (direction > 0) {
1834
+ // Move right
1835
+ let pos = this.cursorColumn;
1836
+
1837
+ // If on collapsed node opening line and cursor is at/after the bracket, jump to closing line
1838
+ if (onCollapsed) {
1839
+ const bracketPos = line.search(/[{\[]/);
1840
+ if (bracketPos >= 0 && pos >= bracketPos) {
1841
+ this.cursorLine = onCollapsed.endLine;
1842
+ this.cursorColumn = (this.lines[this.cursorLine] || '').length;
1843
+ this._invalidateRenderCache();
1844
+ this._scrollToCursor();
1845
+ this.scheduleRender();
1846
+ return;
1847
+ }
1848
+ }
1849
+
1850
+ if (pos >= line.length) {
1851
+ // At end of line, move to start of next visible line
1852
+ if (this.cursorLine < this.lines.length - 1) {
1853
+ let nextLine = this.cursorLine + 1;
1854
+ // Skip collapsed zones
1855
+ const collapsed = this._getCollapsedRangeForLine(nextLine);
1856
+ if (collapsed) {
1857
+ nextLine = collapsed.endLine;
1858
+ }
1859
+ this.cursorLine = Math.min(nextLine, this.lines.length - 1);
1860
+ this.cursorColumn = 0;
1861
+ }
1862
+ } else if (isWordChar(line[pos])) {
1863
+ // Inside a word: move to end of word
1864
+ while (pos < line.length && isWordChar(line[pos])) {
1865
+ pos++;
1866
+ }
1867
+ this.cursorColumn = pos;
1868
+ } else {
1869
+ // On non-word char: skip non-word chars only (stop at start of next word)
1870
+ while (pos < line.length && !isWordChar(line[pos])) {
1871
+ pos++;
1872
+ }
1873
+ this.cursorColumn = pos;
1874
+ }
1875
+ } else {
1876
+ // Move left
1877
+ let pos = this.cursorColumn;
1878
+
1879
+ // Check if we're on closing line of a collapsed node
1880
+ const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
1881
+ if (onClosingLine) {
1882
+ const bracketPos = this._getClosingBracketPos(line);
1883
+ if (bracketPos >= 0 && pos <= bracketPos + 1) {
1884
+ // Jump to opening line, after the bracket
1885
+ this.cursorLine = onClosingLine.startLine;
1886
+ const openLine = this.lines[this.cursorLine] || '';
1887
+ const openBracketPos = openLine.search(/[{\[]/);
1888
+ this.cursorColumn = openBracketPos >= 0 ? openBracketPos : 0;
1889
+ this._invalidateRenderCache();
1890
+ this._scrollToCursor();
1891
+ this.scheduleRender();
1892
+ return;
1893
+ }
1894
+ }
1895
+
1896
+ if (pos === 0) {
1897
+ // At start of line, move to end of previous visible line
1898
+ if (this.cursorLine > 0) {
1899
+ let prevLine = this.cursorLine - 1;
1900
+ // Skip collapsed zones
1901
+ const collapsed = this._getCollapsedRangeForLine(prevLine);
1902
+ if (collapsed) {
1903
+ prevLine = collapsed.startLine;
1904
+ }
1905
+ this.cursorLine = Math.max(prevLine, 0);
1906
+ this.cursorColumn = this.lines[this.cursorLine].length;
1907
+ }
1908
+ } else if (pos > 0 && isWordChar(line[pos - 1])) {
1909
+ // Just after a word char: move to start of word
1910
+ while (pos > 0 && isWordChar(line[pos - 1])) {
1911
+ pos--;
1912
+ }
1913
+ this.cursorColumn = pos;
1914
+ } else {
1915
+ // On or after non-word char: skip non-word chars, then skip word
1916
+ while (pos > 0 && !isWordChar(line[pos - 1])) {
1917
+ pos--;
1918
+ }
1919
+ while (pos > 0 && isWordChar(line[pos - 1])) {
1920
+ pos--;
1921
+ }
1922
+ this.cursorColumn = pos;
1923
+ }
1924
+ }
1925
+
1926
+ this._invalidateRenderCache();
1927
+ this._scrollToCursor();
1928
+ this.scheduleRender();
1929
+ }
1930
+
1702
1931
  /**
1703
1932
  * Handle Home/End with optional selection
1704
1933
  */
@@ -1880,18 +2109,30 @@ class GeoJsonEditor extends HTMLElement {
1880
2109
  handlePaste(e) {
1881
2110
  e.preventDefault();
1882
2111
  const text = e.clipboardData.getData('text/plain');
1883
- if (text) {
1884
- const wasEmpty = this.lines.length === 0;
2112
+ if (!text) return;
2113
+
2114
+ const wasEmpty = this.lines.length === 0;
2115
+
2116
+ // Try to parse as GeoJSON and normalize
2117
+ try {
2118
+ const parsed = JSON.parse(text);
2119
+ const features = this._normalizeToFeatures(parsed);
2120
+ // Valid GeoJSON - insert formatted features
2121
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2122
+ this.insertText(formatted);
2123
+ } catch {
2124
+ // Invalid GeoJSON - fallback to raw text insertion
1885
2125
  this.insertText(text);
1886
- // Auto-collapse coordinates after pasting into empty editor
1887
- if (wasEmpty && this.lines.length > 0) {
1888
- // Cancel pending render, collapse first, then render once
1889
- if (this.renderTimer) {
1890
- cancelAnimationFrame(this.renderTimer);
1891
- this.renderTimer = null;
1892
- }
1893
- this.autoCollapseCoordinates();
2126
+ }
2127
+
2128
+ // Auto-collapse coordinates after pasting into empty editor
2129
+ if (wasEmpty && this.lines.length > 0) {
2130
+ // Cancel pending render, collapse first, then render once
2131
+ if (this.renderTimer) {
2132
+ cancelAnimationFrame(this.renderTimer);
2133
+ this.renderTimer = null;
1894
2134
  }
2135
+ this.autoCollapseCoordinates();
1895
2136
  }
1896
2137
  }
1897
2138
 
@@ -2066,16 +2307,65 @@ class GeoJsonEditor extends HTMLElement {
2066
2307
  }
2067
2308
 
2068
2309
  autoCollapseCoordinates() {
2310
+ this._applyCollapsedOption(['coordinates']);
2311
+ }
2312
+
2313
+ /**
2314
+ * Helper to apply collapsed option from API methods
2315
+ * @param {object} options - Options object with optional collapsed property
2316
+ * @param {array} features - Features array for function mode
2317
+ */
2318
+ _applyCollapsedFromOptions(options, features) {
2319
+ const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2320
+ if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2321
+ this._applyCollapsedOption(collapsed, features);
2322
+ }
2323
+ }
2324
+
2325
+ /**
2326
+ * Apply collapsed option to nodes
2327
+ * @param {string[]|function} collapsed - Attributes to collapse or function returning them
2328
+ * @param {array} features - Features array for function mode (optional)
2329
+ */
2330
+ _applyCollapsedOption(collapsed, features = null) {
2069
2331
  const ranges = this._findCollapsibleRanges();
2070
2332
 
2333
+ // Group ranges by feature (root nodes)
2334
+ const featureRanges = ranges.filter(r => r.isRootFeature);
2335
+
2336
+ // Determine which attributes to collapse per feature
2071
2337
  for (const range of ranges) {
2072
- if (range.nodeKey === 'coordinates') {
2338
+ let shouldCollapse = false;
2339
+
2340
+ if (typeof collapsed === 'function') {
2341
+ // Find which feature this range belongs to
2342
+ const featureIndex = featureRanges.findIndex(fr =>
2343
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
2344
+ );
2345
+ const feature = features?.[featureIndex] || null;
2346
+ const collapsedAttrs = collapsed(feature, featureIndex);
2347
+
2348
+ // Check if this range should be collapsed
2349
+ if (range.isRootFeature) {
2350
+ shouldCollapse = collapsedAttrs.includes('$root');
2351
+ } else {
2352
+ shouldCollapse = collapsedAttrs.includes(range.nodeKey);
2353
+ }
2354
+ } else if (Array.isArray(collapsed)) {
2355
+ // Static list
2356
+ if (range.isRootFeature) {
2357
+ shouldCollapse = collapsed.includes('$root');
2358
+ } else {
2359
+ shouldCollapse = collapsed.includes(range.nodeKey);
2360
+ }
2361
+ }
2362
+
2363
+ if (shouldCollapse) {
2073
2364
  this.collapsedNodes.add(range.nodeId);
2074
2365
  }
2075
2366
  }
2076
2367
 
2077
2368
  // Rebuild everything to ensure consistent state after collapse changes
2078
- // This is especially important after paste into empty editor
2079
2369
  this.updateModel();
2080
2370
  this.scheduleRender();
2081
2371
  }
@@ -2119,11 +2409,11 @@ class GeoJsonEditor extends HTMLElement {
2119
2409
  `;
2120
2410
  document.body.appendChild(anchor);
2121
2411
 
2122
- const colorInput = document.createElement('input');
2412
+ const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
2123
2413
  colorInput.type = 'color';
2124
2414
  colorInput.value = currentColor;
2125
2415
  colorInput.className = 'geojson-color-picker-input';
2126
-
2416
+
2127
2417
  // Position the color input inside the anchor
2128
2418
  colorInput.style.cssText = `
2129
2419
  position: absolute;
@@ -2137,18 +2427,18 @@ class GeoJsonEditor extends HTMLElement {
2137
2427
  cursor: pointer;
2138
2428
  `;
2139
2429
  anchor.appendChild(colorInput);
2140
-
2141
- colorInput.addEventListener('input', (e) => {
2142
- this.updateColorValue(line, e.target.value, attributeName);
2430
+
2431
+ colorInput.addEventListener('input', (e: Event) => {
2432
+ this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
2143
2433
  });
2144
-
2145
- const closeOnClickOutside = (e) => {
2434
+
2435
+ const closeOnClickOutside = (e: Event) => {
2146
2436
  if (e.target !== colorInput) {
2147
2437
  document.removeEventListener('click', closeOnClickOutside, true);
2148
2438
  anchor.remove(); // Remove anchor (which contains the input)
2149
2439
  }
2150
2440
  };
2151
-
2441
+
2152
2442
  colorInput._closeListener = closeOnClickOutside;
2153
2443
 
2154
2444
  setTimeout(() => {
@@ -2246,10 +2536,10 @@ class GeoJsonEditor extends HTMLElement {
2246
2536
 
2247
2537
  updateReadonly() {
2248
2538
  const textarea = this.shadowRoot.getElementById('hiddenTextarea');
2249
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
2250
-
2539
+ const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
2540
+
2251
2541
  // Use readOnly instead of disabled to allow text selection for copying
2252
- if (textarea) textarea.readOnly = this.readonly;
2542
+ if (textarea) (textarea as HTMLTextAreaElement).readOnly = this.readonly;
2253
2543
  if (clearBtn) clearBtn.hidden = this.readonly;
2254
2544
  }
2255
2545
 
@@ -2335,13 +2625,13 @@ class GeoJsonEditor extends HTMLElement {
2335
2625
  return `:host-context(${selector})`;
2336
2626
  }
2337
2627
 
2338
- setTheme(theme) {
2628
+ setTheme(theme: ThemeSettings): void {
2339
2629
  if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2340
2630
  if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
2341
2631
  this.updateThemeCSS();
2342
2632
  }
2343
2633
 
2344
- resetTheme() {
2634
+ resetTheme(): void {
2345
2635
  this.themes = { dark: {}, light: {} };
2346
2636
  this.updateThemeCSS();
2347
2637
  }
@@ -2625,28 +2915,135 @@ class GeoJsonEditor extends HTMLElement {
2625
2915
  return errors;
2626
2916
  }
2627
2917
 
2918
+ /**
2919
+ * Validate a single feature object
2920
+ * @param {object} feature - The feature to validate
2921
+ * @throws {Error} If the feature is invalid
2922
+ */
2923
+ _validateFeature(feature) {
2924
+ if (!feature || typeof feature !== 'object') {
2925
+ throw new Error('Feature must be an object');
2926
+ }
2927
+ if (feature.type !== 'Feature') {
2928
+ throw new Error('Feature type must be "Feature"');
2929
+ }
2930
+ if (!('geometry' in feature)) {
2931
+ throw new Error('Feature must have a geometry property');
2932
+ }
2933
+ if (!('properties' in feature)) {
2934
+ throw new Error('Feature must have a properties property');
2935
+ }
2936
+ if (feature.geometry !== null) {
2937
+ if (!feature.geometry || typeof feature.geometry !== 'object') {
2938
+ throw new Error('Feature geometry must be an object or null');
2939
+ }
2940
+ if (!feature.geometry.type) {
2941
+ throw new Error('Feature geometry must have a type');
2942
+ }
2943
+ if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2944
+ throw new Error(`Invalid geometry type: "${feature.geometry.type}"`);
2945
+ }
2946
+ if (!('coordinates' in feature.geometry)) {
2947
+ throw new Error('Feature geometry must have coordinates');
2948
+ }
2949
+ }
2950
+ if (feature.properties !== null && typeof feature.properties !== 'object') {
2951
+ throw new Error('Feature properties must be an object or null');
2952
+ }
2953
+ }
2954
+
2955
+ /**
2956
+ * Normalize input to an array of features
2957
+ * Accepts: FeatureCollection, Feature[], or single Feature
2958
+ * @param {object|array} input - Input to normalize
2959
+ * @returns {array} Array of features
2960
+ * @throws {Error} If input is invalid
2961
+ */
2962
+ _normalizeToFeatures(input) {
2963
+ let features = [];
2964
+
2965
+ if (Array.isArray(input)) {
2966
+ // Array of features
2967
+ features = input;
2968
+ } else if (input && typeof input === 'object') {
2969
+ if (input.type === 'FeatureCollection' && Array.isArray(input.features)) {
2970
+ // FeatureCollection
2971
+ features = input.features;
2972
+ } else if (input.type === 'Feature') {
2973
+ // Single Feature
2974
+ features = [input];
2975
+ } else {
2976
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2977
+ }
2978
+ } else {
2979
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2980
+ }
2981
+
2982
+ // Validate each feature
2983
+ for (const feature of features) {
2984
+ this._validateFeature(feature);
2985
+ }
2986
+
2987
+ return features;
2988
+ }
2989
+
2628
2990
  // ========== Public API ==========
2629
-
2630
- set(features) {
2631
- if (!Array.isArray(features)) throw new Error('set() expects an array');
2991
+
2992
+ /**
2993
+ * Replace all features in the editor
2994
+ * Accepts: FeatureCollection, Feature[], or single Feature
2995
+ * @param {object|array} input - Features to set
2996
+ * @param {object} options - Optional settings
2997
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2998
+ * - string[]: List of attributes to collapse (e.g., ['coordinates', 'geometry'])
2999
+ * - function(feature, index): Returns string[] of attributes to collapse per feature
3000
+ * - Use '$root' to collapse the entire feature
3001
+ * @throws {Error} If input is invalid
3002
+ */
3003
+ set(input: FeatureInput, options: SetOptions = {}): void {
3004
+ const features = this._normalizeToFeatures(input);
2632
3005
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2633
- this.setValue(formatted);
3006
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
3007
+ this._applyCollapsedFromOptions(options, features);
2634
3008
  }
2635
3009
 
2636
- add(feature) {
2637
- const features = this._parseFeatures();
2638
- features.push(feature);
2639
- this.set(features);
3010
+ /**
3011
+ * Add features to the end of the editor
3012
+ * Accepts: FeatureCollection, Feature[], or single Feature
3013
+ * @param {object|array} input - Features to add
3014
+ * @param {object} options - Optional settings
3015
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
3016
+ * @throws {Error} If input is invalid
3017
+ */
3018
+ add(input: FeatureInput, options: SetOptions = {}): void {
3019
+ const newFeatures = this._normalizeToFeatures(input);
3020
+ const existingFeatures = this._parseFeatures();
3021
+ const allFeatures = [...existingFeatures, ...newFeatures];
3022
+ const formatted = allFeatures.map(f => JSON.stringify(f, null, 2)).join(',\n');
3023
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
3024
+ this._applyCollapsedFromOptions(options, allFeatures);
2640
3025
  }
2641
3026
 
2642
- insertAt(feature, index) {
3027
+ /**
3028
+ * Insert features at a specific index
3029
+ * Accepts: FeatureCollection, Feature[], or single Feature
3030
+ * @param {object|array} input - Features to insert
3031
+ * @param {number} index - Index to insert at (negative = from end)
3032
+ * @param {object} options - Optional settings
3033
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
3034
+ * @throws {Error} If input is invalid
3035
+ */
3036
+ insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
3037
+ const newFeatures = this._normalizeToFeatures(input);
2643
3038
  const features = this._parseFeatures();
2644
3039
  const idx = index < 0 ? features.length + index : index;
2645
- features.splice(Math.max(0, Math.min(idx, features.length)), 0, feature);
2646
- this.set(features);
3040
+ features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
3041
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
3042
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
3043
+ this._applyCollapsedFromOptions(options, features);
2647
3044
  }
2648
3045
 
2649
- removeAt(index) {
3046
+ removeAt(index: number): Feature | undefined {
2650
3047
  const features = this._parseFeatures();
2651
3048
  const idx = index < 0 ? features.length + index : index;
2652
3049
  if (idx >= 0 && idx < features.length) {
@@ -2657,7 +3054,7 @@ class GeoJsonEditor extends HTMLElement {
2657
3054
  return undefined;
2658
3055
  }
2659
3056
 
2660
- removeAll() {
3057
+ removeAll(): Feature[] {
2661
3058
  if (this.lines.length > 0) {
2662
3059
  this._saveToHistory('removeAll');
2663
3060
  }
@@ -2672,26 +3069,24 @@ class GeoJsonEditor extends HTMLElement {
2672
3069
  return removed;
2673
3070
  }
2674
3071
 
2675
- get(index) {
3072
+ get(index: number): Feature | undefined {
2676
3073
  const features = this._parseFeatures();
2677
3074
  const idx = index < 0 ? features.length + index : index;
2678
3075
  return features[idx];
2679
3076
  }
2680
3077
 
2681
- getAll() {
3078
+ getAll(): Feature[] {
2682
3079
  return this._parseFeatures();
2683
3080
  }
2684
3081
 
2685
- emit() {
3082
+ emit(): void {
2686
3083
  this.emitChange();
2687
3084
  }
2688
3085
 
2689
3086
  /**
2690
3087
  * Save GeoJSON to a file (triggers download)
2691
- * @param {string} filename - Optional filename (default: 'features.geojson')
2692
- * @returns {boolean} True if save was successful
2693
3088
  */
2694
- save(filename = 'features.geojson') {
3089
+ save(filename: string = 'features.geojson'): boolean {
2695
3090
  try {
2696
3091
  const features = this._parseFeatures();
2697
3092
  const geojson = {
@@ -2719,17 +3114,19 @@ class GeoJsonEditor extends HTMLElement {
2719
3114
  /**
2720
3115
  * Open a GeoJSON file from the client filesystem
2721
3116
  * Note: Available even in readonly mode via API (only Ctrl+O shortcut is blocked)
3117
+ * @param {object} options - Optional settings
3118
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2722
3119
  * @returns {Promise<boolean>} Promise that resolves to true if file was loaded successfully
2723
3120
  */
2724
- open() {
3121
+ open(options: SetOptions = {}): Promise<boolean> {
2725
3122
  return new Promise((resolve) => {
2726
3123
  const input = document.createElement('input');
2727
3124
  input.type = 'file';
2728
3125
  input.accept = '.geojson,.json,application/geo+json,application/json';
2729
3126
  input.style.display = 'none';
2730
3127
 
2731
- input.addEventListener('change', (e) => {
2732
- const file = e.target.files?.[0];
3128
+ input.addEventListener('change', (e: Event) => {
3129
+ const file = (e.target as HTMLInputElement).files?.[0];
2733
3130
  if (!file) {
2734
3131
  document.body.removeChild(input);
2735
3132
  resolve(false);
@@ -2737,34 +3134,17 @@ class GeoJsonEditor extends HTMLElement {
2737
3134
  }
2738
3135
 
2739
3136
  const reader = new FileReader();
2740
- reader.onload = (event) => {
3137
+ reader.onload = (event: ProgressEvent<FileReader>) => {
2741
3138
  try {
2742
- const content = event.target.result;
3139
+ const content = event.target?.result as string;
2743
3140
  const parsed = JSON.parse(content);
2744
3141
 
2745
- // Extract features from various GeoJSON formats
2746
- let features = [];
2747
- if (parsed.type === 'FeatureCollection' && Array.isArray(parsed.features)) {
2748
- features = parsed.features;
2749
- } else if (parsed.type === 'Feature') {
2750
- features = [parsed];
2751
- } else if (Array.isArray(parsed)) {
2752
- features = parsed;
2753
- } else {
2754
- // Invalid GeoJSON structure
2755
- document.body.removeChild(input);
2756
- resolve(false);
2757
- return;
2758
- }
2759
-
2760
- // Validate features
2761
- for (const feature of features) {
2762
- this._validateFeature(feature);
2763
- }
3142
+ // Normalize and validate features
3143
+ const features = this._normalizeToFeatures(parsed);
2764
3144
 
2765
3145
  // Load features into editor
2766
3146
  this._saveToHistory('open');
2767
- this.set(features);
3147
+ this.set(features, options);
2768
3148
  this.clearHistory(); // Clear history after opening new file
2769
3149
  document.body.removeChild(input);
2770
3150
  resolve(true);