leaflet-theme-control 0.1.3 → 0.1.5

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 CHANGED
@@ -8,13 +8,13 @@ A Leaflet control for switching between visual themes using CSS filters. Perfect
8
8
 
9
9
  - **Multiple themes**: Light, Dark, Grayscale, Custom
10
10
  - **Theme Editor**: Customize filters with live preview sliders (optional)
11
- - **Accessibility**: Adaptable themes for better visibility
11
+ - **Accessibility**: Adaptable themes for better visibility
12
12
  - **CSS Filters**: No need for multiple tile sources
13
13
  - **Persistent**: Saves user preference in localStorage
14
14
  - **System Detection**: Automatically detects OS dark mode preference
15
15
  - **i18n Ready**: Customizable labels with auto-update on language change
16
16
  - **Lightweight**: Zero dependencies (except Leaflet)
17
- - **Performance**: Instant theme switching without reloading tiles
17
+ - **Performance**: Instant theme switching without reloading tiles
18
18
 
19
19
  ## Installation
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leaflet-theme-control",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A Leaflet control for switching between visual themes (light, dark, grayscale, custom, etc.) using CSS filters",
5
5
  "main": "src/leaflet-theme-control.js",
6
6
  "module": "src/leaflet-theme-control.js",
@@ -38,31 +38,31 @@
38
38
  "license": "MIT",
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "git+https://github.com/kristjanesperanto/leaflet-theme-control.git"
41
+ "url": "git+https://github.com/KristjanESPERANTO/leaflet-theme-control.git"
42
42
  },
43
43
  "bugs": {
44
- "url": "https://github.com/kristjanesperanto/leaflet-theme-control/issues"
44
+ "url": "https://github.com/KristjanESPERANTO/leaflet-theme-control/issues"
45
45
  },
46
- "homepage": "https://kristjanesperanto.github.io/leaflet-theme-control/",
46
+ "homepage": "https://KristjanESPERANTO.github.io/leaflet-theme-control/",
47
47
  "peerDependencies": {
48
48
  "leaflet": ">=2.0.0-alpha.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@eslint/css": "^0.14.1",
52
- "@eslint/js": "^9.39.2",
51
+ "@eslint/css": "^1.0.0",
52
+ "@eslint/js": "^10.0.1",
53
53
  "@eslint/markdown": "^7.5.1",
54
- "@stylistic/eslint-plugin": "^5.6.1",
54
+ "@stylistic/eslint-plugin": "^5.10.0",
55
55
  "commit-and-tag-version": "^12.6.1",
56
- "eslint": "^9.39.2",
57
- "eslint-plugin-import-x": "^4.16.1",
58
- "eslint-plugin-jsdoc": "^61.5.0",
59
- "globals": "^16.5.0",
60
- "happy-dom": "^20.0.11",
56
+ "eslint": "^10.0.3",
57
+ "eslint-plugin-import-x": "^4.16.2",
58
+ "eslint-plugin-jsdoc": "^62.8.0",
59
+ "globals": "^17.4.0",
60
+ "happy-dom": "^20.8.4",
61
61
  "leaflet": "^2.0.0-alpha.1",
62
- "lint-staged": "^16.2.7",
63
- "prettier": "^3.7.4",
62
+ "lint-staged": "^16.4.0",
63
+ "prettier": "^3.8.1",
64
64
  "simple-git-hooks": "^2.13.1",
65
- "vitest": "^4.0.16"
65
+ "vitest": "^4.1.0"
66
66
  },
