@softwarity/geojson-editor 1.0.15 → 1.0.16

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,73 @@ 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
+ if (direction > 0) {
1831
+ // Move right
1832
+ let pos = this.cursorColumn;
1833
+
1834
+ if (pos >= line.length) {
1835
+ // At end of line, move to start of next line
1836
+ if (this.cursorLine < this.lines.length - 1) {
1837
+ this.cursorLine++;
1838
+ this.cursorColumn = 0;
1839
+ }
1840
+ } else if (isWordChar(line[pos])) {
1841
+ // Inside a word: move to end of word
1842
+ while (pos < line.length && isWordChar(line[pos])) {
1843
+ pos++;
1844
+ }
1845
+ this.cursorColumn = pos;
1846
+ } else {
1847
+ // On non-word char: skip non-word chars only (stop at start of next word)
1848
+ while (pos < line.length && !isWordChar(line[pos])) {
1849
+ pos++;
1850
+ }
1851
+ this.cursorColumn = pos;
1852
+ }
1853
+ } else {
1854
+ // Move left
1855
+ let pos = this.cursorColumn;
1856
+
1857
+ if (pos === 0) {
1858
+ // At start of line, move to end of previous line
1859
+ if (this.cursorLine > 0) {
1860
+ this.cursorLine--;
1861
+ this.cursorColumn = this.lines[this.cursorLine].length;
1862
+ }
1863
+ } else if (pos > 0 && isWordChar(line[pos - 1])) {
1864
+ // Just after a word char: move to start of word
1865
+ while (pos > 0 && isWordChar(line[pos - 1])) {
1866
+ pos--;
1867
+ }
1868
+ this.cursorColumn = pos;
1869
+ } else {
1870
+ // On or after non-word char: skip non-word chars, then skip word
1871
+ while (pos > 0 && !isWordChar(line[pos - 1])) {
1872
+ pos--;
1873
+ }
1874
+ while (pos > 0 && isWordChar(line[pos - 1])) {
1875
+ pos--;
1876
+ }
1877
+ this.cursorColumn = pos;
1878
+ }
1879
+ }
1880
+
1881
+ this._invalidateRenderCache();
1882
+ this._scrollToCursor();
1883
+ this.scheduleRender();
1884
+ }
1885
+
1702
1886
  /**
1703
1887
  * Handle Home/End with optional selection
1704
1888
  */
