@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.
- package/README.md +97 -15
- package/dist/geojson-editor.js +2 -2
- package/package.json +12 -5
- package/src/geojson-editor.css +14 -14
- package/src/{geojson-editor.template.js → geojson-editor.template.ts} +2 -6
- package/src/{geojson-editor.js → geojson-editor.ts} +599 -219
- package/src/vite-env.d.ts +10 -0
- package/types/geojson-editor.d.ts +497 -0
- package/types/geojson-editor.template.d.ts +4 -0
|
@@ -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 =
|
|
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 (
|
|
527
|
-
|
|
528
|
-
const rect =
|
|
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
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2631
|
-
|
|
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
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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
|
-
|
|
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,
|
|
2646
|
-
|
|
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
|
|
3139
|
+
const content = event.target?.result as string;
|
|
2743
3140
|
const parsed = JSON.parse(content);
|
|
2744
3141
|
|
|
2745
|
-
//
|
|
2746
|
-
|
|
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);
|