67
67
  "scripts": {
68
68
  "test": "npm run lint && vitest run",
@@ -33,22 +33,19 @@ export class ThemeControl extends Control {
33
33
  initialize(options) {
34
34
  Util.setOptions(this, options)
35
35
 
36
- // Create deep copies of themes to avoid mutating DEFAULT_THEMES
37
- // This is necessary because spread operator only does shallow copies
38
- if (this.options.themes) {
39
- // User provided custom themes - create deep copies of each theme
40
- const themesCopy = {}
41
- Object.keys(this.options.themes).forEach((key) => {
42
- themesCopy[key] = { ...this.options.themes[key] }
43
- })
44
- this.options.themes = themesCopy
45
- }
46
- else {
47
- // No themes provided - use copies of DEFAULT_THEMES
48
- this.options.themes = {}
49
- Object.keys(DEFAULT_THEMES).forEach((key) => {
50
- this.options.themes[key] = { ...DEFAULT_THEMES[key] }
51
- })
36
+ // Create shallow copies of themes to avoid mutating the source objects
37
+ this.options.themes = this._shallowCopyThemes(
38
+ this.options.themes || DEFAULT_THEMES,
39
+ )
40
+
41
+ // Store original themes for reset functionality in editor
42
+ // This ensures reset uses user-provided values, not DEFAULT_THEMES
43
+ this.originalThemes = {}
44
+ for (const [key, theme] of Object.entries(this.options.themes)) {
45
+ this.originalThemes[key] = {
46
+ filter: theme.filter,
47
+ controlStyle: theme.controlStyle,
48
+ }
52
49
  }
53
50
 
54
51
  this.root = document.documentElement
@@ -250,32 +247,12 @@ export class ThemeControl extends Control {
250
247
  const controlStyle = theme.controlStyle || 'light'
251
248
  this.root.setAttribute('data-control-style', controlStyle)
252
249
 
253
- // Remove all theme classes from root
254
- Object.values(this.options.themes).forEach((t) => {
255
- if (t.className) {
256
- this.root.classList.remove(t.className)
257
- }
258
- })
259
-
260
- // Add current theme class to root
261
- if (theme.className) {
262
- this.root.classList.add(theme.className)
263
- }
264
-
265
- // Also apply theme class to control container if map exists
250
+ // Apply theme classes to root and control container
251
+ this._applyThemeClasses(this.root, theme)
266
252
  if (this.map) {
267
253
  const controlContainer = this.map.getContainer().querySelector('.leaflet-control-container')
268
254
  if (controlContainer) {
269
- // Remove all theme classes from control container
270
- Object.values(this.options.themes).forEach((t) => {
271
- if (t.className) {
272
- controlContainer.classList.remove(t.className)
273
- }
274
- })
275
- // Add current theme class
276
- if (theme.className) {
277
- controlContainer.classList.add(theme.className)
278
- }
255
+ this._applyThemeClasses(controlContainer, theme)
279
256
  }
280
257
  }
281
258
 
@@ -336,6 +313,29 @@ export class ThemeControl extends Control {
336
313
  }
337
314
  }
338
315
 
316
+ /**
317
+ * Shallow-copy each theme object from the given source.
318
+ * @param {object} source - Theme map to copy
319
+ * @returns {object} New object with copied themes
320
+ */
321
+ _shallowCopyThemes(source) {
322
+ return Object.fromEntries(
323
+ Object.entries(source).map(([key, theme]) => [key, { ...theme }]),
324
+ )
325
+ }
326
+
327
+ /**
328
+ * Remove all theme classes from an element, then add the active one.
329
+ * @param {HTMLElement} el - Target element
330
+ * @param {object} theme - Currently active theme config
331
+ */
332
+ _applyThemeClasses(el, theme) {
333
+ for (const t of Object.values(this.options.themes)) {
334
+ if (t.className) el.classList.remove(t.className)
335
+ }
336
+ if (theme.className) el.classList.add(theme.className)
337
+ }
338
+
339
339
  getCurrentTheme() {
340
340
  return this.currentTheme
341
341
  }
@@ -1,6 +1,48 @@
1
1
  import { DomEvent, DomUtil } from 'leaflet'
2
2
  import { DEFAULT_THEMES } from './leaflet-theme-control-themes.js'
3
3
 
4
+ /** Default editor UI labels (allocated once, reused on every _getLabel call) */
5
+ const DEFAULT_LABELS = {
6
+ selectTheme: 'Select Theme',
7
+ customize: 'Customize',
8
+ close: 'Close',
9
+ back: 'Back',
10
+ resetToDefault: 'Reset to Default',
11
+ customizeTheme: 'Customize this theme',
12
+ customBadge: 'Custom',
13
+ controlStyle: 'Control Style',
14
+ lightControls: 'Light',
15
+ darkControls: 'Dark',
16
+ invert: 'Invert',
17
+ hueRotate: 'Hue Rotate',
18
+ saturate: 'Saturate',
19
+ brightness: 'Brightness',
20
+ contrast: 'Contrast',
21
+ sepia: 'Sepia',
22
+ grayscaleFilter: 'Grayscale',
23
+ themeButton: 'Theme',
24
+ }
25
+
26
+ /**
27
+ * Filter definitions: key → { cssName, regex, defaultValue }
28
+ * Used by both _parseFilterString and _isThemeModified.
29
+ */
30
+ const FILTER_DEFS = [
31
+ { key: 'invert', css: 'invert', default: 0 },
32
+ { key: 'hueRotate', css: 'hue-rotate', default: 0, unit: 'deg' },
33
+ { key: 'saturate', css: 'saturate', default: 1 },
34
+ { key: 'brightness', css: 'brightness', default: 1 },
35
+ { key: 'contrast', css: 'contrast', default: 1 },
36
+ { key: 'sepia', css: 'sepia', default: 0 },
37
+ { key: 'grayscale', css: 'grayscale', default: 0 },
38
+ ]
39
+
40
+ // Pre-compiled regexes (one per filter, built from FILTER_DEFS)
41
+ const FILTER_REGEXES = FILTER_DEFS.map(({ css, unit }) => {
42
+ const unitPattern = unit ? unit : ''
43
+ return new RegExp(`${css}\\(([\\d.]+)${unitPattern}\\)`)
44
+ })
45
+
4
46
  /**
5
47
  * ThemeEditor - UI for selecting and editing themes
6
48
  *
@@ -16,6 +58,9 @@ export class ThemeEditor {
16
58
  this.currentView = 'selector' // 'selector' or 'editor'
17
59
  this.editingTheme = null
18
60
 
61
+ // AbortController for automatic event cleanup
62
+ this._abortController = new AbortController()
63
+
19
64
  // Storage key for custom filters
20
65
  this.storageKey = `${themeControl.options.storageKey}-custom-filters`
21
66
 
@@ -117,12 +162,12 @@ export class ThemeEditor {
117
162
 
118
163
  this.panel = panel
119
164
 
120
- // Close on ESC key
121
- this._onKeyDown = (e) => {
165
+ // Close on ESC key (listener lives for the lifetime of the panel)
166
+ document.addEventListener('keydown', (e) => {
122
167
  if (e.key === 'Escape' && this.isOpen) {
123
168
  this.close()
124
169
  }
125
- }
170
+ }, { signal: this._abortController.signal })
126
171
 
127
172
  return panel
128
173
  }
@@ -133,29 +178,7 @@ export class ThemeEditor {
133
178
  return this.themeControl.options.getEditorLabels(key)
134
179
  }
135
180
 
136
- // Default labels
137
- const labels = {
138
- selectTheme: 'Select Theme',
139
- customize: 'Customize',
140
- close: 'Close',
141
- back: 'Back',
142
- resetToDefault: 'Reset to Default',
143
- customizeTheme: 'Customize this theme',
144
- customBadge: 'Custom',
145
- controlStyle: 'Control Style',
146
- lightControls: 'Light',
147
- darkControls: 'Dark',
148
- invert: 'Invert',
149
- hueRotate: 'Hue Rotate',
150
- saturate: 'Saturate',
151
- brightness: 'Brightness',
152
- contrast: 'Contrast',
153
- sepia: 'Sepia',
154
- grayscaleFilter: 'Grayscale',
155
- themeButton: 'Theme',
156
- }
157
-
158
- return labels[key] || key
181
+ return DEFAULT_LABELS[key] || key
159
182
  }
160
183
 
161
184
  openThemeSelector() {
@@ -166,9 +189,6 @@ export class ThemeEditor {
166
189
  this.panel.style.display = 'block'
167
190
  this._renderThemeSelector()
168
191
 
169
- // Add keyboard listener
170
- document.addEventListener('keydown', this._onKeyDown)
171
-
172
192
  // Focus first interactive element
173
193
  setTimeout(() => {
174
194
  const firstBtn = this.panel.querySelector('.theme-select-btn')
@@ -202,9 +222,6 @@ export class ThemeEditor {
202
222
  this.panel.style.display = 'none'
203
223
  this.currentView = 'selector'
204
224
  this.editingTheme = null
205
-
206
- // Remove keyboard listener
207
- document.removeEventListener('keydown', this._onKeyDown)
208
225
  }
209
226
 
210
227
  _isThemeModified(themeKey) {
@@ -244,11 +261,8 @@ export class ThemeEditor {
244
261
  this.close()
245
262
  }
246
263
 
247
- // Remove keyboard listener (in case close() wasn't called)
248
- if (this._onKeyDown) {
249
- document.removeEventListener('keydown', this._onKeyDown)
250
- this._onKeyDown = null
251
- }
264
+ // Abort all event listeners
265
+ this._abortController.abort()
252
266
 
253
267
  // Remove panel from DOM
254
268
  if (this.panel && this.panel.parentNode) {
@@ -454,13 +468,13 @@ export class ThemeEditor {
454
468
  body.appendChild(this._createControlStyleSelector(controlStyle))
455
469
 
456
470
  // Filter sliders
457
- body.appendChild(this._createSlider('invert', filterValues.invert || 0, 0, 1, 0.1))
458
- body.appendChild(this._createSlider('hueRotate', filterValues.hueRotate || 0, 0, 360, 1, '°'))
459
- body.appendChild(this._createSlider('saturate', filterValues.saturate || 1, 0, 2, 0.1))
460
- body.appendChild(this._createSlider('brightness', filterValues.brightness || 1, 0, 2, 0.1))
461
- body.appendChild(this._createSlider('contrast', filterValues.contrast || 1, 0, 2, 0.1))
462
- body.appendChild(this._createSlider('sepia', filterValues.sepia || 0, 0, 1, 0.1))
463
- body.appendChild(this._createSlider('grayscale', filterValues.grayscale || 0, 0, 1, 0.1))
471
+ body.appendChild(this._createSlider('invert', filterValues.invert ?? 0, 0, 1, 0.1))
472
+ body.appendChild(this._createSlider('hueRotate', filterValues.hueRotate ?? 0, 0, 360, 1, '°'))
473
+ body.appendChild(this._createSlider('saturate', filterValues.saturate ?? 1, 0, 2, 0.1))
474
+ body.appendChild(this._createSlider('brightness', filterValues.brightness ?? 1, 0, 2, 0.1))
475
+ body.appendChild(this._createSlider('contrast', filterValues.contrast ?? 1, 0, 2, 0.1))
476
+ body.appendChild(this._createSlider('sepia', filterValues.sepia ?? 0, 0, 1, 0.1))
477
+ body.appendChild(this._createSlider('grayscale', filterValues.grayscale ?? 0, 0, 1, 0.1))
464
478
 
465
479
  return body
466
480
  }
@@ -593,16 +607,16 @@ export class ThemeEditor {
593
607
  delete this.customFilters[themeKey]
594
608
  this._saveCustomFilters()
595
609
 
596
- // Restore default filter and controlStyle from DEFAULT_THEMES
610
+ // Restore original filter and controlStyle from user-provided themes
597
611
  // but KEEP user-defined properties like applyToSelectors
598
- const defaultTheme = DEFAULT_THEMES[themeKey]
612
+ const originalTheme = this.themeControl.originalThemes[themeKey]
599
613
  const currentTheme = this.themeControl.options.themes[themeKey]
600
614
 
601
- if (defaultTheme && currentTheme) {
615
+ if (originalTheme && currentTheme) {
602
616
  // Only reset the editable properties (filter, controlStyle)
603
617
  // Keep other properties like applyToSelectors, icon, label, className
604
- currentTheme.filter = defaultTheme.filter
605
- currentTheme.controlStyle = defaultTheme.controlStyle
618
+ currentTheme.filter = originalTheme.filter
619
+ currentTheme.controlStyle = originalTheme.controlStyle
606
620
  }
607
621
  else if (!currentTheme) {
608
622
  // Theme doesn't exist - this shouldn't happen
@@ -610,8 +624,8 @@ export class ThemeEditor {
610
624
  return
611
625
  }
612
626
  else {
613
- // For custom themes not in DEFAULT_THEMES, we can't reset
614
- console.warn(`Theme "${themeKey}" has no default in DEFAULT_THEMES, cannot reset filter values`)
627
+ // For themes not in originalThemes, we can't reset
628
+ console.warn(`Theme "${themeKey}" has no original values, cannot reset filter values`)
615
629
  }
616
630
 
617
631
  // Reapply theme if it's currently active
@@ -631,64 +645,32 @@ export class ThemeEditor {
631
645
  }
632
646
 
633
647
  _buildFilterString(values) {
648
+ const EPS = 0.01
634
649
  const parts = []
635
650
 
636
- if (values.invert > 0) parts.push(`invert(${values.invert})`)
637
- if (values.hueRotate !== 0) parts.push(`hue-rotate(${values.hueRotate}deg)`)
638
- if (values.saturate !== 1) parts.push(`saturate(${values.saturate})`)
639
- if (values.brightness !== 1) parts.push(`brightness(${values.brightness})`)
640
- if (values.contrast !== 1) parts.push(`contrast(${values.contrast})`)
641
- if (values.sepia > 0) parts.push(`sepia(${values.sepia})`)
642
- if (values.grayscale > 0) parts.push(`grayscale(${values.grayscale})`)
651
+ if (values.invert > EPS) parts.push(`invert(${values.invert})`)
652
+ if (Math.abs(values.hueRotate) > EPS) parts.push(`hue-rotate(${values.hueRotate}deg)`)
653
+ if (Math.abs(values.saturate - 1) > EPS) parts.push(`saturate(${values.saturate})`)
654
+ if (Math.abs(values.brightness - 1) > EPS) parts.push(`brightness(${values.brightness})`)
655
+ if (Math.abs(values.contrast - 1) > EPS) parts.push(`contrast(${values.contrast})`)
656
+ if (values.sepia > EPS) parts.push(`sepia(${values.sepia})`)
657
+ if (values.grayscale > EPS) parts.push(`grayscale(${values.grayscale})`)
643
658
 
644
659
  return parts.join(' ')
645
660
  }
646
661
 
647
662
  _parseFilterString(filterString) {
648
- if (!filterString) {
649
- return {
650
- invert: 0,
651
- hueRotate: 0,
652
- saturate: 1,
653
- brightness: 1,
654
- contrast: 1,
655
- sepia: 0,
656
- grayscale: 0,
663
+ const values = {}
664
+ for (let i = 0; i < FILTER_DEFS.length; i++) {
665
+ const { key, default: def } = FILTER_DEFS[i]
666
+ if (filterString) {
667
+ const match = filterString.match(FILTER_REGEXES[i])
668
+ values[key] = match ? parseFloat(match[1]) : def
669
+ }
670
+ else {
671
+ values[key] = def
657
672
  }
658
673
  }
659
-
660
- const values = {
661
- invert: 0,
662
- hueRotate: 0,
663
- saturate: 1,
664
- brightness: 1,
665
- contrast: 1,
666
- sepia: 0,
667
- grayscale: 0,
668
- }
669
-
670
- // Parse each filter function
671
- const invertMatch = filterString.match(/invert\(([\d.]+)\)/)
672
- if (invertMatch) values.invert = parseFloat(invertMatch[1])
673
-
674
- const hueMatch = filterString.match(/hue-rotate\(([\d.]+)deg\)/)
675
- if (hueMatch) values.hueRotate = parseFloat(hueMatch[1])
676
-
677
- const saturateMatch = filterString.match(/saturate\(([\d.]+)\)/)
678
- if (saturateMatch) values.saturate = parseFloat(saturateMatch[1])
679
-
680
- const brightnessMatch = filterString.match(/brightness\(([\d.]+)\)/)
681
- if (brightnessMatch) values.brightness = parseFloat(brightnessMatch[1])
682
-
683
- const contrastMatch = filterString.match(/contrast\(([\d.]+)\)/)
684
- if (contrastMatch) values.contrast = parseFloat(contrastMatch[1])
685
-
686
- const sepiaMatch = filterString.match(/sepia\(([\d.]+)\)/)
687
- if (sepiaMatch) values.sepia = parseFloat(sepiaMatch[1])
688
-
689
- const grayscaleMatch = filterString.match(/grayscale\(([\d.]+)\)/)
690
- if (grayscaleMatch) values.grayscale = parseFloat(grayscaleMatch[1])
691
-
692
674
  return values
693
675
  }
694
676
  }
package/types/index.d.ts CHANGED
@@ -130,6 +130,28 @@ declare module "leaflet" {
130
130
  panelZIndex?: number;
131
131
  }
132
132
 
133
+ /**
134
+ * Theme editor for customizing theme filters.
135
+ * Only available when enableEditor is true.
136
+ */
137
+ class ThemeEditor {
138
+ /**
139
+ * Opens the theme selector panel.
140
+ */
141
+ openThemeSelector(): void;
142
+
143
+ /**
144
+ * Opens the theme editor for a specific theme.
145
+ * @param themeKey - The key of the theme to edit
146
+ */
147
+ openThemeEditor(themeKey: string): void;
148
+
149
+ /**
150
+ * Closes the editor panel.
151
+ */
152
+ close(): void;
153
+ }
154
+
133
155
  /**
134
156
  * Leaflet control for switching between visual themes.
135
157
  *
@@ -156,6 +178,11 @@ declare module "leaflet" {
156
178
  class ThemeControl extends Control {
157
179
  options: ThemeControlOptions;
158
180
 
181
+ /**
182
+ * Theme editor instance (only available when enableEditor is true).
183
+ */
184
+ editor?: ThemeEditor;
185
+
159
186
  /**
160
187
  * Creates a new ThemeControl instance.
161
188
  * @param options - Configuration options