@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.
- 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} +554 -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,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
|
-
|
|
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
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2631
|
-
|
|
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
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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
|
-
|
|
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,
|
|
2646
|
-
|
|
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
|
|
3094
|
+
const content = event.target?.result as string;
|
|
2743
3095
|
const parsed = JSON.parse(content);
|
|
2744
3096
|
|
|
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
|
-
}
|
|
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);
|