@softwarity/geojson-editor 1.0.14 → 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 +194 -16
- package/dist/geojson-editor.js +2 -2
- package/package.json +12 -5
- package/src/geojson-editor.css +15 -83
- package/src/{geojson-editor.template.js → geojson-editor.template.ts} +2 -6
- package/src/{geojson-editor.js → geojson-editor.ts} +1113 -506
- 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,61 +1,377 @@
|
|
|
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'];
|
|
152
|
+
|
|
153
|
+
// Pre-compiled regex patterns for performance (avoid re-creation on each call)
|
|
154
|
+
const RE_CONTEXT_GEOMETRY = /"geometry"\s*:/;
|
|
155
|
+
const RE_CONTEXT_PROPERTIES = /"properties"\s*:/;
|
|
156
|
+
const RE_CONTEXT_FEATURES = /"features"\s*:/;
|
|
157
|
+
const RE_COLLAPSED_BRACKET = /^(\s*"[^"]+"\s*:\s*)([{\[])/;
|
|
158
|
+
const RE_COLLAPSED_ROOT = /^(\s*)([{\[]),?\s*$/;
|
|
159
|
+
const RE_ESCAPE_AMP = /&/g;
|
|
160
|
+
const RE_ESCAPE_LT = /</g;
|
|
161
|
+
const RE_ESCAPE_GT = />/g;
|
|
162
|
+
const RE_PUNCTUATION = /([{}[\],:])/g;
|
|
163
|
+
const RE_JSON_KEYS = /"([^"]+)"(<span class="json-punctuation">:<\/span>)/g;
|
|
164
|
+
const RE_TYPE_VALUES = /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>(\s*)"([^"]*)"/g;
|
|
165
|
+
const RE_STRING_VALUES = /(<span class="json-punctuation">:<\/span>)(\s*)"([^"]*)"/g;
|
|
166
|
+
const RE_COLOR_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
167
|
+
const RE_NUMBERS_COLON = /(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
|
|
168
|
+
const RE_NUMBERS_ARRAY = /(<span class="json-punctuation">[\[,]<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
|
|
169
|
+
const RE_NUMBERS_START = /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim;
|
|
170
|
+
const RE_BOOLEANS = /(<span class="json-punctuation">:<\/span>)(\s*)(true|false)/g;
|
|
171
|
+
const RE_NULL = /(<span class="json-punctuation">:<\/span>)(\s*)(null)/g;
|
|
172
|
+
const RE_UNRECOGNIZED = /(<\/span>|^)([^<]+)(<span|$)/g;
|
|
173
|
+
const RE_WHITESPACE_ONLY = /^\s*$/;
|
|
174
|
+
const RE_WHITESPACE_SPLIT = /(\s+)/;
|
|
10
175
|
|
|
11
176
|
/**
|
|
12
177
|
* GeoJSON Editor Web Component
|
|
13
178
|
* Monaco-like architecture with virtualized line rendering
|
|
14
179
|
*/
|
|
15
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
|
+
|
|
16
238
|
constructor() {
|
|
17
239
|
super();
|
|
18
240
|
this.attachShadow({ mode: 'open' });
|
|
241
|
+
}
|
|
19
242
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.collapsedNodes = new Set(); // Set of unique node IDs that are collapsed
|
|
23
|
-
this.hiddenFeatures = new Set(); // Set of feature keys hidden from events
|
|
24
|
-
|
|
25
|
-
// ========== Node ID Management ==========
|
|
26
|
-
this._nodeIdCounter = 0; // Counter for generating unique node IDs
|
|
27
|
-
this._lineToNodeId = new Map(); // lineIndex -> nodeId (for collapsible lines)
|
|
28
|
-
this._nodeIdToLines = new Map(); // nodeId -> {startLine, endLine} (range of collapsed content)
|
|
29
|
-
|
|
30
|
-
// ========== Derived State (computed from model) ==========
|
|
31
|
-
this.visibleLines = []; // Lines to render (after collapse filter)
|
|
32
|
-
this.lineMetadata = new Map(); // lineIndex -> {colors, booleans, collapse, visibility, hidden, featureKey}
|
|
33
|
-
this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
|
|
34
|
-
|
|
35
|
-
// ========== View State ==========
|
|
36
|
-
this.scrollTop = 0;
|
|
37
|
-
this.viewportHeight = 0;
|
|
38
|
-
this.lineHeight = 19.5; // CSS: line-height * font-size = 1.5 * 13px
|
|
39
|
-
this.bufferLines = 5; // Extra lines to render above/below viewport
|
|
40
|
-
|
|
41
|
-
// ========== Render Cache ==========
|
|
243
|
+
// ========== Render Cache ==========
|
|
244
|
+
_invalidateRenderCache() {
|
|
42
245
|
this._lastStartIndex = -1;
|
|
43
246
|
this._lastEndIndex = -1;
|
|
44
247
|
this._lastTotalLines = -1;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ========== Undo/Redo System ==========
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create a snapshot of current editor state
|
|
254
|
+
* @returns {Object} State snapshot
|
|
255
|
+
*/
|
|
256
|
+
_createSnapshot() {
|
|
257
|
+
return {
|
|
258
|
+
lines: [...this.lines],
|
|
259
|
+
cursorLine: this.cursorLine,
|
|
260
|
+
cursorColumn: this.cursorColumn,
|
|
261
|
+
timestamp: Date.now()
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Restore editor state from snapshot
|
|
267
|
+
* @param {Object} snapshot - State to restore
|
|
268
|
+
*/
|
|
269
|
+
_restoreSnapshot(snapshot) {
|
|
270
|
+
this.lines = [...snapshot.lines];
|
|
271
|
+
this.cursorLine = snapshot.cursorLine;
|
|
272
|
+
this.cursorColumn = snapshot.cursorColumn;
|
|
273
|
+
this.updateModel();
|
|
274
|
+
this._invalidateRenderCache();
|
|
275
|
+
this.scheduleRender();
|
|
276
|
+
this.updatePlaceholderVisibility();
|
|
277
|
+
this.emitChange();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Save current state to undo stack before making changes
|
|
282
|
+
* @param {string} actionType - Type of action (insert, delete, paste, etc.)
|
|
283
|
+
*/
|
|
284
|
+
_saveToHistory(actionType = 'edit') {
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
const shouldGroup = (
|
|
287
|
+
actionType === this._lastActionType &&
|
|
288
|
+
(now - this._lastActionTime) < this._groupingDelay
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// If same action type within grouping delay, don't create new entry
|
|
292
|
+
if (!shouldGroup) {
|
|
293
|
+
const snapshot = this._createSnapshot();
|
|
294
|
+
this._undoStack.push(snapshot);
|
|
295
|
+
|
|
296
|
+
// Limit stack size
|
|
297
|
+
if (this._undoStack.length > this._maxHistorySize) {
|
|
298
|
+
this._undoStack.shift();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Clear redo stack on new action
|
|
302
|
+
this._redoStack = [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this._lastActionTime = now;
|
|
306
|
+
this._lastActionType = actionType;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Undo last action
|
|
311
|
+
* @returns {boolean} True if undo was performed
|
|
312
|
+
*/
|
|
313
|
+
undo() {
|
|
314
|
+
if (this._undoStack.length === 0) return false;
|
|
315
|
+
|
|
316
|
+
// Save current state to redo stack
|
|
317
|
+
this._redoStack.push(this._createSnapshot());
|
|
318
|
+
|
|
319
|
+
// Restore previous state
|
|
320
|
+
const previousState = this._undoStack.pop();
|
|
321
|
+
this._restoreSnapshot(previousState);
|
|
322
|
+
|
|
323
|
+
// Reset action tracking
|
|
324
|
+
this._lastActionType = null;
|
|
325
|
+
this._lastActionTime = 0;
|
|
326
|
+
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Redo previously undone action
|
|
332
|
+
* @returns {boolean} True if redo was performed
|
|
333
|
+
*/
|
|
334
|
+
redo() {
|
|
335
|
+
if (this._redoStack.length === 0) return false;
|
|
336
|
+
|
|
337
|
+
// Save current state to undo stack
|
|
338
|
+
this._undoStack.push(this._createSnapshot());
|
|
339
|
+
|
|
340
|
+
// Restore next state
|
|
341
|
+
const nextState = this._redoStack.pop();
|
|
342
|
+
this._restoreSnapshot(nextState);
|
|
343
|
+
|
|
344
|
+
// Reset action tracking
|
|
345
|
+
this._lastActionType = null;
|
|
346
|
+
this._lastActionTime = 0;
|
|
347
|
+
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Clear undo/redo history
|
|
353
|
+
*/
|
|
354
|
+
clearHistory() {
|
|
355
|
+
this._undoStack = [];
|
|
356
|
+
this._redoStack = [];
|
|
357
|
+
this._lastActionType = null;
|
|
358
|
+
this._lastActionTime = 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Check if undo is available
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
canUndo() {
|
|
366
|
+
return this._undoStack.length > 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Check if redo is available
|
|
371
|
+
* @returns {boolean}
|
|
372
|
+
*/
|
|
373
|
+
canRedo() {
|
|
374
|
+
return this._redoStack.length > 0;
|
|
59
375
|
}
|
|
60
376
|
|
|
61
377
|
// ========== Unique ID Generation ==========
|
|
@@ -162,14 +478,16 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
162
478
|
* @param {Object} range - The range info {startLine, endLine}
|
|
163
479
|
*/
|
|
164
480
|
_deleteCollapsedNode(range) {
|
|
481
|
+
this._saveToHistory('delete');
|
|
482
|
+
|
|
165
483
|
// Remove all lines from startLine to endLine
|
|
166
484
|
const count = range.endLine - range.startLine + 1;
|
|
167
485
|
this.lines.splice(range.startLine, count);
|
|
168
|
-
|
|
486
|
+
|
|
169
487
|
// Position cursor at the line where the node was
|
|
170
488
|
this.cursorLine = Math.min(range.startLine, this.lines.length - 1);
|
|
171
489
|
this.cursorColumn = 0;
|
|
172
|
-
|
|
490
|
+
|
|
173
491
|
this.formatAndUpdate();
|
|
174
492
|
}
|
|
175
493
|
|
|
@@ -269,12 +587,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
269
587
|
this.updatePlaceholderVisibility();
|
|
270
588
|
}
|
|
271
589
|
|
|
272
|
-
disconnectedCallback() {
|
|
590
|
+
disconnectedCallback(): void {
|
|
273
591
|
if (this.renderTimer) clearTimeout(this.renderTimer);
|
|
274
592
|
if (this.inputTimer) clearTimeout(this.inputTimer);
|
|
275
|
-
|
|
593
|
+
|
|
276
594
|
// Cleanup color picker
|
|
277
|
-
const colorPicker = document.querySelector('.geojson-color-picker-input');
|
|
595
|
+
const colorPicker = document.querySelector('.geojson-color-picker-input') as HTMLInputElement & { _closeListener?: EventListener };
|
|
278
596
|
if (colorPicker) {
|
|
279
597
|
if (colorPicker._closeListener) {
|
|
280
598
|
document.removeEventListener('click', colorPicker._closeListener, true);
|
|
@@ -343,9 +661,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
343
661
|
this.handleEditorClick(e);
|
|
344
662
|
}, true);
|
|
345
663
|
|
|
346
|
-
viewport.addEventListener('mousedown', (e) => {
|
|
664
|
+
viewport.addEventListener('mousedown', (e: MouseEvent) => {
|
|
665
|
+
const target = e.target as HTMLElement;
|
|
347
666
|
// Skip if clicking on visibility pseudo-element (line-level)
|
|
348
|
-
const lineEl =
|
|
667
|
+
const lineEl = target.closest('.line.has-visibility');
|
|
349
668
|
if (lineEl) {
|
|
350
669
|
const rect = lineEl.getBoundingClientRect();
|
|
351
670
|
const clickX = e.clientX - rect.left;
|
|
@@ -357,9 +676,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
357
676
|
}
|
|
358
677
|
|
|
359
678
|
// Skip if clicking on an inline control pseudo-element (positioned with negative left)
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
const rect =
|
|
679
|
+
if (target.classList.contains('json-color') ||
|
|
680
|
+
target.classList.contains('json-boolean')) {
|
|
681
|
+
const rect = target.getBoundingClientRect();
|
|
363
682
|
const clickX = e.clientX - rect.left;
|
|
364
683
|
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
365
684
|
if (clickX < 0 && clickX >= -8) {
|
|
@@ -391,7 +710,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
391
710
|
|
|
392
711
|
// Focus textarea
|
|
393
712
|
hiddenTextarea.focus();
|
|
394
|
-
this.
|
|
713
|
+
this._invalidateRenderCache();
|
|
395
714
|
this.scheduleRender();
|
|
396
715
|
});
|
|
397
716
|
|
|
@@ -417,7 +736,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
417
736
|
viewport.scrollTop += scrollSpeed;
|
|
418
737
|
}
|
|
419
738
|
|
|
420
|
-
this.
|
|
739
|
+
this._invalidateRenderCache();
|
|
421
740
|
this.scheduleRender();
|
|
422
741
|
});
|
|
423
742
|
|
|
@@ -429,13 +748,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
429
748
|
// Focus/blur handling to show/hide cursor
|
|
430
749
|
hiddenTextarea.addEventListener('focus', () => {
|
|
431
750
|
editorWrapper.classList.add('focused');
|
|
432
|
-
this.
|
|
751
|
+
this._invalidateRenderCache(); // Force re-render to show cursor
|
|
433
752
|
this.scheduleRender();
|
|
434
753
|
});
|
|
435
754
|
|
|
436
755
|
hiddenTextarea.addEventListener('blur', () => {
|
|
437
756
|
editorWrapper.classList.remove('focused');
|
|
438
|
-
this.
|
|
757
|
+
this._invalidateRenderCache(); // Force re-render to hide cursor
|
|
439
758
|
this.scheduleRender();
|
|
440
759
|
});
|
|
441
760
|
|
|
@@ -443,7 +762,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
443
762
|
let isRendering = false;
|
|
444
763
|
viewport.addEventListener('scroll', () => {
|
|
445
764
|
if (isRendering) return;
|
|
446
|
-
this.scrollTop = viewport.scrollTop;
|
|
447
765
|
this.syncGutterScroll();
|
|
448
766
|
|
|
449
767
|
// Use requestAnimationFrame to batch scroll updates
|
|
@@ -505,7 +823,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
505
823
|
});
|
|
506
824
|
|
|
507
825
|
// Wheel on gutter -> scroll viewport
|
|
508
|
-
gutter.addEventListener('wheel', (e) => {
|
|
826
|
+
gutter.addEventListener('wheel', (e: WheelEvent) => {
|
|
509
827
|
e.preventDefault();
|
|
510
828
|
viewport.scrollTop += e.deltaY;
|
|
511
829
|
});
|
|
@@ -524,7 +842,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
524
842
|
/**
|
|
525
843
|
* Set the editor content from a string value
|
|
526
844
|
*/
|
|
527
|
-
setValue(value) {
|
|
845
|
+
setValue(value, autoCollapse = true) {
|
|
846
|
+
// Save to history only if there's existing content
|
|
847
|
+
if (this.lines.length > 0) {
|
|
848
|
+
this._saveToHistory('setValue');
|
|
849
|
+
}
|
|
850
|
+
|
|
528
851
|
if (!value || !value.trim()) {
|
|
529
852
|
this.lines = [];
|
|
530
853
|
} else {
|
|
@@ -541,7 +864,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
541
864
|
this.lines = value.split('\n');
|
|
542
865
|
}
|
|
543
866
|
}
|
|
544
|
-
|
|
867
|
+
|
|
545
868
|
// Clear state for new content
|
|
546
869
|
this.collapsedNodes.clear();
|
|
547
870
|
this.hiddenFeatures.clear();
|
|
@@ -549,18 +872,18 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
549
872
|
this._nodeIdToLines.clear();
|
|
550
873
|
this.cursorLine = 0;
|
|
551
874
|
this.cursorColumn = 0;
|
|
552
|
-
|
|
875
|
+
|
|
553
876
|
this.updateModel();
|
|
554
877
|
this.scheduleRender();
|
|
555
878
|
this.updatePlaceholderVisibility();
|
|
556
|
-
|
|
557
|
-
// Auto-collapse coordinates
|
|
558
|
-
if (this.lines.length > 0) {
|
|
879
|
+
|
|
880
|
+
// Auto-collapse coordinates (unless disabled)
|
|
881
|
+
if (autoCollapse && this.lines.length > 0) {
|
|
559
882
|
requestAnimationFrame(() => {
|
|
560
883
|
this.autoCollapseCoordinates();
|
|
561
884
|
});
|
|
562
885
|
}
|
|
563
|
-
|
|
886
|
+
|
|
564
887
|
this.emitChange();
|
|
565
888
|
}
|
|
566
889
|
|
|
@@ -576,9 +899,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
576
899
|
* Rebuilds line-to-nodeId mapping while preserving collapsed state
|
|
577
900
|
*/
|
|
578
901
|
updateModel() {
|
|
902
|
+
// Invalidate context map cache since content changed
|
|
903
|
+
this._contextMapCache = null;
|
|
904
|
+
|
|
579
905
|
// Rebuild lineToNodeId mapping (may shift due to edits)
|
|
580
906
|
this._rebuildNodeIdMappings();
|
|
581
|
-
|
|
907
|
+
|
|
582
908
|
this.computeFeatureRanges();
|
|
583
909
|
this.computeLineMetadata();
|
|
584
910
|
this.computeVisibleLines();
|
|
@@ -757,7 +1083,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
757
1083
|
}
|
|
758
1084
|
|
|
759
1085
|
// Reset render cache to force re-render
|
|
760
|
-
this.
|
|
1086
|
+
this._invalidateRenderCache();
|
|
761
1087
|
this._lastEndIndex = -1;
|
|
762
1088
|
this._lastTotalLines = -1;
|
|
763
1089
|
}
|
|
@@ -845,7 +1171,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
845
1171
|
|
|
846
1172
|
const lineEl = document.createElement('div');
|
|
847
1173
|
lineEl.className = 'line';
|
|
848
|
-
lineEl.dataset.lineIndex = lineData.index;
|
|
1174
|
+
lineEl.dataset.lineIndex = String(lineData.index);
|
|
849
1175
|
|
|
850
1176
|
// Add visibility button on line (uses ::before pseudo-element)
|
|
851
1177
|
if (lineData.meta?.visibilityButton) {
|
|
@@ -980,7 +1306,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
980
1306
|
// Line number first
|
|
981
1307
|
const lineNum = document.createElement('span');
|
|
982
1308
|
lineNum.className = 'line-number';
|
|
983
|
-
lineNum.textContent = lineData.index + 1;
|
|
1309
|
+
lineNum.textContent = String(lineData.index + 1);
|
|
984
1310
|
gutterLine.appendChild(lineNum);
|
|
985
1311
|
|
|
986
1312
|
// Collapse column (always present for alignment)
|
|
@@ -990,7 +1316,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
990
1316
|
const btn = document.createElement('div');
|
|
991
1317
|
btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
|
|
992
1318
|
btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
|
|
993
|
-
btn.dataset.line = lineData.index;
|
|
1319
|
+
btn.dataset.line = String(lineData.index);
|
|
994
1320
|
btn.dataset.nodeId = meta.collapseButton.nodeId;
|
|
995
1321
|
btn.title = meta.collapseButton.isCollapsed ? 'Expand' : 'Collapse';
|
|
996
1322
|
collapseCol.appendChild(btn);
|
|
@@ -1014,9 +1340,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1014
1340
|
}
|
|
1015
1341
|
|
|
1016
1342
|
// ========== Input Handling ==========
|
|
1017
|
-
|
|
1018
|
-
handleInput() {
|
|
1019
|
-
const textarea = this.shadowRoot
|
|
1343
|
+
|
|
1344
|
+
handleInput(): void {
|
|
1345
|
+
const textarea = this.shadowRoot!.getElementById('hiddenTextarea') as HTMLTextAreaElement;
|
|
1020
1346
|
const inputValue = textarea.value;
|
|
1021
1347
|
|
|
1022
1348
|
if (!inputValue) return;
|
|
@@ -1090,183 +1416,166 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1090
1416
|
}
|
|
1091
1417
|
|
|
1092
1418
|
handleKeydown(e) {
|
|
1093
|
-
//
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1419
|
+
// Build context for collapsed zone detection
|
|
1420
|
+
const ctx = {
|
|
1421
|
+
inCollapsedZone: this._getCollapsedRangeForLine(this.cursorLine),
|
|
1422
|
+
onCollapsedNode: this._getCollapsedNodeAtLine(this.cursorLine),
|
|
1423
|
+
onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
|
|
1424
|
+
};
|
|
1425
|
+
|
|
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]();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
_handleEnter(ctx) {
|
|
1464
|
+
// Block in collapsed zones
|
|
1465
|
+
if (ctx.onCollapsedNode || ctx.inCollapsedZone) return;
|
|
1466
|
+
// On closing line, before bracket -> block
|
|
1467
|
+
if (ctx.onClosingLine) {
|
|
1468
|
+
const line = this.lines[this.cursorLine];
|
|
1469
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1470
|
+
if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
this.insertNewline();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
_handleBackspace(ctx) {
|
|
1478
|
+
// Delete selection if any
|
|
1479
|
+
if (this._hasSelection()) {
|
|
1480
|
+
this._deleteSelection();
|
|
1481
|
+
this.formatAndUpdate();
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// On closing line
|
|
1485
|
+
if (ctx.onClosingLine) {
|
|
1486
|
+
const line = this.lines[this.cursorLine];
|
|
1487
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1488
|
+
if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
|
|
1156
1489
|
this.deleteBackward();
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
this._deleteCollapsedNode(ctx.onClosingLine);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
// If on collapsed node opening line at position 0, delete whole node
|
|
1496
|
+
if (ctx.onCollapsedNode && this.cursorColumn === 0) {
|
|
1497
|
+
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
// Block inside collapsed zones
|
|
1501
|
+
if (ctx.inCollapsedZone) return;
|
|
1502
|
+
// On opening line, allow editing before bracket
|
|
1503
|
+
if (ctx.onCollapsedNode) {
|
|
1504
|
+
const line = this.lines[this.cursorLine];
|
|
1505
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1506
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1507
|
+
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
this.deleteBackward();
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
_handleDelete(ctx) {
|
|
1515
|
+
// Delete selection if any
|
|
1516
|
+
if (this._hasSelection()) {
|
|
1517
|
+
this._deleteSelection();
|
|
1518
|
+
this.formatAndUpdate();
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
// On closing line
|
|
1522
|
+
if (ctx.onClosingLine) {
|
|
1523
|
+
const line = this.lines[this.cursorLine];
|
|
1524
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1525
|
+
if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
|
|
1192
1526
|
this.deleteForward();
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const startLine = this.lines[containingNode.startLine];
|
|
1235
|
-
const bracketPos = startLine.search(/[{\[]/);
|
|
1236
|
-
|
|
1237
|
-
this.toggleCollapse(containingNode.nodeId);
|
|
1238
|
-
|
|
1239
|
-
// Move cursor to just after the opening bracket
|
|
1240
|
-
this.cursorLine = containingNode.startLine;
|
|
1241
|
-
this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
|
|
1242
|
-
this._clearSelection();
|
|
1243
|
-
this._scrollToCursor();
|
|
1244
|
-
}
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// Tab: expand collapsed node if on one
|
|
1249
|
-
if (onCollapsedNode) {
|
|
1250
|
-
this.toggleCollapse(onCollapsedNode.nodeId);
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
if (onClosingLine) {
|
|
1254
|
-
this.toggleCollapse(onClosingLine.nodeId);
|
|
1255
|
-
return;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// Block in hidden collapsed zones
|
|
1259
|
-
if (inCollapsedZone) return;
|
|
1260
|
-
break;
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
this._deleteCollapsedNode(ctx.onClosingLine);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
// If on collapsed node opening line
|
|
1533
|
+
if (ctx.onCollapsedNode) {
|
|
1534
|
+
const line = this.lines[this.cursorLine];
|
|
1535
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1536
|
+
if (this.cursorColumn > bracketPos) {
|
|
1537
|
+
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
// Block inside collapsed zones
|
|
1542
|
+
if (ctx.inCollapsedZone) return;
|
|
1543
|
+
this.deleteForward();
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
_handleTab(isShiftKey, ctx) {
|
|
1547
|
+
// Shift+Tab: collapse the containing expanded node
|
|
1548
|
+
if (isShiftKey) {
|
|
1549
|
+
const containingNode = this._getContainingExpandedNode(this.cursorLine);
|
|
1550
|
+
if (containingNode) {
|
|
1551
|
+
const startLine = this.lines[containingNode.startLine];
|
|
1552
|
+
const bracketPos = startLine.search(/[{\[]/);
|
|
1553
|
+
this.toggleCollapse(containingNode.nodeId);
|
|
1554
|
+
this.cursorLine = containingNode.startLine;
|
|
1555
|
+
this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
|
|
1556
|
+
this._clearSelection();
|
|
1557
|
+
this._scrollToCursor();
|
|
1558
|
+
}
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
// Tab: expand collapsed node if on one
|
|
1562
|
+
if (ctx.onCollapsedNode) {
|
|
1563
|
+
this.toggleCollapse(ctx.onCollapsedNode.nodeId);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (ctx.onClosingLine) {
|
|
1567
|
+
this.toggleCollapse(ctx.onClosingLine.nodeId);
|
|
1261
1568
|
}
|
|
1262
1569
|
}
|
|
1263
1570
|
|
|
1264
1571
|
insertNewline() {
|
|
1572
|
+
this._saveToHistory('newline');
|
|
1573
|
+
|
|
1265
1574
|
if (this.cursorLine < this.lines.length) {
|
|
1266
1575
|
const line = this.lines[this.cursorLine];
|
|
1267
1576
|
const before = line.substring(0, this.cursorColumn);
|
|
1268
1577
|
const after = line.substring(this.cursorColumn);
|
|
1269
|
-
|
|
1578
|
+
|
|
1270
1579
|
this.lines[this.cursorLine] = before;
|
|
1271
1580
|
this.lines.splice(this.cursorLine + 1, 0, after);
|
|
1272
1581
|
this.cursorLine++;
|
|
@@ -1276,11 +1585,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1276
1585
|
this.cursorLine = this.lines.length - 1;
|
|
1277
1586
|
this.cursorColumn = 0;
|
|
1278
1587
|
}
|
|
1279
|
-
|
|
1588
|
+
|
|
1280
1589
|
this.formatAndUpdate();
|
|
1281
1590
|
}
|
|
1282
1591
|
|
|
1283
1592
|
deleteBackward() {
|
|
1593
|
+
this._saveToHistory('delete');
|
|
1594
|
+
|
|
1284
1595
|
if (this.cursorColumn > 0) {
|
|
1285
1596
|
const line = this.lines[this.cursorLine];
|
|
1286
1597
|
this.lines[this.cursorLine] = line.substring(0, this.cursorColumn - 1) + line.substring(this.cursorColumn);
|
|
@@ -1294,11 +1605,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1294
1605
|
this.lines.splice(this.cursorLine, 1);
|
|
1295
1606
|
this.cursorLine--;
|
|
1296
1607
|
}
|
|
1297
|
-
|
|
1608
|
+
|
|
1298
1609
|
this.formatAndUpdate();
|
|
1299
1610
|
}
|
|
1300
1611
|
|
|
1301
1612
|
deleteForward() {
|
|
1613
|
+
this._saveToHistory('delete');
|
|
1614
|
+
|
|
1302
1615
|
if (this.cursorLine < this.lines.length) {
|
|
1303
1616
|
const line = this.lines[this.cursorLine];
|
|
1304
1617
|
if (this.cursorColumn < line.length) {
|
|
@@ -1309,7 +1622,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1309
1622
|
this.lines.splice(this.cursorLine + 1, 1);
|
|
1310
1623
|
}
|
|
1311
1624
|
}
|
|
1312
|
-
|
|
1625
|
+
|
|
1313
1626
|
this.formatAndUpdate();
|
|
1314
1627
|
}
|
|
1315
1628
|
|
|
@@ -1339,7 +1652,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1339
1652
|
const maxCol = this.lines[this.cursorLine]?.length || 0;
|
|
1340
1653
|
this.cursorColumn = Math.min(this.cursorColumn, maxCol);
|
|
1341
1654
|
|
|
1342
|
-
this.
|
|
1655
|
+
this._invalidateRenderCache();
|
|
1343
1656
|
this._scrollToCursor();
|
|
1344
1657
|
this.scheduleRender();
|
|
1345
1658
|
}
|
|
@@ -1348,124 +1661,103 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1348
1661
|
* Move cursor horizontally with smart navigation around collapsed nodes
|
|
1349
1662
|
*/
|
|
1350
1663
|
moveCursorHorizontal(delta) {
|
|
1664
|
+
if (delta > 0) {
|
|
1665
|
+
this._moveCursorRight();
|
|
1666
|
+
} else {
|
|
1667
|
+
this._moveCursorLeft();
|
|
1668
|
+
}
|
|
1669
|
+
this._invalidateRenderCache();
|
|
1670
|
+
this._scrollToCursor();
|
|
1671
|
+
this.scheduleRender();
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
_moveCursorRight() {
|
|
1351
1675
|
const line = this.lines[this.cursorLine];
|
|
1352
1676
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1353
1677
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1354
|
-
|
|
1355
|
-
if (
|
|
1356
|
-
|
|
1357
|
-
if (
|
|
1358
|
-
|
|
1359
|
-
if (this.cursorColumn < bracketPos) {
|
|
1360
|
-
// Before bracket, jump to bracket
|
|
1361
|
-
this.cursorColumn = bracketPos;
|
|
1362
|
-
} else if (this.cursorColumn >= line.length) {
|
|
1363
|
-
// At end, go to next line
|
|
1364
|
-
if (this.cursorLine < this.lines.length - 1) {
|
|
1365
|
-
this.cursorLine++;
|
|
1366
|
-
this.cursorColumn = 0;
|
|
1367
|
-
}
|
|
1368
|
-
} else {
|
|
1369
|
-
// On or after bracket, move normally
|
|
1370
|
-
this.cursorColumn++;
|
|
1371
|
-
}
|
|
1372
|
-
} else if (onCollapsed) {
|
|
1373
|
-
const bracketPos = line.search(/[{\[]/);
|
|
1374
|
-
if (this.cursorColumn < bracketPos) {
|
|
1375
|
-
// Before bracket, move normally
|
|
1376
|
-
this.cursorColumn++;
|
|
1377
|
-
} else if (this.cursorColumn === bracketPos) {
|
|
1378
|
-
// On bracket, go to after bracket
|
|
1379
|
-
this.cursorColumn = bracketPos + 1;
|
|
1380
|
-
} else {
|
|
1381
|
-
// After bracket, jump to closing line at bracket
|
|
1382
|
-
this.cursorLine = onCollapsed.endLine;
|
|
1383
|
-
const closingLine = this.lines[this.cursorLine];
|
|
1384
|
-
this.cursorColumn = this._getClosingBracketPos(closingLine);
|
|
1385
|
-
}
|
|
1678
|
+
|
|
1679
|
+
if (onClosingLine) {
|
|
1680
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1681
|
+
if (this.cursorColumn < bracketPos) {
|
|
1682
|
+
this.cursorColumn = bracketPos;
|
|
1386
1683
|
} else if (this.cursorColumn >= line.length) {
|
|
1387
|
-
// Move to next line
|
|
1388
1684
|
if (this.cursorLine < this.lines.length - 1) {
|
|
1389
1685
|
this.cursorLine++;
|
|
1390
1686
|
this.cursorColumn = 0;
|
|
1391
|
-
// Skip hidden collapsed zones
|
|
1392
|
-
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1393
|
-
if (collapsed) {
|
|
1394
|
-
this.cursorLine = collapsed.endLine;
|
|
1395
|
-
this.cursorColumn = 0;
|
|
1396
|
-
}
|
|
1397
1687
|
}
|
|
1398
1688
|
} else {
|
|
1399
1689
|
this.cursorColumn++;
|
|
1400
1690
|
}
|
|
1401
|
-
} else {
|
|
1402
|
-
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
this.cursorColumn = openBracketPos + 1;
|
|
1420
|
-
}
|
|
1421
|
-
} else if (onCollapsed) {
|
|
1422
|
-
const bracketPos = line.search(/[{\[]/);
|
|
1423
|
-
if (this.cursorColumn > bracketPos + 1) {
|
|
1424
|
-
// After bracket, go to just after bracket
|
|
1425
|
-
this.cursorColumn = bracketPos + 1;
|
|
1426
|
-
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1427
|
-
// Just after bracket, go to bracket
|
|
1428
|
-
this.cursorColumn = bracketPos;
|
|
1429
|
-
} else if (this.cursorColumn > 0) {
|
|
1430
|
-
// Before bracket, move normally
|
|
1431
|
-
this.cursorColumn--;
|
|
1432
|
-
} else {
|
|
1433
|
-
// At start, go to previous line
|
|
1434
|
-
if (this.cursorLine > 0) {
|
|
1435
|
-
this.cursorLine--;
|
|
1436
|
-
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1437
|
-
}
|
|
1691
|
+
} else if (onCollapsed) {
|
|
1692
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1693
|
+
if (this.cursorColumn < bracketPos) {
|
|
1694
|
+
this.cursorColumn++;
|
|
1695
|
+
} else if (this.cursorColumn === bracketPos) {
|
|
1696
|
+
this.cursorColumn = bracketPos + 1;
|
|
1697
|
+
} else {
|
|
1698
|
+
this.cursorLine = onCollapsed.endLine;
|
|
1699
|
+
this.cursorColumn = this._getClosingBracketPos(this.lines[this.cursorLine]);
|
|
1700
|
+
}
|
|
1701
|
+
} else if (this.cursorColumn >= line.length) {
|
|
1702
|
+
if (this.cursorLine < this.lines.length - 1) {
|
|
1703
|
+
this.cursorLine++;
|
|
1704
|
+
this.cursorColumn = 0;
|
|
1705
|
+
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1706
|
+
if (collapsed) {
|
|
1707
|
+
this.cursorLine = collapsed.endLine;
|
|
1708
|
+
this.cursorColumn = 0;
|
|
1438
1709
|
}
|
|
1710
|
+
}
|
|
1711
|
+
} else {
|
|
1712
|
+
this.cursorColumn++;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
_moveCursorLeft() {
|
|
1717
|
+
const line = this.lines[this.cursorLine];
|
|
1718
|
+
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1719
|
+
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1720
|
+
|
|
1721
|
+
if (onClosingLine) {
|
|
1722
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1723
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1724
|
+
this.cursorColumn--;
|
|
1725
|
+
} else {
|
|
1726
|
+
// Jump to opening line after bracket
|
|
1727
|
+
this.cursorLine = onClosingLine.startLine;
|
|
1728
|
+
const openLine = this.lines[this.cursorLine];
|
|
1729
|
+
this.cursorColumn = openLine.search(/[{\[]/) + 1;
|
|
1730
|
+
}
|
|
1731
|
+
} else if (onCollapsed) {
|
|
1732
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1733
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1734
|
+
this.cursorColumn = bracketPos + 1;
|
|
1735
|
+
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1736
|
+
this.cursorColumn = bracketPos;
|
|
1439
1737
|
} else if (this.cursorColumn > 0) {
|
|
1440
1738
|
this.cursorColumn--;
|
|
1441
1739
|
} else if (this.cursorLine > 0) {
|
|
1442
|
-
// Move to previous line
|
|
1443
1740
|
this.cursorLine--;
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1741
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1742
|
+
}
|
|
1743
|
+
} else if (this.cursorColumn > 0) {
|
|
1744
|
+
this.cursorColumn--;
|
|
1745
|
+
} else if (this.cursorLine > 0) {
|
|
1746
|
+
this.cursorLine--;
|
|
1747
|
+
const closing = this._getCollapsedClosingLine(this.cursorLine);
|
|
1748
|
+
if (closing) {
|
|
1749
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1750
|
+
} else {
|
|
1751
|
+
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1752
|
+
if (collapsed) {
|
|
1753
|
+
this.cursorLine = collapsed.startLine;
|
|
1754
|
+
const openLine = this.lines[this.cursorLine];
|
|
1755
|
+
this.cursorColumn = openLine.search(/[{\[]/) + 1;
|
|
1450
1756
|
} else {
|
|
1451
|
-
|
|
1452
|
-
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1453
|
-
if (collapsed) {
|
|
1454
|
-
// Jump to opening line after bracket
|
|
1455
|
-
this.cursorLine = collapsed.startLine;
|
|
1456
|
-
const openLine = this.lines[this.cursorLine];
|
|
1457
|
-
const bracketPos = openLine.search(/[{\[]/);
|
|
1458
|
-
this.cursorColumn = bracketPos + 1;
|
|
1459
|
-
} else {
|
|
1460
|
-
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1461
|
-
}
|
|
1757
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1462
1758
|
}
|
|
1463
1759
|
}
|
|
1464
1760
|
}
|
|
1465
|
-
|
|
1466
|
-
this._lastStartIndex = -1;
|
|
1467
|
-
this._scrollToCursor();
|
|
1468
|
-
this.scheduleRender();
|
|
1469
1761
|
}
|
|
1470
1762
|
|
|
1471
1763
|
/**
|
|
@@ -1494,32 +1786,26 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1494
1786
|
}
|
|
1495
1787
|
|
|
1496
1788
|
/**
|
|
1497
|
-
*
|
|
1789
|
+
* Handle arrow key with optional selection and word jump
|
|
1498
1790
|
*/
|
|
1499
|
-
|
|
1500
|
-
if (deltaLine !== 0) {
|
|
1501
|
-
this.moveCursorSkipCollapsed(deltaLine);
|
|
1502
|
-
} else if (deltaCol !== 0) {
|
|
1503
|
-
this.moveCursorHorizontal(deltaCol);
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
/**
|
|
1508
|
-
* Handle arrow key with optional selection
|
|
1509
|
-
*/
|
|
1510
|
-
_handleArrowKey(deltaLine, deltaCol, isShift) {
|
|
1791
|
+
_handleArrowKey(deltaLine, deltaCol, isShift, isCtrl = false) {
|
|
1511
1792
|
// Start selection if shift is pressed and no selection exists
|
|
1512
1793
|
if (isShift && !this.selectionStart) {
|
|
1513
1794
|
this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
|
|
1514
1795
|
}
|
|
1515
|
-
|
|
1796
|
+
|
|
1516
1797
|
// Move cursor
|
|
1517
1798
|
if (deltaLine !== 0) {
|
|
1518
1799
|
this.moveCursorSkipCollapsed(deltaLine);
|
|
1519
1800
|
} else if (deltaCol !== 0) {
|
|
1520
|
-
|
|
1801
|
+
if (isCtrl) {
|
|
1802
|
+
// Word-by-word movement
|
|
1803
|
+
this._moveCursorByWord(deltaCol);
|
|
1804
|
+
} else {
|
|
1805
|
+
this.moveCursorHorizontal(deltaCol);
|
|
1806
|
+
}
|
|
1521
1807
|
}
|
|
1522
|
-
|
|
1808
|
+
|
|
1523
1809
|
// Update selection end if shift is pressed
|
|
1524
1810
|
if (isShift) {
|
|
1525
1811
|
this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
|
|
@@ -1530,6 +1816,73 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1530
1816
|
}
|
|
1531
1817
|
}
|
|
1532
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
|
+
|
|
1533
1886
|
/**
|
|
1534
1887
|
* Handle Home/End with optional selection
|
|
1535
1888
|
*/
|
|
@@ -1558,7 +1911,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1558
1911
|
this.selectionEnd = null;
|
|
1559
1912
|
}
|
|
1560
1913
|
|
|
1561
|
-
this.
|
|
1914
|
+
this._invalidateRenderCache();
|
|
1562
1915
|
this._scrollToCursor();
|
|
1563
1916
|
this.scheduleRender();
|
|
1564
1917
|
}
|
|
@@ -1573,7 +1926,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1573
1926
|
this.cursorLine = lastLine;
|
|
1574
1927
|
this.cursorColumn = this.lines[lastLine]?.length || 0;
|
|
1575
1928
|
|
|
1576
|
-
this.
|
|
1929
|
+
this._invalidateRenderCache();
|
|
1577
1930
|
this._scrollToCursor();
|
|
1578
1931
|
this.scheduleRender();
|
|
1579
1932
|
}
|
|
@@ -1640,9 +1993,11 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1640
1993
|
*/
|
|
1641
1994
|
_deleteSelection() {
|
|
1642
1995
|
if (!this._hasSelection()) return false;
|
|
1643
|
-
|
|
1996
|
+
|
|
1997
|
+
this._saveToHistory('delete');
|
|
1998
|
+
|
|
1644
1999
|
const { start, end } = this._normalizeSelection();
|
|
1645
|
-
|
|
2000
|
+
|
|
1646
2001
|
if (start.line === end.line) {
|
|
1647
2002
|
// Single line selection
|
|
1648
2003
|
const line = this.lines[start.line];
|
|
@@ -1654,12 +2009,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1654
2009
|
this.lines[start.line] = startLine + endLine;
|
|
1655
2010
|
this.lines.splice(start.line + 1, end.line - start.line);
|
|
1656
2011
|
}
|
|
1657
|
-
|
|
2012
|
+
|
|
1658
2013
|
this.cursorLine = start.line;
|
|
1659
2014
|
this.cursorColumn = start.column;
|
|
1660
2015
|
this.selectionStart = null;
|
|
1661
2016
|
this.selectionEnd = null;
|
|
1662
|
-
|
|
2017
|
+
|
|
1663
2018
|
return true;
|
|
1664
2019
|
}
|
|
1665
2020
|
|
|
@@ -1668,10 +2023,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1668
2023
|
if (this._hasSelection()) {
|
|
1669
2024
|
this._deleteSelection();
|
|
1670
2025
|
}
|
|
1671
|
-
|
|
2026
|
+
|
|
1672
2027
|
// Block insertion in hidden collapsed zones
|
|
1673
2028
|
if (this._getCollapsedRangeForLine(this.cursorLine)) return;
|
|
1674
|
-
|
|
2029
|
+
|
|
1675
2030
|
// On closing line, only allow after bracket
|
|
1676
2031
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1677
2032
|
if (onClosingLine) {
|
|
@@ -1679,7 +2034,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1679
2034
|
const bracketPos = this._getClosingBracketPos(line);
|
|
1680
2035
|
if (this.cursorColumn <= bracketPos) return;
|
|
1681
2036
|
}
|
|
1682
|
-
|
|
2037
|
+
|
|
1683
2038
|
// On collapsed opening line, only allow before bracket
|
|
1684
2039
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1685
2040
|
if (onCollapsed) {
|
|
@@ -1687,7 +2042,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1687
2042
|
const bracketPos = line.search(/[{\[]/);
|
|
1688
2043
|
if (this.cursorColumn > bracketPos) return;
|
|
1689
2044
|
}
|
|
1690
|
-
|
|
2045
|
+
|
|
2046
|
+
// Save to history before making changes
|
|
2047
|
+
this._saveToHistory('insert');
|
|
2048
|
+
|
|
1691
2049
|
// Handle empty editor case
|
|
1692
2050
|
if (this.lines.length === 0) {
|
|
1693
2051
|
// Split text by newlines to properly handle multi-line paste
|
|
@@ -1706,18 +2064,30 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1706
2064
|
handlePaste(e) {
|
|
1707
2065
|
e.preventDefault();
|
|
1708
2066
|
const text = e.clipboardData.getData('text/plain');
|
|
1709
|
-
if (text)
|
|
1710
|
-
|
|
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
|
|
1711
2080
|
this.insertText(text);
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
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;
|
|
1720
2089
|
}
|
|
2090
|
+
this.autoCollapseCoordinates();
|
|
1721
2091
|
}
|
|
1722
2092
|
}
|
|
1723
2093
|
|
|
@@ -1735,11 +2105,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1735
2105
|
e.preventDefault();
|
|
1736
2106
|
if (this._hasSelection()) {
|
|
1737
2107
|
e.clipboardData.setData('text/plain', this._getSelectedText());
|
|
2108
|
+
this._saveToHistory('cut');
|
|
1738
2109
|
this._deleteSelection();
|
|
1739
2110
|
this.formatAndUpdate();
|
|
1740
2111
|
} else {
|
|
1741
2112
|
// Cut all content
|
|
1742
2113
|
e.clipboardData.setData('text/plain', this.getContent());
|
|
2114
|
+
this._saveToHistory('cut');
|
|
1743
2115
|
this.lines = [];
|
|
1744
2116
|
this.cursorLine = 0;
|
|
1745
2117
|
this.cursorColumn = 0;
|
|
@@ -1885,21 +2257,70 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1885
2257
|
|
|
1886
2258
|
// Use updateView - don't rebuild nodeId mappings since content didn't change
|
|
1887
2259
|
this.updateView();
|
|
1888
|
-
this.
|
|
2260
|
+
this._invalidateRenderCache(); // Force re-render
|
|
1889
2261
|
this.scheduleRender();
|
|
1890
2262
|
}
|
|
1891
2263
|
|
|
1892
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) {
|
|
1893
2286
|
const ranges = this._findCollapsibleRanges();
|
|
1894
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
|
|
1895
2292
|
for (const range of ranges) {
|
|
1896
|
-
|
|
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) {
|
|
1897
2319
|
this.collapsedNodes.add(range.nodeId);
|
|
1898
2320
|
}
|
|
1899
2321
|
}
|
|
1900
2322
|
|
|
1901
2323
|
// Rebuild everything to ensure consistent state after collapse changes
|
|
1902
|
-
// This is especially important after paste into empty editor
|
|
1903
2324
|
this.updateModel();
|
|
1904
2325
|
this.scheduleRender();
|
|
1905
2326
|
}
|
|
@@ -1943,11 +2364,11 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1943
2364
|
`;
|
|
1944
2365
|
document.body.appendChild(anchor);
|
|
1945
2366
|
|
|
1946
|
-
const colorInput = document.createElement('input');
|
|
2367
|
+
const colorInput = document.createElement('input') as HTMLInputElement & { _closeListener?: EventListener };
|
|
1947
2368
|
colorInput.type = 'color';
|
|
1948
2369
|
colorInput.value = currentColor;
|
|
1949
2370
|
colorInput.className = 'geojson-color-picker-input';
|
|
1950
|
-
|
|
2371
|
+
|
|
1951
2372
|
// Position the color input inside the anchor
|
|
1952
2373
|
colorInput.style.cssText = `
|
|
1953
2374
|
position: absolute;
|
|
@@ -1961,18 +2382,18 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1961
2382
|
cursor: pointer;
|
|
1962
2383
|
`;
|
|
1963
2384
|
anchor.appendChild(colorInput);
|
|
1964
|
-
|
|
1965
|
-
colorInput.addEventListener('input', (e) => {
|
|
1966
|
-
this.updateColorValue(line, e.target.value, attributeName);
|
|
2385
|
+
|
|
2386
|
+
colorInput.addEventListener('input', (e: Event) => {
|
|
2387
|
+
this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
|
|
1967
2388
|
});
|
|
1968
|
-
|
|
1969
|
-
const closeOnClickOutside = (e) => {
|
|
2389
|
+
|
|
2390
|
+
const closeOnClickOutside = (e: Event) => {
|
|
1970
2391
|
if (e.target !== colorInput) {
|
|
1971
2392
|
document.removeEventListener('click', closeOnClickOutside, true);
|
|
1972
2393
|
anchor.remove(); // Remove anchor (which contains the input)
|
|
1973
2394
|
}
|
|
1974
2395
|
};
|
|
1975
|
-
|
|
2396
|
+
|
|
1976
2397
|
colorInput._closeListener = closeOnClickOutside;
|
|
1977
2398
|
|
|
1978
2399
|
setTimeout(() => {
|
|
@@ -2070,10 +2491,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2070
2491
|
|
|
2071
2492
|
updateReadonly() {
|
|
2072
2493
|
const textarea = this.shadowRoot.getElementById('hiddenTextarea');
|
|
2073
|
-
const clearBtn = this.shadowRoot
|
|
2074
|
-
|
|
2494
|
+
const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
|
|
2495
|
+
|
|
2075
2496
|
// Use readOnly instead of disabled to allow text selection for copying
|
|
2076
|
-
if (textarea) textarea.readOnly = this.readonly;
|
|
2497
|
+
if (textarea) (textarea as HTMLTextAreaElement).readOnly = this.readonly;
|
|
2077
2498
|
if (clearBtn) clearBtn.hidden = this.readonly;
|
|
2078
2499
|
}
|
|
2079
2500
|
|
|
@@ -2159,13 +2580,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2159
2580
|
return `:host-context(${selector})`;
|
|
2160
2581
|
}
|
|
2161
2582
|
|
|
2162
|
-
setTheme(theme) {
|
|
2583
|
+
setTheme(theme: ThemeSettings): void {
|
|
2163
2584
|
if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
|
|
2164
2585
|
if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
|
|
2165
2586
|
this.updateThemeCSS();
|
|
2166
2587
|
}
|
|
2167
2588
|
|
|
2168
|
-
resetTheme() {
|
|
2589
|
+
resetTheme(): void {
|
|
2169
2590
|
this.themes = { dark: {}, light: {} };
|
|
2170
2591
|
this.updateThemeCSS();
|
|
2171
2592
|
}
|
|
@@ -2266,178 +2687,164 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2266
2687
|
}
|
|
2267
2688
|
|
|
2268
2689
|
_buildContextMap() {
|
|
2690
|
+
// Memoization: return cached result if content hasn't changed
|
|
2691
|
+
const linesLength = this.lines.length;
|
|
2692
|
+
if (this._contextMapCache &&
|
|
2693
|
+
this._contextMapLinesLength === linesLength &&
|
|
2694
|
+
this._contextMapFirstLine === this.lines[0] &&
|
|
2695
|
+
this._contextMapLastLine === this.lines[linesLength - 1]) {
|
|
2696
|
+
return this._contextMapCache;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2269
2699
|
const contextMap = new Map();
|
|
2270
2700
|
const contextStack = [];
|
|
2271
2701
|
let pendingContext = null;
|
|
2272
|
-
|
|
2273
|
-
for (let i = 0; i <
|
|
2702
|
+
|
|
2703
|
+
for (let i = 0; i < linesLength; i++) {
|
|
2274
2704
|
const line = this.lines[i];
|
|
2275
2705
|
const currentContext = contextStack[contextStack.length - 1]?.context || 'Feature';
|
|
2276
2706
|
contextMap.set(i, currentContext);
|
|
2277
|
-
|
|
2707
|
+
|
|
2278
2708
|
// Check for context-changing keys
|
|
2279
|
-
if (
|
|
2280
|
-
else if (
|
|
2281
|
-
else if (
|
|
2282
|
-
|
|
2709
|
+
if (RE_CONTEXT_GEOMETRY.test(line)) pendingContext = 'geometry';
|
|
2710
|
+
else if (RE_CONTEXT_PROPERTIES.test(line)) pendingContext = 'properties';
|
|
2711
|
+
else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
|
|
2712
|
+
|
|
2283
2713
|
// Track brackets
|
|
2284
2714
|
const openBraces = (line.match(/\{/g) || []).length;
|
|
2285
2715
|
const closeBraces = (line.match(/\}/g) || []).length;
|
|
2286
2716
|
const openBrackets = (line.match(/\[/g) || []).length;
|
|
2287
2717
|
const closeBrackets = (line.match(/\]/g) || []).length;
|
|
2288
|
-
|
|
2718
|
+
|
|
2289
2719
|
for (let j = 0; j < openBraces + openBrackets; j++) {
|
|
2290
2720
|
contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
|
|
2291
2721
|
pendingContext = null;
|
|
2292
2722
|
}
|
|
2293
|
-
|
|
2723
|
+
|
|
2294
2724
|
for (let j = 0; j < closeBraces + closeBrackets && contextStack.length > 0; j++) {
|
|
2295
2725
|
contextStack.pop();
|
|
2296
2726
|
}
|
|
2297
2727
|
}
|
|
2298
|
-
|
|
2728
|
+
|
|
2729
|
+
// Cache the result
|
|
2730
|
+
this._contextMapCache = contextMap;
|
|
2731
|
+
this._contextMapLinesLength = linesLength;
|
|
2732
|
+
this._contextMapFirstLine = this.lines[0];
|
|
2733
|
+
this._contextMapLastLine = this.lines[linesLength - 1];
|
|
2734
|
+
|
|
2299
2735
|
return contextMap;
|
|
2300
2736
|
}
|
|
2301
2737
|
|
|
2302
2738
|
_highlightSyntax(text, context, meta) {
|
|
2303
2739
|
if (!text) return '';
|
|
2304
|
-
|
|
2740
|
+
|
|
2305
2741
|
// For collapsed nodes, truncate the text at the opening bracket
|
|
2306
2742
|
let displayText = text;
|
|
2307
2743
|
let collapsedBracket = null;
|
|
2308
|
-
|
|
2744
|
+
|
|
2309
2745
|
if (meta?.collapseButton?.isCollapsed) {
|
|
2310
2746
|
// Match "key": { or "key": [
|
|
2311
|
-
const bracketMatch = text.match(
|
|
2747
|
+
const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
|
|
2312
2748
|
// Also match standalone { or [ (root Feature objects)
|
|
2313
|
-
const rootMatch = !bracketMatch && text.match(
|
|
2314
|
-
|
|
2749
|
+
const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
|
|
2750
|
+
|
|
2315
2751
|
if (bracketMatch) {
|
|
2316
|
-
// Keep only the part up to and including the opening bracket
|
|
2317
2752
|
displayText = bracketMatch[1] + bracketMatch[2];
|
|
2318
2753
|
collapsedBracket = bracketMatch[2];
|
|
2319
2754
|
} else if (rootMatch) {
|
|
2320
|
-
// Root object - just keep the bracket
|
|
2321
2755
|
displayText = rootMatch[1] + rootMatch[2];
|
|
2322
2756
|
collapsedBracket = rootMatch[2];
|
|
2323
2757
|
}
|
|
2324
2758
|
}
|
|
2325
|
-
|
|
2759
|
+
|
|
2326
2760
|
// Escape HTML first
|
|
2327
2761
|
let result = displayText
|
|
2328
|
-
.replace(
|
|
2329
|
-
.replace(
|
|
2330
|
-
.replace(
|
|
2331
|
-
|
|
2762
|
+
.replace(RE_ESCAPE_AMP, '&')
|
|
2763
|
+
.replace(RE_ESCAPE_LT, '<')
|
|
2764
|
+
.replace(RE_ESCAPE_GT, '>');
|
|
2765
|
+
|
|
2332
2766
|
// Punctuation FIRST (before other replacements can interfere)
|
|
2333
|
-
result = result.replace(
|
|
2334
|
-
|
|
2767
|
+
result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
|
|
2768
|
+
|
|
2335
2769
|
// JSON keys - match "key" followed by :
|
|
2336
2770
|
// In properties context, all keys are treated as regular JSON keys
|
|
2337
|
-
|
|
2771
|
+
RE_JSON_KEYS.lastIndex = 0;
|
|
2772
|
+
result = result.replace(RE_JSON_KEYS, (match, key, colon) => {
|
|
2338
2773
|
if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
|
|
2339
2774
|
return `<span class="geojson-key">"${key}"</span>${colon}`;
|
|
2340
2775
|
}
|
|
2341
2776
|
return `<span class="json-key">"${key}"</span>${colon}`;
|
|
2342
2777
|
});
|
|
2343
|
-
|
|
2778
|
+
|
|
2344
2779
|
// Type values - "type": "Value" - but NOT inside properties context
|
|
2345
|
-
// IMPORTANT: Preserve original spacing by capturing and re-emitting whitespace
|
|
2346
2780
|
if (context !== 'properties') {
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
}
|
|
2354
|
-
);
|
|
2781
|
+
RE_TYPE_VALUES.lastIndex = 0;
|
|
2782
|
+
result = result.replace(RE_TYPE_VALUES, (match, space, type) => {
|
|
2783
|
+
const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
|
|
2784
|
+
const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
|
|
2785
|
+
return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
|
|
2786
|
+
});
|
|
2355
2787
|
}
|
|
2356
2788
|
|
|
2357
2789
|
// String values (not already wrapped in spans)
|
|
2358
|
-
|
|
2359
|
-
result = result.replace(
|
|
2360
|
-
|
|
2361
|
-
(
|
|
2362
|
-
|
|
2363
|
-
if (match.includes('geojson-type') || match.includes('json-string')) return match;
|
|
2364
|
-
|
|
2365
|
-
// Check if it's a color value (hex) - use ::before for swatch via CSS class
|
|
2366
|
-
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(val)) {
|
|
2367
|
-
return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
|
|
2368
|
-
}
|
|
2369
|
-
|
|
2370
|
-
return `${colon}${space}<span class="json-string">"${val}"</span>`;
|
|
2790
|
+
RE_STRING_VALUES.lastIndex = 0;
|
|
2791
|
+
result = result.replace(RE_STRING_VALUES, (match, colon, space, val) => {
|
|
2792
|
+
if (match.includes('geojson-type') || match.includes('json-string')) return match;
|
|
2793
|
+
if (RE_COLOR_HEX.test(val)) {
|
|
2794
|
+
return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
|
|
2371
2795
|
}
|
|
2372
|
-
|
|
2796
|
+
return `${colon}${space}<span class="json-string">"${val}"</span>`;
|
|
2797
|
+
});
|
|
2373
2798
|
|
|
2374
2799
|
// Numbers after colon
|
|
2375
|
-
|
|
2376
|
-
result = result.replace(
|
|
2377
|
-
/(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
|
|
2378
|
-
'$1$2<span class="json-number">$3</span>'
|
|
2379
|
-
);
|
|
2800
|
+
RE_NUMBERS_COLON.lastIndex = 0;
|
|
2801
|
+
result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
|
|
2380
2802
|
|
|
2381
2803
|
// Numbers in arrays (after [ or ,)
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
'$1$2<span class="json-number">$3</span>'
|
|
2385
|
-
);
|
|
2804
|
+
RE_NUMBERS_ARRAY.lastIndex = 0;
|
|
2805
|
+
result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
|
|
2386
2806
|
|
|
2387
2807
|
// Standalone numbers at start of line (coordinates arrays)
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
'$1<span class="json-number">$2</span>'
|
|
2391
|
-
);
|
|
2808
|
+
RE_NUMBERS_START.lastIndex = 0;
|
|
2809
|
+
result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
|
|
2392
2810
|
|
|
2393
2811
|
// Booleans - use ::before for checkbox via CSS class
|
|
2394
|
-
|
|
2395
|
-
result = result.replace(
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
|
|
2400
|
-
}
|
|
2401
|
-
);
|
|
2812
|
+
RE_BOOLEANS.lastIndex = 0;
|
|
2813
|
+
result = result.replace(RE_BOOLEANS, (match, colon, space, val) => {
|
|
2814
|
+
const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
|
|
2815
|
+
return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
|
|
2816
|
+
});
|
|
2402
2817
|
|
|
2403
2818
|
// Null
|
|
2404
|
-
|
|
2405
|
-
result = result.replace(
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
);
|
|
2409
|
-
|
|
2410
|
-
// Collapsed bracket indicator - just add the class, CSS ::after adds the "...]" or "...}"
|
|
2819
|
+
RE_NULL.lastIndex = 0;
|
|
2820
|
+
result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
|
|
2821
|
+
|
|
2822
|
+
// Collapsed bracket indicator
|
|
2411
2823
|
if (collapsedBracket) {
|
|
2412
2824
|
const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
|
|
2413
|
-
// Replace the last punctuation span (the opening bracket) with collapsed style class
|
|
2414
2825
|
result = result.replace(
|
|
2415
2826
|
new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
|
|
2416
2827
|
`<span class="${bracketClass}">${collapsedBracket}</span>`
|
|
2417
2828
|
);
|
|
2418
2829
|
}
|
|
2419
|
-
|
|
2420
|
-
// Mark unrecognized text as error
|
|
2421
|
-
|
|
2422
|
-
result = result.replace(
|
|
2423
|
-
|
|
2424
|
-
(
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
}).join('');
|
|
2438
|
-
return hasError ? before + processed + after : match;
|
|
2439
|
-
}
|
|
2440
|
-
);
|
|
2830
|
+
|
|
2831
|
+
// Mark unrecognized text as error
|
|
2832
|
+
RE_UNRECOGNIZED.lastIndex = 0;
|
|
2833
|
+
result = result.replace(RE_UNRECOGNIZED, (match, before, text, after) => {
|
|
2834
|
+
if (!text || RE_WHITESPACE_ONLY.test(text)) return match;
|
|
2835
|
+
// Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
|
|
2836
|
+
// Keep whitespace as-is, wrap any non-whitespace unrecognized token
|
|
2837
|
+
const parts = text.split(RE_WHITESPACE_SPLIT);
|
|
2838
|
+
let hasError = false;
|
|
2839
|
+
const processed = parts.map(part => {
|
|
2840
|
+
// If it's whitespace, keep it
|
|
2841
|
+
if (RE_WHITESPACE_ONLY.test(part)) return part;
|
|
2842
|
+
// Mark as error
|
|
2843
|
+
hasError = true;
|
|
2844
|
+
return `<span class="json-error">${part}</span>`;
|
|
2845
|
+
}).join('');
|
|
2846
|
+
return hasError ? before + processed + after : match;
|
|
2847
|
+
});
|
|
2441
2848
|
|
|
2442
2849
|
// Note: visibility is now handled at line level (has-visibility class on .line element)
|
|
2443
2850
|
|
|
@@ -2463,28 +2870,135 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2463
2870
|
return errors;
|
|
2464
2871
|
}
|
|
2465
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
|
+
|
|
2466
2945
|
// ========== Public API ==========
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
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);
|
|
2470
2960
|
const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
|
|
2471
|
-
this.setValue(formatted);
|
|
2961
|
+
this.setValue(formatted, false); // Don't auto-collapse coordinates
|
|
2962
|
+
this._applyCollapsedFromOptions(options, features);
|
|
2472
2963
|
}
|
|
2473
2964
|
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
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);
|
|
2478
2980
|
}
|
|
2479
2981
|
|
|
2480
|
-
|
|
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);
|
|
2481
2993
|
const features = this._parseFeatures();
|
|
2482
2994
|
const idx = index < 0 ? features.length + index : index;
|
|
2483
|
-
features.splice(Math.max(0, Math.min(idx, features.length)), 0,
|
|
2484
|
-
|
|
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);
|
|
2485
2999
|
}
|
|
2486
3000
|
|
|
2487
|
-
removeAt(index) {
|
|
3001
|
+
removeAt(index: number): Feature | undefined {
|
|
2488
3002
|
const features = this._parseFeatures();
|
|
2489
3003
|
const idx = index < 0 ? features.length + index : index;
|
|
2490
3004
|
if (idx >= 0 && idx < features.length) {
|
|
@@ -2495,7 +3009,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2495
3009
|
return undefined;
|
|
2496
3010
|
}
|
|
2497
3011
|
|
|
2498
|
-
removeAll() {
|
|
3012
|
+
removeAll(): Feature[] {
|
|
3013
|
+
if (this.lines.length > 0) {
|
|
3014
|
+
this._saveToHistory('removeAll');
|
|
3015
|
+
}
|
|
2499
3016
|
const removed = this._parseFeatures();
|
|
2500
3017
|
this.lines = [];
|
|
2501
3018
|
this.collapsedNodes.clear();
|
|
@@ -2507,20 +3024,110 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2507
3024
|
return removed;
|
|
2508
3025
|
}
|
|
2509
3026
|
|
|
2510
|
-
get(index) {
|
|
3027
|
+
get(index: number): Feature | undefined {
|
|
2511
3028
|
const features = this._parseFeatures();
|
|
2512
3029
|
const idx = index < 0 ? features.length + index : index;
|
|
2513
3030
|
return features[idx];
|
|
2514
3031
|
}
|
|
2515
3032
|
|
|
2516
|
-
getAll() {
|
|
3033
|
+
getAll(): Feature[] {
|
|
2517
3034
|
return this._parseFeatures();
|
|
2518
3035
|
}
|
|
2519
3036
|
|
|
2520
|
-
emit() {
|
|
3037
|
+
emit(): void {
|
|
2521
3038
|
this.emitChange();
|
|
2522
3039
|
}
|
|
2523
3040
|
|
|
3041
|
+
/**
|
|
3042
|
+
* Save GeoJSON to a file (triggers download)
|
|
3043
|
+
*/
|
|
3044
|
+
save(filename: string = 'features.geojson'): boolean {
|
|
3045
|
+
try {
|
|
3046
|
+
const features = this._parseFeatures();
|
|
3047
|
+
const geojson = {
|
|
3048
|
+
type: 'FeatureCollection',
|
|
3049
|
+
features: features
|
|
3050
|
+
};
|
|
3051
|
+
const json = JSON.stringify(geojson, null, 2);
|
|
3052
|
+
const blob = new Blob([json], { type: 'application/geo+json' });
|
|
3053
|
+
const url = URL.createObjectURL(blob);
|
|
3054
|
+
|
|
3055
|
+
const a = document.createElement('a');
|
|
3056
|
+
a.href = url;
|
|
3057
|
+
a.download = filename;
|
|
3058
|
+
document.body.appendChild(a);
|
|
3059
|
+
a.click();
|
|
3060
|
+
document.body.removeChild(a);
|
|
3061
|
+
URL.revokeObjectURL(url);
|
|
3062
|
+
|
|
3063
|
+
return true;
|
|
3064
|
+
} catch (e) {
|
|
3065
|
+
return false;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
/**
|
|
3070
|
+
* Open a GeoJSON file from the client filesystem
|
|
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'])
|
|
3074
|
+
* @returns {Promise<boolean>} Promise that resolves to true if file was loaded successfully
|
|
3075
|
+
*/
|
|
3076
|
+
open(options: SetOptions = {}): Promise<boolean> {
|
|
3077
|
+
return new Promise((resolve) => {
|
|
3078
|
+
const input = document.createElement('input');
|
|
3079
|
+
input.type = 'file';
|
|
3080
|
+
input.accept = '.geojson,.json,application/geo+json,application/json';
|
|
3081
|
+
input.style.display = 'none';
|
|
3082
|
+
|
|
3083
|
+
input.addEventListener('change', (e: Event) => {
|
|
3084
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
3085
|
+
if (!file) {
|
|
3086
|
+
document.body.removeChild(input);
|
|
3087
|
+
resolve(false);
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
const reader = new FileReader();
|
|
3092
|
+
reader.onload = (event: ProgressEvent<FileReader>) => {
|
|
3093
|
+
try {
|
|
3094
|
+
const content = event.target?.result as string;
|
|
3095
|
+
const parsed = JSON.parse(content);
|
|
3096
|
+
|
|
3097
|
+
// Normalize and validate features
|
|
3098
|
+
const features = this._normalizeToFeatures(parsed);
|
|
3099
|
+
|
|
3100
|
+
// Load features into editor
|
|
3101
|
+
this._saveToHistory('open');
|
|
3102
|
+
this.set(features, options);
|
|
3103
|
+
this.clearHistory(); // Clear history after opening new file
|
|
3104
|
+
document.body.removeChild(input);
|
|
3105
|
+
resolve(true);
|
|
3106
|
+
} catch (err) {
|
|
3107
|
+
document.body.removeChild(input);
|
|
3108
|
+
resolve(false);
|
|
3109
|
+
}
|
|
3110
|
+
};
|
|
3111
|
+
|
|
3112
|
+
reader.onerror = () => {
|
|
3113
|
+
document.body.removeChild(input);
|
|
3114
|
+
resolve(false);
|
|
3115
|
+
};
|
|
3116
|
+
|
|
3117
|
+
reader.readAsText(file);
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
// Handle cancel (no file selected)
|
|
3121
|
+
input.addEventListener('cancel', () => {
|
|
3122
|
+
document.body.removeChild(input);
|
|
3123
|
+
resolve(false);
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
document.body.appendChild(input);
|
|
3127
|
+
input.click();
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2524
3131
|
_parseFeatures() {
|
|
2525
3132
|
try {
|
|
2526
3133
|
const content = this.lines.join('\n');
|