@@ -1880,18 +2064,30 @@ class GeoJsonEditor extends HTMLElement {
1880
2064
  handlePaste(e) {
1881
2065
  e.preventDefault();
1882
2066
  const text = e.clipboardData.getData('text/plain');
1883
- if (text) {
1884
- const wasEmpty = this.lines.length === 0;
2067
+ if (!text) return;
2068
+
2069
+ const wasEmpty = this.lines.length === 0;
2070
+
2071
+ // Try to parse as GeoJSON and normalize
2072
+ try {
2073
+ const parsed = JSON.parse(text);
2074
+ const features = this._normalizeToFeatures(parsed);
2075
+ // Valid GeoJSON - insert formatted features
2076
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2077
+ this.insertText(formatted);
2078
+ } catch {
2079
+ // Invalid GeoJSON - fallback to raw text insertion
1885
2080
  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();
2081
+ }
2082
+
2083
+ // Auto-collapse coordinates after pasting into empty editor
2084
+ if (wasEmpty && this.lines.length > 0) {
2085
+ // Cancel pending render, collapse first, then render once
2086
+ if (this.renderTimer) {
2087
+ cancelAnimationFrame(this.renderTimer);
2088
+ this.renderTimer = null;
1894
2089
  }
2090
+ this.autoCollapseCoordinates();
1895
2091
  }
1896
2092
  }
1897
2093
 
@@ -2066,16 +2262,65 @@ class GeoJsonEditor extends HTMLElement {
2066
2262
  }
2067
2263
 
2068
2264
  autoCollapseCoordinates() {
2265
+ this._applyCollapsedOption(['coordinates']);
2266
+ }
2267
+
2268
+ /**
2269
+ * Helper to apply collapsed option from API methods
2270
+ * @param {object} options - Options object with optional collapsed property
2271
+ * @param {array} features - Features array for function mode
2272
+ */
2273
+ _applyCollapsedFromOptions(options, features) {
2274
+ const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
2275
+ if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
2276
+ this._applyCollapsedOption(collapsed, features);
2277
+ }
2278
+ }
2279
+
2280
+ /**
2281
+ * Apply collapsed option to nodes
2282
+ * @param {string[]|function} collapsed - Attributes to collapse or function returning them
2283
+ * @param {array} features - Features array for function mode (optional)
2284
+ */
2285
+ _applyCollapsedOption(collapsed, features = null) {
2069
2286
  const ranges = this._findCollapsibleRanges();
2070
2287
 
2288
+ // Group ranges by feature (root nodes)
2289
+ const featureRanges = ranges.filter(r => r.isRootFeature);
2290
+
2291
+ // Determine which attributes to collapse per feature
2071
2292
  for (const range of ranges) {
2072
- if (range.nodeKey === 'coordinates') {
2293
+ let shouldCollapse = false;
2294
+
2295
+ if (typeof collapsed === 'function') {
2296
+ // Find which feature this range belongs to
2297
+ const featureIndex = featureRanges.findIndex(fr =>
2298
+ range.startLine >= fr.startLine && range.endLine <= fr.endLine
2299
+ );
2300
+ const feature = features?.[featureIndex] || null;
2301
+ const collapsedAttrs = collapsed(feature, featureIndex);
2302
+
2303
+ // Check if this range should be collapsed
2304
+ if (range.isRootFeature) {
2305
+ shouldCollapse = collapsedAttrs.includes('$root');
2306
+ } else {
2307
+ shouldCollapse = collapsedAttrs.includes(range.nodeKey);
2308
+ }
2309
+ } else if (Array.isArray(collapsed)) {
2310
+ // Static list
2311
+ if (range.isRootFeature) {
2312
+ shouldCollapse = collapsed.includes('$root');
2313
+ } else {
2314
+ shouldCollapse = collapsed.includes(range.nodeKey);
2315
+ }
2316
+ }
2317
+
2318
+ if (shouldCollapse) {
2073
2319
  this.collapsedNodes.add(range.nodeId);
2074
2320
  }
2075
2321
  }
2076
2322
 
2077
2323
  // Rebuild everything to ensure consistent state after collapse changes
2078
- // This is especially important after paste into empty editor
2079
2324
  this.updateModel();
2080
2325
  this.scheduleRender();
2081
2326
  }
@@ -2119,11 +2364,11 @@ class GeoJsonEditor extends HTMLElement {
2119
2364
  `;
2120
2365
  document.body.appendChild(anchor);
2121
2366
 
2122
- const colorInput = document.createElement('input');
2367
+ const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
2123
2368
  colorInput.type = 'color';
2124
2369
  colorInput.value = currentColor;
2125
2370
  colorInput.className = 'geojson-color-picker-input';
2126
-
2371
+
2127
2372
  // Position the color input inside the anchor
2128
2373
  colorInput.style.cssText = `
2129
2374
  position: absolute;
@@ -2137,18 +2382,18 @@ class GeoJsonEditor extends HTMLElement {
2137
2382
  cursor: pointer;
2138
2383
  `;
2139
2384
  anchor.appendChild(colorInput);
2140
-
2141
- colorInput.addEventListener('input', (e) => {
2142
- this.updateColorValue(line, e.target.value, attributeName);
2385
+
2386
+ colorInput.addEventListener('input', (e: Event) => {
2387
+ this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
2143
2388
  });
2144
-
2145
- const closeOnClickOutside = (e) => {
2389
+
2390
+ const closeOnClickOutside = (e: Event) => {
2146
2391
  if (e.target !== colorInput) {
2147
2392
  document.removeEventListener('click', closeOnClickOutside, true);
2148
2393
  anchor.remove(); // Remove anchor (which contains the input)
2149
2394
  }
2150
2395
  };
2151
-
2396
+
2152
2397
  colorInput._closeListener = closeOnClickOutside;
2153
2398
 
2154
2399
  setTimeout(() => {
@@ -2246,10 +2491,10 @@ class GeoJsonEditor extends HTMLElement {
2246
2491
 
2247
2492
  updateReadonly() {
2248
2493
  const textarea = this.shadowRoot.getElementById('hiddenTextarea');
2249
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
2250
-
2494
+ const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
2495
+
2251
2496
  // Use readOnly instead of disabled to allow text selection for copying
2252
- if (textarea) textarea.readOnly = this.readonly;
2497
+ if (textarea) (textarea as HTMLTextAreaElement).readOnly = this.readonly;
2253
2498
  if (clearBtn) clearBtn.hidden = this.readonly;
2254
2499
  }
2255
2500
 
@@ -2335,13 +2580,13 @@ class GeoJsonEditor extends HTMLElement {
2335
2580
  return `:host-context(${selector})`;
2336
2581
  }
2337
2582
 
2338
- setTheme(theme) {
2583
+ setTheme(theme: ThemeSettings): void {
2339
2584
  if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2340
2585
  if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
2341
2586
  this.updateThemeCSS();
2342
2587
  }
2343
2588
 
2344
- resetTheme() {
2589
+ resetTheme(): void {
2345
2590
  this.themes = { dark: {}, light: {} };
2346
2591
  this.updateThemeCSS();
2347
2592
  }
@@ -2625,28 +2870,135 @@ class GeoJsonEditor extends HTMLElement {
2625
2870
  return errors;
2626
2871
  }
2627
2872
 
2873
+ /**
2874
+ * Validate a single feature object
2875
+ * @param {object} feature - The feature to validate
2876
+ * @throws {Error} If the feature is invalid
2877
+ */
2878
+ _validateFeature(feature) {
2879
+ if (!feature || typeof feature !== 'object') {
2880
+ throw new Error('Feature must be an object');
2881
+ }
2882
+ if (feature.type !== 'Feature') {
2883
+ throw new Error('Feature type must be "Feature"');
2884
+ }
2885
+ if (!('geometry' in feature)) {
2886
+ throw new Error('Feature must have a geometry property');
2887
+ }
2888
+ if (!('properties' in feature)) {
2889
+ throw new Error('Feature must have a properties property');
2890
+ }
2891
+ if (feature.geometry !== null) {
2892
+ if (!feature.geometry || typeof feature.geometry !== 'object') {
2893
+ throw new Error('Feature geometry must be an object or null');
2894
+ }
2895
+ if (!feature.geometry.type) {
2896
+ throw new Error('Feature geometry must have a type');
2897
+ }
2898
+ if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2899
+ throw new Error(`Invalid geometry type: "${feature.geometry.type}"`);
2900
+ }
2901
+ if (!('coordinates' in feature.geometry)) {
2902
+ throw new Error('Feature geometry must have coordinates');
2903
+ }
2904
+ }
2905
+ if (feature.properties !== null && typeof feature.properties !== 'object') {
2906
+ throw new Error('Feature properties must be an object or null');
2907
+ }
2908
+ }
2909
+
2910
+ /**
2911
+ * Normalize input to an array of features
2912
+ * Accepts: FeatureCollection, Feature[], or single Feature
2913
+ * @param {object|array} input - Input to normalize
2914
+ * @returns {array} Array of features
2915
+ * @throws {Error} If input is invalid
2916
+ */
2917
+ _normalizeToFeatures(input) {
2918
+ let features = [];
2919
+
2920
+ if (Array.isArray(input)) {
2921
+ // Array of features
2922
+ features = input;
2923
+ } else if (input && typeof input === 'object') {
2924
+ if (input.type === 'FeatureCollection' && Array.isArray(input.features)) {
2925
+ // FeatureCollection
2926
+ features = input.features;
2927
+ } else if (input.type === 'Feature') {
2928
+ // Single Feature
2929
+ features = [input];
2930
+ } else {
2931
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2932
+ }
2933
+ } else {
2934
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
2935
+ }
2936
+
2937
+ // Validate each feature
2938
+ for (const feature of features) {
2939
+ this._validateFeature(feature);
2940
+ }
2941
+
2942
+ return features;
2943
+ }
2944
+
2628
2945
  // ========== Public API ==========
2629
-
2630
- set(features) {
2631
- if (!Array.isArray(features)) throw new Error('set() expects an array');
2946
+
2947
+ /**
2948
+ * Replace all features in the editor
2949
+ * Accepts: FeatureCollection, Feature[], or single Feature
2950
+ * @param {object|array} input - Features to set
2951
+ * @param {object} options - Optional settings
2952
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2953
+ * - string[]: List of attributes to collapse (e.g., ['coordinates', 'geometry'])
2954
+ * - function(feature, index): Returns string[] of attributes to collapse per feature
2955
+ * - Use '$root' to collapse the entire feature
2956
+ * @throws {Error} If input is invalid
2957
+ */
2958
+ set(input: FeatureInput, options: SetOptions = {}): void {
2959
+ const features = this._normalizeToFeatures(input);
2632
2960
  const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2633
- this.setValue(formatted);
2961
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
2962
+ this._applyCollapsedFromOptions(options, features);
2634
2963
  }
2635
2964
 
2636
- add(feature) {
2637
- const features = this._parseFeatures();
2638
- features.push(feature);
2639
- this.set(features);
2965
+ /**
2966
+ * Add features to the end of the editor
2967
+ * Accepts: FeatureCollection, Feature[], or single Feature
2968
+ * @param {object|array} input - Features to add
2969
+ * @param {object} options - Optional settings
2970
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2971
+ * @throws {Error} If input is invalid
2972
+ */
2973
+ add(input: FeatureInput, options: SetOptions = {}): void {
2974
+ const newFeatures = this._normalizeToFeatures(input);
2975
+ const existingFeatures = this._parseFeatures();
2976
+ const allFeatures = [...existingFeatures, ...newFeatures];
2977
+ const formatted = allFeatures.map(f => JSON.stringify(f, null, 2)).join(',\n');
2978
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
2979
+ this._applyCollapsedFromOptions(options, allFeatures);
2640
2980
  }
2641
2981
 
2642
- insertAt(feature, index) {
2982
+ /**
2983
+ * Insert features at a specific index
2984
+ * Accepts: FeatureCollection, Feature[], or single Feature
2985
+ * @param {object|array} input - Features to insert
2986
+ * @param {number} index - Index to insert at (negative = from end)
2987
+ * @param {object} options - Optional settings
2988
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2989
+ * @throws {Error} If input is invalid
2990
+ */
2991
+ insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
2992
+ const newFeatures = this._normalizeToFeatures(input);
2643
2993
  const features = this._parseFeatures();
2644
2994
  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);
2995
+ features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
2996
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2997
+ this.setValue(formatted, false); // Don't auto-collapse coordinates
2998
+ this._applyCollapsedFromOptions(options, features);
2647
2999
  }
2648
3000
 
2649
- removeAt(index) {
3001
+ removeAt(index: number): Feature | undefined {
2650
3002
  const features = this._parseFeatures();
2651
3003
  const idx = index < 0 ? features.length + index : index;
2652
3004
  if (idx >= 0 && idx < features.length) {
@@ -2657,7 +3009,7 @@ class GeoJsonEditor extends HTMLElement {
2657
3009
  return undefined;
2658
3010
  }
2659
3011
 
2660
- removeAll() {
3012
+ removeAll(): Feature[] {
2661
3013
  if (this.lines.length > 0) {
2662
3014
  this._saveToHistory('removeAll');
2663
3015
  }
@@ -2672,26 +3024,24 @@ class GeoJsonEditor extends HTMLElement {
2672
3024
  return removed;
2673
3025
  }
2674
3026
 
2675
- get(index) {
3027
+ get(index: number): Feature | undefined {
2676
3028
  const features = this._parseFeatures();
2677
3029
  const idx = index < 0 ? features.length + index : index;
2678
3030
  return features[idx];
2679
3031
  }
2680
3032
 
2681
- getAll() {
3033
+ getAll(): Feature[] {
2682
3034
  return this._parseFeatures();
2683
3035
  }
2684
3036
 
2685
- emit() {
3037
+ emit(): void {
2686
3038
  this.emitChange();
2687
3039
  }
2688
3040
 
2689
3041
  /**
2690
3042
  * 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
3043
  */
2694
- save(filename = 'features.geojson') {
3044
+ save(filename: string = 'features.geojson'): boolean {
2695
3045
  try {
2696
3046
  const features = this._parseFeatures();
2697
3047
  const geojson = {
@@ -2719,17 +3069,19 @@ class GeoJsonEditor extends HTMLElement {
2719
3069
  /**
2720
3070
  * Open a GeoJSON file from the client filesystem
2721
3071
  * Note: Available even in readonly mode via API (only Ctrl+O shortcut is blocked)
3072
+ * @param {object} options - Optional settings
3073
+ * @param {string[]|function} options.collapsed - Attributes to collapse (default: ['coordinates'])
2722
3074
  * @returns {Promise<boolean>} Promise that resolves to true if file was loaded successfully
2723
3075
  */
2724
- open() {
3076
+ open(options: SetOptions = {}): Promise<boolean> {
2725
3077
  return new Promise((resolve) => {
2726
3078
  const input = document.createElement('input');
2727
3079
  input.type = 'file';
2728
3080
  input.accept = '.geojson,.json,application/geo+json,application/json';
2729
3081
  input.style.display = 'none';
2730
3082
 
2731
- input.addEventListener('change', (e) => {
2732
- const file = e.target.files?.[0];
3083
+ input.addEventListener('change', (e: Event) => {
3084
+ const file = (e.target as HTMLInputElement).files?.[0];
2733
3085
  if (!file) {
2734
3086
  document.body.removeChild(input);
2735
3087
  resolve(false);
@@ -2737,34 +3089,17 @@ class GeoJsonEditor extends HTMLElement {
2737
3089
  }
2738
3090
 
2739
3091
  const reader = new FileReader();
2740
- reader.onload = (event) => {
3092
+ reader.onload = (event: ProgressEvent<FileReader>) => {
2741
3093
  try {
2742
- const content = event.target.result;
3094
+ const content = event.target?.result as string;
2743
3095
  const parsed = JSON.parse(content);
2744
3096
 
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
- }
3097
+ // Normalize and validate features
3098
+ const features = this._normalizeToFeatures(parsed);
2764
3099
 
2765
3100
  // Load features into editor
2766
3101
  this._saveToHistory('open');
2767
- this.set(features);
3102
+ this.set(features, options);
2768
3103
  this.clearHistory(); // Clear history after opening new file
2769
3104
  document.body.removeChild(input);
2770
3105
  resolve(true);