@sc4rfurryx/proteusjs 1.0.0
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/API.md +438 -0
- package/FEATURES.md +286 -0
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/proteus.cjs.js +16014 -0
- package/dist/proteus.cjs.js.map +1 -0
- package/dist/proteus.d.ts +3018 -0
- package/dist/proteus.esm.js +16005 -0
- package/dist/proteus.esm.js.map +1 -0
- package/dist/proteus.esm.min.js +8 -0
- package/dist/proteus.esm.min.js.map +1 -0
- package/dist/proteus.js +16020 -0
- package/dist/proteus.js.map +1 -0
- package/dist/proteus.min.js +8 -0
- package/dist/proteus.min.js.map +1 -0
- package/package.json +98 -0
- package/src/__tests__/mvp-integration.test.ts +518 -0
- package/src/accessibility/AccessibilityEngine.ts +2106 -0
- package/src/accessibility/ScreenReaderSupport.ts +444 -0
- package/src/accessibility/__tests__/ScreenReaderSupport.test.ts +435 -0
- package/src/animations/FLIPAnimationSystem.ts +491 -0
- package/src/compatibility/BrowserCompatibility.ts +1076 -0
- package/src/containers/BreakpointSystem.ts +347 -0
- package/src/containers/ContainerBreakpoints.ts +726 -0
- package/src/containers/ContainerManager.ts +370 -0
- package/src/containers/ContainerUnits.ts +336 -0
- package/src/containers/ContextIsolation.ts +394 -0
- package/src/containers/ElementQueries.ts +411 -0
- package/src/containers/SmartContainer.ts +536 -0
- package/src/containers/SmartContainers.ts +376 -0
- package/src/containers/__tests__/ContainerBreakpoints.test.ts +411 -0
- package/src/containers/__tests__/SmartContainers.test.ts +281 -0
- package/src/content/ResponsiveImages.ts +570 -0
- package/src/core/EventSystem.ts +147 -0
- package/src/core/MemoryManager.ts +321 -0
- package/src/core/PerformanceMonitor.ts +238 -0
- package/src/core/PluginSystem.ts +275 -0
- package/src/core/ProteusJS.test.ts +164 -0
- package/src/core/ProteusJS.ts +962 -0
- package/src/developer/PerformanceProfiler.ts +567 -0
- package/src/developer/VisualDebuggingTools.ts +656 -0
- package/src/developer/ZeroConfigSystem.ts +593 -0
- package/src/index.ts +35 -0
- package/src/integration.test.ts +227 -0
- package/src/layout/AdaptiveGrid.ts +429 -0
- package/src/layout/ContentReordering.ts +532 -0
- package/src/layout/FlexboxEnhancer.ts +406 -0
- package/src/layout/FlowLayout.ts +545 -0
- package/src/layout/SpacingSystem.ts +512 -0
- package/src/observers/IntersectionObserverPolyfill.ts +289 -0
- package/src/observers/ObserverManager.ts +299 -0
- package/src/observers/ResizeObserverPolyfill.ts +179 -0
- package/src/performance/BatchDOMOperations.ts +519 -0
- package/src/performance/CSSOptimizationEngine.ts +646 -0
- package/src/performance/CacheOptimizationSystem.ts +601 -0
- package/src/performance/EfficientEventHandler.ts +740 -0
- package/src/performance/LazyEvaluationSystem.ts +532 -0
- package/src/performance/MemoryManagementSystem.ts +497 -0
- package/src/performance/PerformanceMonitor.ts +931 -0
- package/src/performance/__tests__/BatchDOMOperations.test.ts +309 -0
- package/src/performance/__tests__/EfficientEventHandler.test.ts +268 -0
- package/src/performance/__tests__/PerformanceMonitor.test.ts +422 -0
- package/src/polyfills/BrowserPolyfills.ts +586 -0
- package/src/polyfills/__tests__/BrowserPolyfills.test.ts +328 -0
- package/src/test/setup.ts +115 -0
- package/src/theming/SmartThemeSystem.ts +591 -0
- package/src/types/index.ts +134 -0
- package/src/typography/ClampScaling.ts +356 -0
- package/src/typography/FluidTypography.ts +759 -0
- package/src/typography/LineHeightOptimization.ts +430 -0
- package/src/typography/LineHeightOptimizer.ts +326 -0
- package/src/typography/TextFitting.ts +355 -0
- package/src/typography/TypographicScale.ts +428 -0
- package/src/typography/VerticalRhythm.ts +369 -0
- package/src/typography/__tests__/FluidTypography.test.ts +432 -0
- package/src/typography/__tests__/LineHeightOptimization.test.ts +436 -0
- package/src/utils/Logger.ts +173 -0
- package/src/utils/debounce.ts +259 -0
- package/src/utils/performance.ts +371 -0
- package/src/utils/support.ts +106 -0
- package/src/utils/version.ts +24 -0
@@ -0,0 +1,570 @@
|
|
1
|
+
/**
|
2
|
+
* Responsive Images System for ProteusJS
|
3
|
+
* Next-generation image optimization with container-based sizing
|
4
|
+
*/
|
5
|
+
|
6
|
+
export interface ImageConfig {
|
7
|
+
formats: ('webp' | 'avif' | 'jpeg' | 'png')[];
|
8
|
+
sizes: number[];
|
9
|
+
quality: number;
|
10
|
+
lazyLoading: boolean;
|
11
|
+
artDirection: boolean;
|
12
|
+
containerBased: boolean;
|
13
|
+
placeholder: 'blur' | 'color' | 'svg' | 'none';
|
14
|
+
placeholderColor?: string;
|
15
|
+
fadeInDuration: number;
|
16
|
+
retina: boolean;
|
17
|
+
progressive: boolean;
|
18
|
+
}
|
19
|
+
|
20
|
+
export interface ImageSource {
|
21
|
+
src: string;
|
22
|
+
format: string;
|
23
|
+
width: number;
|
24
|
+
height: number;
|
25
|
+
quality: number;
|
26
|
+
media?: string;
|
27
|
+
}
|
28
|
+
|
29
|
+
export interface ImageState {
|
30
|
+
loaded: boolean;
|
31
|
+
loading: boolean;
|
32
|
+
error: boolean;
|
33
|
+
currentSrc: string;
|
34
|
+
containerSize: { width: number; height: number };
|
35
|
+
optimalSource: ImageSource | null;
|
36
|
+
intersecting: boolean;
|
37
|
+
}
|
38
|
+
|
39
|
+
export class ResponsiveImages {
|
40
|
+
private element: Element;
|
41
|
+
private config: Required<ImageConfig>;
|
42
|
+
private state: ImageState;
|
43
|
+
private resizeObserver: ResizeObserver | null = null;
|
44
|
+
private intersectionObserver: IntersectionObserver | null = null;
|
45
|
+
private sources: ImageSource[] = [];
|
46
|
+
|
47
|
+
private static readonly FORMAT_SUPPORT = new Map<string, boolean>();
|
48
|
+
private static readonly MIME_TYPES = {
|
49
|
+
'webp': 'image/webp',
|
50
|
+
'avif': 'image/avif',
|
51
|
+
'jpeg': 'image/jpeg',
|
52
|
+
'png': 'image/png'
|
53
|
+
};
|
54
|
+
|
55
|
+
constructor(element: Element, config: Partial<ImageConfig> = {}) {
|
56
|
+
this.element = element;
|
57
|
+
this.config = {
|
58
|
+
formats: ['avif', 'webp', 'jpeg'],
|
59
|
+
sizes: [320, 640, 768, 1024, 1280, 1920],
|
60
|
+
quality: 80,
|
61
|
+
lazyLoading: true,
|
62
|
+
artDirection: false,
|
63
|
+
containerBased: true,
|
64
|
+
placeholder: 'blur',
|
65
|
+
placeholderColor: '#f0f0f0',
|
66
|
+
fadeInDuration: 300,
|
67
|
+
retina: true,
|
68
|
+
progressive: true,
|
69
|
+
...config
|
70
|
+
};
|
71
|
+
|
72
|
+
this.state = this.createInitialState();
|
73
|
+
this.setupImage();
|
74
|
+
}
|
75
|
+
|
76
|
+
/**
|
77
|
+
* Activate responsive image system
|
78
|
+
*/
|
79
|
+
public activate(): void {
|
80
|
+
this.detectFormatSupport();
|
81
|
+
this.generateSources();
|
82
|
+
this.setupObservers();
|
83
|
+
|
84
|
+
if (!this.config.lazyLoading) {
|
85
|
+
this.loadImage();
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
/**
|
90
|
+
* Deactivate and clean up
|
91
|
+
*/
|
92
|
+
public deactivate(): void {
|
93
|
+
this.cleanupObservers();
|
94
|
+
this.removeImageFeatures();
|
95
|
+
}
|
96
|
+
|
97
|
+
/**
|
98
|
+
* Update image configuration
|
99
|
+
*/
|
100
|
+
public updateConfig(newConfig: Partial<ImageConfig>): void {
|
101
|
+
this.config = { ...this.config, ...newConfig };
|
102
|
+
this.generateSources();
|
103
|
+
this.updateImage();
|
104
|
+
}
|
105
|
+
|
106
|
+
/**
|
107
|
+
* Get current image state
|
108
|
+
*/
|
109
|
+
public getState(): ImageState {
|
110
|
+
return { ...this.state };
|
111
|
+
}
|
112
|
+
|
113
|
+
/**
|
114
|
+
* Force image load
|
115
|
+
*/
|
116
|
+
public load(): void {
|
117
|
+
this.loadImage();
|
118
|
+
}
|
119
|
+
|
120
|
+
/**
|
121
|
+
* Preload image
|
122
|
+
*/
|
123
|
+
public preload(): Promise<void> {
|
124
|
+
return new Promise((resolve, reject) => {
|
125
|
+
const optimalSource = this.getOptimalSource();
|
126
|
+
if (!optimalSource) {
|
127
|
+
reject(new Error('No optimal source found'));
|
128
|
+
return;
|
129
|
+
}
|
130
|
+
|
131
|
+
const img = new Image();
|
132
|
+
img.onload = () => resolve();
|
133
|
+
img.onerror = () => reject(new Error('Failed to preload image'));
|
134
|
+
img.src = optimalSource.src;
|
135
|
+
});
|
136
|
+
}
|
137
|
+
|
138
|
+
/**
|
139
|
+
* Setup initial image
|
140
|
+
*/
|
141
|
+
private setupImage(): void {
|
142
|
+
this.addPlaceholder();
|
143
|
+
this.setupImageElement();
|
144
|
+
}
|
145
|
+
|
146
|
+
/**
|
147
|
+
* Detect browser format support
|
148
|
+
*/
|
149
|
+
private async detectFormatSupport(): Promise<void> {
|
150
|
+
const formats = ['webp', 'avif'];
|
151
|
+
|
152
|
+
for (const format of formats) {
|
153
|
+
if (!ResponsiveImages.FORMAT_SUPPORT.has(format)) {
|
154
|
+
const supported = await this.testFormatSupport(format);
|
155
|
+
ResponsiveImages.FORMAT_SUPPORT.set(format, supported);
|
156
|
+
}
|
157
|
+
}
|
158
|
+
}
|
159
|
+
|
160
|
+
/**
|
161
|
+
* Test if browser supports image format
|
162
|
+
*/
|
163
|
+
private testFormatSupport(format: string): Promise<boolean> {
|
164
|
+
return new Promise((resolve) => {
|
165
|
+
const img = new Image();
|
166
|
+
img.onload = () => resolve(img.width > 0 && img.height > 0);
|
167
|
+
img.onerror = () => resolve(false);
|
168
|
+
|
169
|
+
// Test images (1x1 pixel)
|
170
|
+
const testImages = {
|
171
|
+
'webp': 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA',
|
172
|
+
'avif': 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgABogQEAwgMg8f8D///8WfhwB8+ErK42A='
|
173
|
+
};
|
174
|
+
|
175
|
+
img.src = testImages[format as keyof typeof testImages] || '';
|
176
|
+
});
|
177
|
+
}
|
178
|
+
|
179
|
+
/**
|
180
|
+
* Generate image sources
|
181
|
+
*/
|
182
|
+
private generateSources(): void {
|
183
|
+
const baseSrc = this.getBaseSrc();
|
184
|
+
if (!baseSrc) return;
|
185
|
+
|
186
|
+
this.sources = [];
|
187
|
+
|
188
|
+
// Generate sources for each format and size
|
189
|
+
this.config.formats.forEach(format => {
|
190
|
+
if (this.isFormatSupported(format)) {
|
191
|
+
this.config.sizes.forEach(width => {
|
192
|
+
const source: ImageSource = {
|
193
|
+
src: this.generateSrcUrl(baseSrc, format, width),
|
194
|
+
format,
|
195
|
+
width,
|
196
|
+
height: this.calculateHeight(width),
|
197
|
+
quality: this.config.quality
|
198
|
+
};
|
199
|
+
|
200
|
+
if (this.config.containerBased) {
|
201
|
+
source.media = `(min-width: ${width}px)`;
|
202
|
+
}
|
203
|
+
|
204
|
+
this.sources.push(source);
|
205
|
+
|
206
|
+
// Add retina version
|
207
|
+
if (this.config.retina) {
|
208
|
+
const retinaSource = {
|
209
|
+
...source,
|
210
|
+
src: this.generateSrcUrl(baseSrc, format, width * 2),
|
211
|
+
width: width * 2,
|
212
|
+
height: this.calculateHeight(width * 2)
|
213
|
+
};
|
214
|
+
if (source.media) {
|
215
|
+
retinaSource.media = `${source.media} and (-webkit-min-device-pixel-ratio: 2)`;
|
216
|
+
}
|
217
|
+
this.sources.push(retinaSource);
|
218
|
+
}
|
219
|
+
});
|
220
|
+
}
|
221
|
+
});
|
222
|
+
|
223
|
+
// Sort by format preference and size
|
224
|
+
this.sources.sort((a, b) => {
|
225
|
+
const formatPriorityA = this.config.formats.indexOf(a.format as any);
|
226
|
+
const formatPriorityB = this.config.formats.indexOf(b.format as any);
|
227
|
+
|
228
|
+
if (formatPriorityA !== formatPriorityB) {
|
229
|
+
return formatPriorityA - formatPriorityB;
|
230
|
+
}
|
231
|
+
|
232
|
+
return a.width - b.width;
|
233
|
+
});
|
234
|
+
}
|
235
|
+
|
236
|
+
/**
|
237
|
+
* Get optimal image source
|
238
|
+
*/
|
239
|
+
private getOptimalSource(): ImageSource | null {
|
240
|
+
if (this.sources.length === 0) return null;
|
241
|
+
|
242
|
+
const containerWidth = this.state.containerSize.width;
|
243
|
+
const devicePixelRatio = window.devicePixelRatio || 1;
|
244
|
+
const targetWidth = containerWidth * devicePixelRatio;
|
245
|
+
|
246
|
+
// Find best size match
|
247
|
+
const suitableSources = this.sources.filter(source =>
|
248
|
+
source.width >= targetWidth || source === this.sources[this.sources.length - 1]
|
249
|
+
);
|
250
|
+
|
251
|
+
// Return smallest suitable source
|
252
|
+
return suitableSources[0] || this.sources[this.sources.length - 1] || null;
|
253
|
+
}
|
254
|
+
|
255
|
+
/**
|
256
|
+
* Load image
|
257
|
+
*/
|
258
|
+
private loadImage(): void {
|
259
|
+
if (this.state.loading || this.state.loaded) return;
|
260
|
+
|
261
|
+
this.state.loading = true;
|
262
|
+
const optimalSource = this.getOptimalSource();
|
263
|
+
|
264
|
+
if (!optimalSource) {
|
265
|
+
this.state.loading = false;
|
266
|
+
this.state.error = true;
|
267
|
+
return;
|
268
|
+
}
|
269
|
+
|
270
|
+
const img = new Image();
|
271
|
+
|
272
|
+
img.onload = () => {
|
273
|
+
this.state.loaded = true;
|
274
|
+
this.state.loading = false;
|
275
|
+
this.state.currentSrc = optimalSource.src;
|
276
|
+
this.state.optimalSource = optimalSource;
|
277
|
+
|
278
|
+
this.applyImage(img);
|
279
|
+
this.removePlaceholder();
|
280
|
+
};
|
281
|
+
|
282
|
+
img.onerror = () => {
|
283
|
+
this.state.loading = false;
|
284
|
+
this.state.error = true;
|
285
|
+
this.handleImageError();
|
286
|
+
};
|
287
|
+
|
288
|
+
img.src = optimalSource.src;
|
289
|
+
}
|
290
|
+
|
291
|
+
/**
|
292
|
+
* Apply loaded image
|
293
|
+
*/
|
294
|
+
private applyImage(img: HTMLImageElement): void {
|
295
|
+
if (this.element.tagName === 'IMG') {
|
296
|
+
const imgElement = this.element as HTMLImageElement;
|
297
|
+
imgElement.src = img.src;
|
298
|
+
imgElement.style.opacity = '0';
|
299
|
+
|
300
|
+
// Fade in animation
|
301
|
+
requestAnimationFrame(() => {
|
302
|
+
imgElement.style.transition = `opacity ${this.config.fadeInDuration}ms ease-in-out`;
|
303
|
+
imgElement.style.opacity = '1';
|
304
|
+
});
|
305
|
+
} else {
|
306
|
+
// Apply as background image
|
307
|
+
const htmlElement = this.element as HTMLElement;
|
308
|
+
htmlElement.style.backgroundImage = `url(${img.src})`;
|
309
|
+
htmlElement.style.backgroundSize = 'cover';
|
310
|
+
htmlElement.style.backgroundPosition = 'center';
|
311
|
+
htmlElement.style.opacity = '0';
|
312
|
+
|
313
|
+
requestAnimationFrame(() => {
|
314
|
+
htmlElement.style.transition = `opacity ${this.config.fadeInDuration}ms ease-in-out`;
|
315
|
+
htmlElement.style.opacity = '1';
|
316
|
+
});
|
317
|
+
}
|
318
|
+
}
|
319
|
+
|
320
|
+
/**
|
321
|
+
* Add placeholder
|
322
|
+
*/
|
323
|
+
private addPlaceholder(): void {
|
324
|
+
if (this.config.placeholder === 'none') return;
|
325
|
+
|
326
|
+
const htmlElement = this.element as HTMLElement;
|
327
|
+
|
328
|
+
switch (this.config.placeholder) {
|
329
|
+
case 'color':
|
330
|
+
htmlElement.style.backgroundColor = this.config.placeholderColor || '#f0f0f0';
|
331
|
+
break;
|
332
|
+
case 'blur':
|
333
|
+
this.addBlurPlaceholder();
|
334
|
+
break;
|
335
|
+
case 'svg':
|
336
|
+
this.addSvgPlaceholder();
|
337
|
+
break;
|
338
|
+
}
|
339
|
+
}
|
340
|
+
|
341
|
+
/**
|
342
|
+
* Add blur placeholder
|
343
|
+
*/
|
344
|
+
private addBlurPlaceholder(): void {
|
345
|
+
const canvas = document.createElement('canvas');
|
346
|
+
canvas.width = 40;
|
347
|
+
canvas.height = 30;
|
348
|
+
|
349
|
+
const ctx = canvas.getContext('2d');
|
350
|
+
if (ctx) {
|
351
|
+
const gradient = ctx.createLinearGradient(0, 0, 40, 30);
|
352
|
+
gradient.addColorStop(0, '#f0f0f0');
|
353
|
+
gradient.addColorStop(1, '#e0e0e0');
|
354
|
+
|
355
|
+
ctx.fillStyle = gradient;
|
356
|
+
ctx.fillRect(0, 0, 40, 30);
|
357
|
+
}
|
358
|
+
|
359
|
+
const blurDataUrl = canvas.toDataURL();
|
360
|
+
const htmlElement = this.element as HTMLElement;
|
361
|
+
|
362
|
+
if (this.element.tagName === 'IMG') {
|
363
|
+
(this.element as HTMLImageElement).src = blurDataUrl;
|
364
|
+
} else {
|
365
|
+
htmlElement.style.backgroundImage = `url(${blurDataUrl})`;
|
366
|
+
htmlElement.style.filter = 'blur(5px)';
|
367
|
+
}
|
368
|
+
}
|
369
|
+
|
370
|
+
/**
|
371
|
+
* Add SVG placeholder
|
372
|
+
*/
|
373
|
+
private addSvgPlaceholder(): void {
|
374
|
+
const svg = `data:image/svg+xml;base64,${btoa(`
|
375
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
376
|
+
<rect width="400" height="300" fill="${this.config.placeholderColor}"/>
|
377
|
+
<circle cx="200" cy="150" r="50" fill="#ddd"/>
|
378
|
+
</svg>
|
379
|
+
`)}`;
|
380
|
+
|
381
|
+
if (this.element.tagName === 'IMG') {
|
382
|
+
(this.element as HTMLImageElement).src = svg;
|
383
|
+
} else {
|
384
|
+
(this.element as HTMLElement).style.backgroundImage = `url(${svg})`;
|
385
|
+
}
|
386
|
+
}
|
387
|
+
|
388
|
+
/**
|
389
|
+
* Remove placeholder
|
390
|
+
*/
|
391
|
+
private removePlaceholder(): void {
|
392
|
+
const htmlElement = this.element as HTMLElement;
|
393
|
+
|
394
|
+
if (this.config.placeholder === 'blur') {
|
395
|
+
htmlElement.style.filter = '';
|
396
|
+
}
|
397
|
+
|
398
|
+
if (this.config.placeholder === 'color') {
|
399
|
+
htmlElement.style.backgroundColor = '';
|
400
|
+
}
|
401
|
+
}
|
402
|
+
|
403
|
+
/**
|
404
|
+
* Handle image loading error
|
405
|
+
*/
|
406
|
+
private handleImageError(): void {
|
407
|
+
console.warn('ProteusJS: Failed to load image', this.element);
|
408
|
+
|
409
|
+
// Try fallback format
|
410
|
+
const fallbackSource = this.sources.find(source => source.format === 'jpeg');
|
411
|
+
if (fallbackSource && fallbackSource !== this.state.optimalSource) {
|
412
|
+
this.state.error = false;
|
413
|
+
this.state.loading = true;
|
414
|
+
|
415
|
+
const img = new Image();
|
416
|
+
img.onload = () => {
|
417
|
+
this.state.loaded = true;
|
418
|
+
this.state.loading = false;
|
419
|
+
this.applyImage(img);
|
420
|
+
};
|
421
|
+
img.src = fallbackSource.src;
|
422
|
+
}
|
423
|
+
}
|
424
|
+
|
425
|
+
/**
|
426
|
+
* Setup image element
|
427
|
+
*/
|
428
|
+
private setupImageElement(): void {
|
429
|
+
if (this.element.tagName === 'IMG') {
|
430
|
+
const imgElement = this.element as HTMLImageElement;
|
431
|
+
imgElement.loading = this.config.lazyLoading ? 'lazy' : 'eager';
|
432
|
+
imgElement.decoding = 'async';
|
433
|
+
}
|
434
|
+
}
|
435
|
+
|
436
|
+
/**
|
437
|
+
* Update image based on container size
|
438
|
+
*/
|
439
|
+
private updateImage(): void {
|
440
|
+
const newOptimalSource = this.getOptimalSource();
|
441
|
+
|
442
|
+
if (newOptimalSource && newOptimalSource !== this.state.optimalSource) {
|
443
|
+
this.state.loaded = false;
|
444
|
+
this.loadImage();
|
445
|
+
}
|
446
|
+
}
|
447
|
+
|
448
|
+
/**
|
449
|
+
* Get base source URL
|
450
|
+
*/
|
451
|
+
private getBaseSrc(): string | null {
|
452
|
+
if (this.element.tagName === 'IMG') {
|
453
|
+
return (this.element as HTMLImageElement).src ||
|
454
|
+
(this.element as HTMLImageElement).dataset['src'] || null;
|
455
|
+
}
|
456
|
+
|
457
|
+
return (this.element as HTMLElement).dataset['src'] || null;
|
458
|
+
}
|
459
|
+
|
460
|
+
/**
|
461
|
+
* Generate source URL with format and size
|
462
|
+
*/
|
463
|
+
private generateSrcUrl(baseSrc: string, format: string, width: number): string {
|
464
|
+
// This would typically integrate with an image CDN or processing service
|
465
|
+
// For now, return the base URL with query parameters
|
466
|
+
const url = new URL(baseSrc, window.location.origin);
|
467
|
+
url.searchParams.set('format', format);
|
468
|
+
url.searchParams.set('width', width.toString());
|
469
|
+
url.searchParams.set('quality', this.config.quality.toString());
|
470
|
+
|
471
|
+
if (this.config.progressive) {
|
472
|
+
url.searchParams.set('progressive', 'true');
|
473
|
+
}
|
474
|
+
|
475
|
+
return url.toString();
|
476
|
+
}
|
477
|
+
|
478
|
+
/**
|
479
|
+
* Calculate height based on width (maintaining aspect ratio)
|
480
|
+
*/
|
481
|
+
private calculateHeight(width: number): number {
|
482
|
+
// Default 16:9 aspect ratio, could be made configurable
|
483
|
+
return Math.round(width * (9 / 16));
|
484
|
+
}
|
485
|
+
|
486
|
+
/**
|
487
|
+
* Check if format is supported
|
488
|
+
*/
|
489
|
+
private isFormatSupported(format: string): boolean {
|
490
|
+
if (format === 'jpeg' || format === 'png') return true;
|
491
|
+
return ResponsiveImages.FORMAT_SUPPORT.get(format) || false;
|
492
|
+
}
|
493
|
+
|
494
|
+
/**
|
495
|
+
* Setup observers
|
496
|
+
*/
|
497
|
+
private setupObservers(): void {
|
498
|
+
// Container size observer
|
499
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
500
|
+
for (const entry of entries) {
|
501
|
+
this.state.containerSize = {
|
502
|
+
width: entry.contentRect.width,
|
503
|
+
height: entry.contentRect.height
|
504
|
+
};
|
505
|
+
|
506
|
+
if (this.config.containerBased) {
|
507
|
+
this.updateImage();
|
508
|
+
}
|
509
|
+
}
|
510
|
+
});
|
511
|
+
this.resizeObserver.observe(this.element);
|
512
|
+
|
513
|
+
// Lazy loading observer
|
514
|
+
if (this.config.lazyLoading) {
|
515
|
+
this.intersectionObserver = new IntersectionObserver((entries) => {
|
516
|
+
for (const entry of entries) {
|
517
|
+
if (entry.isIntersecting) {
|
518
|
+
this.state.intersecting = true;
|
519
|
+
this.loadImage();
|
520
|
+
this.intersectionObserver?.unobserve(this.element);
|
521
|
+
}
|
522
|
+
}
|
523
|
+
}, { rootMargin: '50px' });
|
524
|
+
|
525
|
+
this.intersectionObserver.observe(this.element);
|
526
|
+
}
|
527
|
+
}
|
528
|
+
|
529
|
+
/**
|
530
|
+
* Clean up observers
|
531
|
+
*/
|
532
|
+
private cleanupObservers(): void {
|
533
|
+
if (this.resizeObserver) {
|
534
|
+
this.resizeObserver.disconnect();
|
535
|
+
this.resizeObserver = null;
|
536
|
+
}
|
537
|
+
|
538
|
+
if (this.intersectionObserver) {
|
539
|
+
this.intersectionObserver.disconnect();
|
540
|
+
this.intersectionObserver = null;
|
541
|
+
}
|
542
|
+
}
|
543
|
+
|
544
|
+
/**
|
545
|
+
* Remove image features
|
546
|
+
*/
|
547
|
+
private removeImageFeatures(): void {
|
548
|
+
this.removePlaceholder();
|
549
|
+
|
550
|
+
const htmlElement = this.element as HTMLElement;
|
551
|
+
htmlElement.style.transition = '';
|
552
|
+
htmlElement.style.opacity = '';
|
553
|
+
htmlElement.style.filter = '';
|
554
|
+
}
|
555
|
+
|
556
|
+
/**
|
557
|
+
* Create initial state
|
558
|
+
*/
|
559
|
+
private createInitialState(): ImageState {
|
560
|
+
return {
|
561
|
+
loaded: false,
|
562
|
+
loading: false,
|
563
|
+
error: false,
|
564
|
+
currentSrc: '',
|
565
|
+
containerSize: { width: 0, height: 0 },
|
566
|
+
optimalSource: null,
|
567
|
+
intersecting: false
|
568
|
+
};
|
569
|
+
}
|
570
|
+
}
|
@@ -0,0 +1,147 @@
|
|
1
|
+
/**
|
2
|
+
* Event System for ProteusJS
|
3
|
+
* Handles all internal and external events
|
4
|
+
*/
|
5
|
+
|
6
|
+
import type { ProteusEvent } from '../types';
|
7
|
+
|
8
|
+
export type EventCallback = (event: ProteusEvent) => void;
|
9
|
+
|
10
|
+
export class EventSystem {
|
11
|
+
private listeners: Map<string, Set<EventCallback>> = new Map();
|
12
|
+
private initialized: boolean = false;
|
13
|
+
|
14
|
+
/**
|
15
|
+
* Initialize the event system
|
16
|
+
*/
|
17
|
+
public init(): void {
|
18
|
+
if (this.initialized) return;
|
19
|
+
this.initialized = true;
|
20
|
+
}
|
21
|
+
|
22
|
+
/**
|
23
|
+
* Add event listener
|
24
|
+
*/
|
25
|
+
public on(eventType: string, callback: EventCallback): () => void {
|
26
|
+
if (!this.listeners.has(eventType)) {
|
27
|
+
this.listeners.set(eventType, new Set());
|
28
|
+
}
|
29
|
+
|
30
|
+
const callbacks = this.listeners.get(eventType)!;
|
31
|
+
callbacks.add(callback);
|
32
|
+
|
33
|
+
// Return unsubscribe function
|
34
|
+
return () => {
|
35
|
+
callbacks.delete(callback);
|
36
|
+
if (callbacks.size === 0) {
|
37
|
+
this.listeners.delete(eventType);
|
38
|
+
}
|
39
|
+
};
|
40
|
+
}
|
41
|
+
|
42
|
+
/**
|
43
|
+
* Add one-time event listener
|
44
|
+
*/
|
45
|
+
public once(eventType: string, callback: EventCallback): () => void {
|
46
|
+
const unsubscribe = this.on(eventType, (event) => {
|
47
|
+
callback(event);
|
48
|
+
unsubscribe();
|
49
|
+
});
|
50
|
+
return unsubscribe;
|
51
|
+
}
|
52
|
+
|
53
|
+
/**
|
54
|
+
* Remove event listener
|
55
|
+
*/
|
56
|
+
public off(eventType: string, callback?: EventCallback): void {
|
57
|
+
if (!callback) {
|
58
|
+
// Remove all listeners for this event type
|
59
|
+
this.listeners.delete(eventType);
|
60
|
+
return;
|
61
|
+
}
|
62
|
+
|
63
|
+
const callbacks = this.listeners.get(eventType);
|
64
|
+
if (callbacks) {
|
65
|
+
callbacks.delete(callback);
|
66
|
+
if (callbacks.size === 0) {
|
67
|
+
this.listeners.delete(eventType);
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
/**
|
73
|
+
* Emit event
|
74
|
+
*/
|
75
|
+
public emit(eventType: string, detail?: any, target?: Element): void {
|
76
|
+
const callbacks = this.listeners.get(eventType);
|
77
|
+
if (!callbacks || callbacks.size === 0) return;
|
78
|
+
|
79
|
+
const event: ProteusEvent = {
|
80
|
+
type: eventType,
|
81
|
+
target: target || document.documentElement,
|
82
|
+
detail,
|
83
|
+
timestamp: Date.now()
|
84
|
+
};
|
85
|
+
|
86
|
+
// Execute callbacks
|
87
|
+
callbacks.forEach(callback => {
|
88
|
+
try {
|
89
|
+
callback(event);
|
90
|
+
} catch (error) {
|
91
|
+
console.error(`ProteusJS: Error in event listener for "${eventType}":`, error);
|
92
|
+
}
|
93
|
+
});
|
94
|
+
}
|
95
|
+
|
96
|
+
/**
|
97
|
+
* Get all event types with listeners
|
98
|
+
*/
|
99
|
+
public getEventTypes(): string[] {
|
100
|
+
return Array.from(this.listeners.keys());
|
101
|
+
}
|
102
|
+
|
103
|
+
/**
|
104
|
+
* Get listener count for event type
|
105
|
+
*/
|
106
|
+
public getListenerCount(eventType: string): number {
|
107
|
+
const callbacks = this.listeners.get(eventType);
|
108
|
+
return callbacks ? callbacks.size : 0;
|
109
|
+
}
|
110
|
+
|
111
|
+
/**
|
112
|
+
* Check if event type has listeners
|
113
|
+
*/
|
114
|
+
public hasListeners(eventType: string): boolean {
|
115
|
+
return this.getListenerCount(eventType) > 0;
|
116
|
+
}
|
117
|
+
|
118
|
+
/**
|
119
|
+
* Clear all listeners
|
120
|
+
*/
|
121
|
+
public clear(): void {
|
122
|
+
this.listeners.clear();
|
123
|
+
}
|
124
|
+
|
125
|
+
/**
|
126
|
+
* Destroy the event system
|
127
|
+
*/
|
128
|
+
public destroy(): void {
|
129
|
+
this.clear();
|
130
|
+
this.initialized = false;
|
131
|
+
}
|
132
|
+
|
133
|
+
/**
|
134
|
+
* Get debug information
|
135
|
+
*/
|
136
|
+
public getDebugInfo(): object {
|
137
|
+
const info: Record<string, number> = {};
|
138
|
+
this.listeners.forEach((callbacks, eventType) => {
|
139
|
+
info[eventType] = callbacks.size;
|
140
|
+
});
|
141
|
+
return {
|
142
|
+
initialized: this.initialized,
|
143
|
+
totalEventTypes: this.listeners.size,
|
144
|
+
listeners: info
|
145
|
+
};
|
146
|
+
}
|
147
|
+
}
|