@softwarity/geojson-editor 1.0.17 → 1.0.19

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.
@@ -0,0 +1,101 @@
1
+ import type { Feature, FeatureCollection } from 'geojson';
2
+
3
+ /**
4
+ * Internal types - not exported publicly
5
+ */
6
+
7
+ /** Position in the editor (line and column) */
8
+ export interface CursorPosition {
9
+ line: number;
10
+ column: number;
11
+ }
12
+
13
+ /** Input types accepted by API methods */
14
+ export type FeatureInput = Feature | Feature[] | FeatureCollection;
15
+
16
+ /** Color metadata for a line */
17
+ export interface ColorMeta {
18
+ attributeName: string;
19
+ color: string;
20
+ }
21
+
22
+ /** Boolean metadata for a line */
23
+ export interface BooleanMeta {
24
+ attributeName: string;
25
+ value: boolean;
26
+ }
27
+
28
+ /** Collapse button metadata */
29
+ export interface CollapseButtonMeta {
30
+ nodeKey: string;
31
+ nodeId: string;
32
+ isCollapsed: boolean;
33
+ }
34
+
35
+ /** Visibility button metadata */
36
+ export interface VisibilityButtonMeta {
37
+ featureKey: string;
38
+ isHidden: boolean;
39
+ }
40
+
41
+ /** Line metadata */
42
+ export interface LineMeta {
43
+ colors: ColorMeta[];
44
+ booleans: BooleanMeta[];
45
+ collapseButton: CollapseButtonMeta | null;
46
+ visibilityButton: VisibilityButtonMeta | null;
47
+ isHidden: boolean;
48
+ isCollapsed: boolean;
49
+ featureKey: string | null;
50
+ hasError: boolean;
51
+ }
52
+
53
+ /** Visible line data */
54
+ export interface VisibleLine {
55
+ index: number;
56
+ content: string;
57
+ meta: LineMeta | undefined;
58
+ }
59
+
60
+ /** Feature range in the editor */
61
+ export interface FeatureRange {
62
+ startLine: number;
63
+ endLine: number;
64
+ featureIndex: number;
65
+ }
66
+
67
+ /** Node range info */
68
+ export interface NodeRangeInfo {
69
+ startLine: number;
70
+ endLine: number;
71
+ nodeKey?: string;
72
+ uniqueKey?: string; // nodeKey:occurrence - stable identifier across rebuilds
73
+ isRootFeature?: boolean;
74
+ }
75
+
76
+ /** Editor state snapshot for undo/redo */
77
+ export interface EditorSnapshot {
78
+ lines: string[];
79
+ cursorLine: number;
80
+ cursorColumn: number;
81
+ timestamp: number;
82
+ }
83
+
84
+ /** Bracket count result */
85
+ export interface BracketCount {
86
+ open: number;
87
+ close: number;
88
+ }
89
+
90
+ /** Collapsed zone context for keydown handlers */
91
+ export interface CollapsedZoneContext {
92
+ inCollapsedZone: CollapsedNodeInfo | null;
93
+ onCollapsedNode: CollapsedNodeInfo | null;
94
+ onClosingLine: CollapsedNodeInfo | null;
95
+ }
96
+
97
+ /** Collapsed node info with nodeId */
98
+ export interface CollapsedNodeInfo extends NodeRangeInfo {
99
+ nodeId: string;
100
+ isCollapsed?: boolean;
101
+ }
@@ -0,0 +1,197 @@
1
+ import type { LineMeta } from './internal-types.js';
2
+ import {
3
+ GEOJSON_KEYS,
4
+ GEOMETRY_TYPES,
5
+ RE_COLLAPSED_BRACKET,
6
+ RE_COLLAPSED_ROOT,
7
+ RE_ESCAPE_AMP,
8
+ RE_ESCAPE_LT,
9
+ RE_ESCAPE_GT,
10
+ RE_PUNCTUATION,
11
+ RE_JSON_KEYS,
12
+ RE_TYPE_VALUES,
13
+ RE_STRING_VALUES,
14
+ RE_COLOR_HEX,
15
+ RE_NUMBERS_COLON,
16
+ RE_NUMBERS_ARRAY,
17
+ RE_NUMBERS_START,
18
+ RE_BOOLEANS,
19
+ RE_NULL,
20
+ RE_UNRECOGNIZED,
21
+ RE_WHITESPACE_ONLY,
22
+ RE_WHITESPACE_SPLIT
23
+ } from './constants.js';
24
+
25
+ // CSS named colors (147 colors, ~1.2KB) - format: ,color1,color2,...,
26
+ // Using string with indexOf for O(n) lookup, simpler than Set
27
+ const CSS_COLORS = ',aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen,';
28
+
29
+ // Reusable DOM element for color conversion (getComputedStyle)
30
+ let _colorTestEl: HTMLElement | null = null;
31
+
32
+ /**
33
+ * Get or create the color test element (lazy initialization)
34
+ */
35
+ function getColorTestEl(): HTMLElement {
36
+ if (!_colorTestEl) {
37
+ _colorTestEl = document.createElement('div');
38
+ _colorTestEl.style.display = 'none';
39
+ document.body.appendChild(_colorTestEl);
40
+ }
41
+ return _colorTestEl;
42
+ }
43
+
44
+ /**
45
+ * Check if a string is a valid CSS named color
46
+ */
47
+ export function isNamedColor(value: string): boolean {
48
+ return CSS_COLORS.includes(',' + value.toLowerCase() + ',');
49
+ }
50
+
51
+ /**
52
+ * Convert a named CSS color to hex using browser's getComputedStyle
53
+ */
54
+ export function namedColorToHex(colorName: string): string | null {
55
+ const el = getColorTestEl();
56
+ el.style.color = colorName;
57
+
58
+ // Get computed color (browser returns rgb(r, g, b) or rgba(r, g, b, a))
59
+ const computed = getComputedStyle(el).color;
60
+ if (!computed) return null;
61
+
62
+ // Parse rgb(r, g, b) or rgba(r, g, b, a)
63
+ const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
64
+ if (!match) return null;
65
+ const [ ,rd, gd, bd ] = match;
66
+
67
+ // Convert to hex
68
+ const r = parseInt(rd, 10).toString(16).padStart(2, '0');
69
+ const g = parseInt(gd, 10).toString(16).padStart(2, '0');
70
+ const b = parseInt(bd, 10).toString(16).padStart(2, '0');
71
+
72
+ return '#' + r + g + b;
73
+ }
74
+
75
+ /**
76
+ * Apply syntax highlighting to a line of JSON text
77
+ * Returns HTML with syntax highlighting spans
78
+ */
79
+ export function highlightSyntax(text: string, context: string, meta: LineMeta | undefined): string {
80
+ if (!text) return '';
81
+
82
+ // For collapsed nodes, truncate the text at the opening bracket
83
+ let displayText = text;
84
+ let collapsedBracket: string | null = null;
85
+
86
+ if (meta?.collapseButton?.isCollapsed) {
87
+ // Match "key": { or "key": [
88
+ const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
89
+ // Also match standalone { or [ (root Feature objects)
90
+ const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
91
+
92
+ if (bracketMatch) {
93
+ displayText = bracketMatch[1] + bracketMatch[2];
94
+ collapsedBracket = bracketMatch[2];
95
+ } else if (rootMatch) {
96
+ displayText = rootMatch[1] + rootMatch[2];
97
+ collapsedBracket = rootMatch[2];
98
+ }
99
+ }
100
+
101
+ // Escape HTML first
102
+ let result = displayText
103
+ .replace(RE_ESCAPE_AMP, '&')
104
+ .replace(RE_ESCAPE_LT, '<')
105
+ .replace(RE_ESCAPE_GT, '>');
106
+
107
+ // Punctuation FIRST (before other replacements can interfere)
108
+ result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
109
+
110
+ // JSON keys - match "key" followed by :
111
+ // In properties context, all keys are treated as regular JSON keys
112
+ RE_JSON_KEYS.lastIndex = 0;
113
+ result = result.replace(RE_JSON_KEYS, (_match, key, colon) => {
114
+ if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
115
+ return `<span class="geojson-key">"${key}"</span>${colon}`;
116
+ }
117
+ return `<span class="json-key">"${key}"</span>${colon}`;
118
+ });
119
+
120
+ // Type values - "type": "Value" - but NOT inside properties context
121
+ if (context !== 'properties') {
122
+ RE_TYPE_VALUES.lastIndex = 0;
123
+ result = result.replace(RE_TYPE_VALUES, (_match, space, type) => {
124
+ const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
125
+ const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
126
+ return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
127
+ });
128
+ }
129
+
130
+ // String values (not already wrapped in spans)
131
+ RE_STRING_VALUES.lastIndex = 0;
132
+ result = result.replace(RE_STRING_VALUES, (match, colon, space, val) => {
133
+ if (match.includes('geojson-type') || match.includes('json-string')) return match;
134
+ // Check for hex color (#fff or #ffffff)
135
+ if (RE_COLOR_HEX.test(val)) {
136
+ return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
137
+ }
138
+ // Check for named CSS color (red, blue, etc.) - uses cached browser validation
139
+ if (isNamedColor(val)) {
140
+ // Use the named color directly for swatch, browser handles it
141
+ return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
142
+ }
143
+ return `${colon}${space}<span class="json-string">"${val}"</span>`;
144
+ });
145
+
146
+ // Numbers after colon
147
+ RE_NUMBERS_COLON.lastIndex = 0;
148
+ result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
149
+
150
+ // Numbers in arrays (after [ or ,)
151
+ RE_NUMBERS_ARRAY.lastIndex = 0;
152
+ result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
153
+
154
+ // Standalone numbers at start of line (coordinates arrays)
155
+ RE_NUMBERS_START.lastIndex = 0;
156
+ result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
157
+
158
+ // Booleans - use ::before for checkbox via CSS class
159
+ RE_BOOLEANS.lastIndex = 0;
160
+ result = result.replace(RE_BOOLEANS, (_match, colon, space, val) => {
161
+ const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
162
+ return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
163
+ });
164
+
165
+ // Null
166
+ RE_NULL.lastIndex = 0;
167
+ result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
168
+
169
+ // Collapsed bracket indicator
170
+ if (collapsedBracket) {
171
+ const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
172
+ result = result.replace(
173
+ new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
174
+ `<span class="${bracketClass}">${collapsedBracket}</span>`
175
+ );
176
+ }
177
+
178
+ // Mark unrecognized text as error
179
+ RE_UNRECOGNIZED.lastIndex = 0;
180
+ result = result.replace(RE_UNRECOGNIZED, (match, before, text, after) => {
181
+ if (!text || RE_WHITESPACE_ONLY.test(text)) return match;
182
+ // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
183
+ // Keep whitespace as-is, wrap any non-whitespace unrecognized token
184
+ const parts: string[] = text.split(RE_WHITESPACE_SPLIT);
185
+ let hasError = false;
186
+ const processed = parts.map(part => {
187
+ // If it's whitespace, keep it
188
+ if (RE_WHITESPACE_ONLY.test(part)) return part;
189
+ // Mark as error
190
+ hasError = true;
191
+ return `<span class="json-error">${part}</span>`;
192
+ }).join('');
193
+ return hasError ? before + processed + after : match;
194
+ });
195
+
196
+ return result;
197
+ }
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Feature } from 'geojson';
2
+
3
+ /**
4
+ * Public types - exported from the package
5
+ */
6
+
7
+ /** Options for set/add/insertAt/open methods */
8
+ export interface SetOptions {
9
+ /**
10
+ * Attributes to collapse after loading.
11
+ * - string[]: List of attribute names (e.g., ['coordinates', 'geometry'])
12
+ * - function: Dynamic function (feature, index) => string[]
13
+ * - '$root': Special keyword to collapse entire features
14
+ * - Empty array: No auto-collapse
15
+ * @default ['coordinates']
16
+ */
17
+ collapsed?: string[] | ((feature: Feature | null, index: number) => string[]);
18
+ }
19
+
20
+ /** Theme configuration */
21
+ export interface ThemeConfig {
22
+ bgColor?: string;
23
+ textColor?: string;
24
+ caretColor?: string;
25
+ gutterBg?: string;
26
+ gutterBorder?: string;
27
+ gutterText?: string;
28
+ jsonKey?: string;
29
+ jsonString?: string;
30
+ jsonNumber?: string;
31
+ jsonBoolean?: string;
32
+ jsonNull?: string;
33
+ jsonPunct?: string;
34
+ jsonError?: string;
35
+ controlColor?: string;
36
+ controlBg?: string;
37
+ controlBorder?: string;
38
+ geojsonKey?: string;
39
+ geojsonType?: string;
40
+ geojsonTypeInvalid?: string;
41
+ jsonKeyInvalid?: string;
42
+ }
43
+
44
+ /** Theme settings for dark and light modes */
45
+ export interface ThemeSettings {
46
+ dark?: ThemeConfig;
47
+ light?: ThemeConfig;
48
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { Feature } from 'geojson';
2
+ import type { BracketCount } from './internal-types.js';
3
+
4
+ /**
5
+ * Alias for document.createElement - optimized for minification
6
+ */
7
+ export const createElement = (tag: string): HTMLElement => document.createElement(tag);
8
+
9
+ /**
10
+ * Generate a unique feature key from a feature object
11
+ * Uses id, properties.id, or a hash of geometry coordinates
12
+ */
13
+ export function getFeatureKey(feature: Feature | null): string | null {
14
+ if (!feature) return null;
15
+ if (feature.id !== undefined) return `id:${feature.id}`;
16
+ if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
17
+
18
+ const geomType = feature.geometry?.type || 'null';
19
+ const geom = feature.geometry as { coordinates?: unknown } | null;
20
+ const coords = JSON.stringify(geom?.coordinates || []);
21
+ let hash = 0;
22
+ for (let i = 0; i < coords.length; i++) {
23
+ hash = ((hash << 5) - hash) + coords.charCodeAt(i);
24
+ hash = hash & hash;
25
+ }
26
+ return `hash:${geomType}:${hash.toString(36)}`;
27
+ }
28
+
29
+ /**
30
+ * Count open and close brackets in a line
31
+ * Handles string escaping properly
32
+ */
33
+ export function countBrackets(line: string, openBracket: string): BracketCount {
34
+ const closeBracket = openBracket === '{' ? '}' : ']';
35
+ let open = 0, close = 0, inString = false, escape = false;
36
+
37
+ for (const char of line) {
38
+ if (escape) { escape = false; continue; }
39
+ if (char === '\\' && inString) { escape = true; continue; }
40
+ if (char === '"') { inString = !inString; continue; }
41
+ if (!inString) {
42
+ if (char === openBracket) open++;
43
+ if (char === closeBracket) close++;
44
+ }
45
+ }
46
+
47
+ return { open, close };
48
+ }
49
+
50
+ /**
51
+ * Parse a CSS selector to a :host rule for shadow DOM
52
+ */
53
+ export function parseSelectorToHostRule(selector: string | null): string {
54
+ if (!selector) return ':host([data-color-scheme="dark"])';
55
+ if (selector.startsWith('.') && !selector.includes(' ')) {
56
+ return `:host(${selector})`;
57
+ }
58
+ return `:host-context(${selector})`;
59
+ }
@@ -0,0 +1,90 @@
1
+ import type { Feature, FeatureCollection } from 'geojson';
2
+ import { GEOMETRY_TYPES, type GeometryType } from './constants.js';
3
+
4
+ /**
5
+ * Validate a parsed FeatureCollection and return any errors
6
+ */
7
+ export function validateGeoJSON(parsed: FeatureCollection): string[] {
8
+ const errors: string[] = [];
9
+
10
+ if (!parsed.features) return errors;
11
+
12
+ parsed.features.forEach((feature, i) => {
13
+ if (feature.type !== 'Feature') {
14
+ errors.push(`features[${i}]: type must be "Feature"`);
15
+ }
16
+ if (feature.geometry && feature.geometry.type) {
17
+ if (!GEOMETRY_TYPES.includes(feature.geometry.type as GeometryType)) {
18
+ errors.push(`features[${i}].geometry: invalid type "${feature.geometry.type}"`);
19
+ }
20
+ }
21
+ });
22
+
23
+ return errors;
24
+ }
25
+
26
+ /**
27
+ * Validate a single feature object
28
+ * @throws Error if the feature is invalid
29
+ */
30
+ export function validateFeature(feature: Feature): void {
31
+ if (!feature || typeof feature !== 'object') {
32
+ throw new Error('Feature must be an object');
33
+ }
34
+ if (feature.type !== 'Feature') {
35
+ throw new Error('Feature type must be "Feature"');
36
+ }
37
+ if (!('geometry' in feature)) {
38
+ throw new Error('Feature must have a geometry property');
39
+ }
40
+ if (!('properties' in feature)) {
41
+ throw new Error('Feature must have a properties property');
42
+ }
43
+ if (feature.geometry !== null) {
44
+ if (typeof feature.geometry !== 'object') {
45
+ throw new Error('Feature geometry must be an object or null');
46
+ }
47
+ if (!feature.geometry.type) {
48
+ throw new Error('Feature geometry must have a type');
49
+ }
50
+ if (!GEOMETRY_TYPES.includes(feature.geometry.type as GeometryType)) {
51
+ throw new Error(`Invalid geometry type: "${feature.geometry.type}"`);
52
+ }
53
+ if (!('coordinates' in feature.geometry)) {
54
+ throw new Error('Feature geometry must have coordinates');
55
+ }
56
+ }
57
+ if (feature.properties !== null && typeof feature.properties !== 'object') {
58
+ throw new Error('Feature properties must be an object or null');
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Normalize input to an array of features
64
+ * Accepts: FeatureCollection, Feature[], or single Feature
65
+ * @throws Error if input is invalid
66
+ */
67
+ export function normalizeToFeatures(input: Feature | Feature[] | FeatureCollection): Feature[] {
68
+ let features: Feature[] = [];
69
+
70
+ if (Array.isArray(input)) {
71
+ features = input;
72
+ } else if (input && typeof input === 'object') {
73
+ if (input.type === 'FeatureCollection' && 'features' in input && Array.isArray(input.features)) {
74
+ features = input.features;
75
+ } else if (input.type === 'Feature') {
76
+ features = [input as Feature];
77
+ } else {
78
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
79
+ }
80
+ } else {
81
+ throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
82
+ }
83
+
84
+ // Validate each feature
85
+ for (const feature of features) {
86
+ validateFeature(feature);
87
+ }
88
+
89
+ return features;
90
+ }