leaflet-theme-control 0.0.1
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/LICENSE.md +21 -0
- package/README.md +277 -0
- package/package.json +82 -0
- package/src/leaflet-theme-control-themes.js +33 -0
- package/src/leaflet-theme-control.css +503 -0
- package/src/leaflet-theme-control.js +273 -0
- package/src/leaflet-theme-editor.js +621 -0
- package/types/index.d.ts +186 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Control, DomEvent, DomUtil, Util } from 'leaflet'
|
|
2
|
+
import { DEFAULT_THEMES } from './leaflet-theme-control-themes.js'
|
|
3
|
+
import { ThemeEditor } from './leaflet-theme-editor.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ThemeControl - Leaflet control for switching visual themes
|
|
7
|
+
*
|
|
8
|
+
* Supports multiple themes using CSS filters:
|
|
9
|
+
* - Light (default)
|
|
10
|
+
* - Dark (inverted colors)
|
|
11
|
+
* - Grayscale (black & white)
|
|
12
|
+
* - Custom themes via options
|
|
13
|
+
*/
|
|
14
|
+
export class ThemeControl extends Control {
|
|
15
|
+
static {
|
|
16
|
+
this.setDefaultOptions({
|
|
17
|
+
position: 'topright',
|
|
18
|
+
themes: null, // Will be set to a copy of DEFAULT_THEMES in initialize
|
|
19
|
+
defaultTheme: 'light',
|
|
20
|
+
storageKey: 'leaflet-theme',
|
|
21
|
+
detectSystemTheme: true,
|
|
22
|
+
cssSelector: '.leaflet-tile-pane',
|
|
23
|
+
addButton: true, // Add UI button to map (set to false for programmatic control only)
|
|
24
|
+
enableEditor: false, // Enable theme editor UI
|
|
25
|
+
onChange: null,
|
|
26
|
+
getLabel: null, // Function to get translated theme labels: (themeKey) => string
|
|
27
|
+
getEditorLabels: null, // Function to get translated editor UI labels: (key) => string
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
initialize(options) {
|
|
32
|
+
Util.setOptions(this, options)
|
|
33
|
+
|
|
34
|
+
// Create a deep copy of DEFAULT_THEMES to avoid mutating the original
|
|
35
|
+
if (!this.options.themes) {
|
|
36
|
+
this.options.themes = {}
|
|
37
|
+
Object.keys(DEFAULT_THEMES).forEach((key) => {
|
|
38
|
+
this.options.themes[key] = { ...DEFAULT_THEMES[key] }
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.root = document.documentElement
|
|
43
|
+
this.savedTheme = localStorage.getItem(this.options.storageKey)
|
|
44
|
+
|
|
45
|
+
// AbortController for automatic event cleanup
|
|
46
|
+
this._abortController = new AbortController()
|
|
47
|
+
|
|
48
|
+
// Setup media query listener for system theme changes
|
|
49
|
+
if (this.options.detectSystemTheme) {
|
|
50
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
51
|
+
this.prefersDark = mediaQuery.matches
|
|
52
|
+
|
|
53
|
+
mediaQuery.addEventListener('change', (e) => {
|
|
54
|
+
// Only auto-switch if user hasn't manually selected a theme
|
|
55
|
+
if (!this.savedTheme) {
|
|
56
|
+
this.setTheme(e.matches ? 'dark' : this.options.defaultTheme)
|
|
57
|
+
}
|
|
58
|
+
}, { signal: this._abortController.signal })
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.prefersDark = false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Initialize theme editor if enabled
|
|
65
|
+
if (this.options.enableEditor) {
|
|
66
|
+
this.editor = new ThemeEditor(this)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Set initial theme
|
|
70
|
+
const initialTheme = this._determineInitialTheme()
|
|
71
|
+
this.currentTheme = initialTheme
|
|
72
|
+
this._applyTheme(initialTheme, false)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_determineInitialTheme() {
|
|
76
|
+
// 1. Check localStorage
|
|
77
|
+
if (this.savedTheme && this.options.themes[this.savedTheme]) {
|
|
78
|
+
return this.savedTheme
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Check system preference (if enabled)
|
|
82
|
+
if (this.options.detectSystemTheme && this.prefersDark && this.options.themes.dark) {
|
|
83
|
+
return 'dark'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Use default
|
|
87
|
+
return this.options.defaultTheme
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onAdd(map) {
|
|
91
|
+
this.map = map
|
|
92
|
+
|
|
93
|
+
// Add theme selector panel to map container if editor enabled
|
|
94
|
+
if (this.options.enableEditor && this.editor) {
|
|
95
|
+
const panel = this.editor.createPanel()
|
|
96
|
+
map.getContainer().appendChild(panel)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Return empty container if no button should be added
|
|
100
|
+
if (!this.options.addButton) {
|
|
101
|
+
const container = DomUtil.create('div', 'leaflet-control-theme-hidden')
|
|
102
|
+
container.style.display = 'none'
|
|
103
|
+
return container
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const container = DomUtil.create('div', 'leaflet-bar leaflet-control-theme')
|
|
107
|
+
const button = DomUtil.create('button', 'leaflet-control-theme-button', container)
|
|
108
|
+
|
|
109
|
+
button.type = 'button'
|
|
110
|
+
this._updateButton(button, this.currentTheme)
|
|
111
|
+
|
|
112
|
+
// Store button reference for updates
|
|
113
|
+
this.button = button
|
|
114
|
+
|
|
115
|
+
// Event listener - inline arrow function (no need to store reference)
|
|
116
|
+
DomEvent.on(button, 'click', (event) => {
|
|
117
|
+
DomEvent.stop(event)
|
|
118
|
+
|
|
119
|
+
if (this.options.enableEditor && this.editor) {
|
|
120
|
+
this.editor.openThemeSelector()
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this._cycleTheme()
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return container
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
onRemove() {
|
|
131
|
+
// Abort all event listeners
|
|
132
|
+
this._abortController.abort()
|
|
133
|
+
|
|
134
|
+
// Cleanup editor
|
|
135
|
+
if (this.editor) {
|
|
136
|
+
this.editor.cleanup()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.button = null
|
|
140
|
+
this.map = null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_updateButton(button, themeKey) {
|
|
144
|
+
if (!button) return // No button if addButton: false
|
|
145
|
+
|
|
146
|
+
const theme = this.options.themes[themeKey]
|
|
147
|
+
const label = this._getThemeLabel(themeKey)
|
|
148
|
+
|
|
149
|
+
button.setAttribute('aria-label', 'Theme: ' + label)
|
|
150
|
+
button.title = 'Theme: ' + label
|
|
151
|
+
button.textContent = theme.icon || '🎨'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_getThemeLabel(themeKey) {
|
|
155
|
+
// Use custom label function if provided
|
|
156
|
+
if (this.options.getLabel) {
|
|
157
|
+
return this.options.getLabel(themeKey)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Use theme's built-in label
|
|
161
|
+
const theme = this.options.themes[themeKey]
|
|
162
|
+
return theme.label || themeKey
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_cycleTheme() {
|
|
166
|
+
const themeKeys = Object.keys(this.options.themes)
|
|
167
|
+
const currentIndex = themeKeys.indexOf(this.currentTheme)
|
|
168
|
+
const nextIndex = (currentIndex + 1) % themeKeys.length
|
|
169
|
+
const nextTheme = themeKeys[nextIndex]
|
|
170
|
+
|
|
171
|
+
this.setTheme(nextTheme)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setTheme(themeKey) {
|
|
175
|
+
if (!this.options.themes[themeKey]) {
|
|
176
|
+
console.warn(`Theme "${themeKey}" not found`)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.currentTheme = themeKey
|
|
181
|
+
this._applyTheme(themeKey, true)
|
|
182
|
+
this._updateButton(this.button, themeKey)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_applyTheme(themeKey, save = true) {
|
|
186
|
+
const theme = this.options.themes[themeKey]
|
|
187
|
+
|
|
188
|
+
// Set data-theme attribute on root
|
|
189
|
+
this.root.setAttribute('data-theme', themeKey)
|
|
190
|
+
|
|
191
|
+
// Set control style (light or dark controls)
|
|
192
|
+
const controlStyle = theme.controlStyle || 'light'
|
|
193
|
+
this.root.setAttribute('data-control-style', controlStyle)
|
|
194
|
+
|
|
195
|
+
// Remove all theme classes from root
|
|
196
|
+
Object.values(this.options.themes).forEach((t) => {
|
|
197
|
+
if (t.className) {
|
|
198
|
+
this.root.classList.remove(t.className)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Add current theme class to root
|
|
203
|
+
if (theme.className) {
|
|
204
|
+
this.root.classList.add(theme.className)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Apply CSS filter to map tiles
|
|
208
|
+
const mapElements = document.querySelectorAll(this.options.cssSelector)
|
|
209
|
+
mapElements.forEach((el) => {
|
|
210
|
+
if (theme.filter) {
|
|
211
|
+
el.style.filter = theme.filter
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
el.style.filter = ''
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Collect all selectors from all themes to clear filters first
|
|
219
|
+
const allSelectors = new Set()
|
|
220
|
+
Object.values(this.options.themes).forEach((t) => {
|
|
221
|
+
if (t.applyToSelectors) {
|
|
222
|
+
const selectors = Array.isArray(t.applyToSelectors)
|
|
223
|
+
? t.applyToSelectors
|
|
224
|
+
: [t.applyToSelectors]
|
|
225
|
+
selectors.forEach(s => allSelectors.add(s))
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Clear filters from all possible selectors
|
|
230
|
+
allSelectors.forEach((selector) => {
|
|
231
|
+
const elements = document.querySelectorAll(selector)
|
|
232
|
+
elements.forEach((el) => {
|
|
233
|
+
el.style.filter = ''
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Apply CSS filter to additional custom selectors (if specified in current theme)
|
|
238
|
+
if (theme.applyToSelectors) {
|
|
239
|
+
const selectors = Array.isArray(theme.applyToSelectors)
|
|
240
|
+
? theme.applyToSelectors
|
|
241
|
+
: [theme.applyToSelectors]
|
|
242
|
+
|
|
243
|
+
selectors.forEach((selector) => {
|
|
244
|
+
const elements = document.querySelectorAll(selector)
|
|
245
|
+
elements.forEach((el) => {
|
|
246
|
+
if (theme.filter) {
|
|
247
|
+
el.style.filter = theme.filter
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Save to localStorage
|
|
254
|
+
if (save) {
|
|
255
|
+
localStorage.setItem(this.options.storageKey, themeKey)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Trigger onChange callback
|
|
259
|
+
if (this.options.onChange) {
|
|
260
|
+
this.options.onChange(themeKey, theme)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getCurrentTheme() {
|
|
265
|
+
return this.currentTheme
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getThemes() {
|
|
269
|
+
return this.options.themes
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export { DEFAULT_THEMES }
|