move.gl
Version:
Motion and Animation Library for Stylescape.
1 lines • 74.2 kB
Source Map (JSON)
{"version":3,"sources":["../../src/ts/Draggable.ts","../../src/ts/Screensaver.ts","../../src/ts/Keyboard.ts","../../src/ts/Gesture.ts","../../src/ts/VideoOverlay.ts","../../src/ts/LoaderManager.ts","../../src/ts/index.ts"],"sourcesContent":["// ============================================================================\n// move.gl | Draggable\n// ============================================================================\n// Copyright 2025 Scape Agency BV\n// Licensed under MIT License\n// ============================================================================\n\n/**\n * Options for the Draggable class\n */\nexport interface DraggableOptions {\n /** Whether to constrain dragging to parent bounds */\n constrainToParent?: boolean;\n /** CSS cursor style during drag */\n dragCursor?: string;\n /** Callback when drag starts */\n onDragStart?: (x: number, y: number) => void;\n /** Callback during drag */\n onDrag?: (x: number, y: number) => void;\n /** Callback when drag ends */\n onDragEnd?: (x: number, y: number) => void;\n}\n\n/**\n * Draggable Element Handler\n *\n * Provides functionality to make an element draggable within the confines\n * of its parent container. Supports both mouse and touch interactions,\n * ensuring usability across different devices.\n *\n * @example\n * ```typescript\n * const draggable = new Draggable('myElement');\n * // Element with id=\"myElement\" is now draggable\n * ```\n */\nexport class Draggable {\n private element: HTMLElement;\n private isDragging: boolean = false;\n private startX: number = 0;\n private startY: number = 0;\n private boundRect: DOMRect;\n\n /**\n * Creates a new Draggable instance.\n * @param elementId - The ID of the HTML element to make draggable.\n * @throws Error if element or parent element is not found.\n */\n constructor(elementId: string) {\n const element = document.getElementById(elementId);\n if (!element) {\n throw new Error(`Element with id \"${elementId}\" not found`);\n }\n this.element = element;\n\n const parent = this.element.parentElement;\n if (!parent) {\n throw new Error('Draggable element must have a parent element');\n }\n this.boundRect = parent.getBoundingClientRect();\n this.attachEventListeners();\n }\n\n /**\n * Attaches all necessary event listeners for drag functionality.\n */\n private attachEventListeners(): void {\n this.element.addEventListener('mousedown', this.startDrag);\n this.element.addEventListener('touchstart', this.startDrag, { passive: false });\n\n document.addEventListener('mouseup', this.stopDrag);\n document.addEventListener('touchend', this.stopDrag);\n\n document.addEventListener('mousemove', this.drag);\n document.addEventListener('touchmove', this.drag, { passive: false });\n }\n\n /**\n * Gets the client coordinates from a mouse or touch event.\n */\n private getClientCoordinates(event: MouseEvent | TouchEvent): { clientX: number; clientY: number } {\n if ('touches' in event && event.touches.length > 0) {\n return {\n clientX: event.touches[0].clientX,\n clientY: event.touches[0].clientY\n };\n }\n return {\n clientX: (event as MouseEvent).clientX,\n clientY: (event as MouseEvent).clientY\n };\n }\n\n /**\n * Initiates the drag operation.\n */\n private startDrag = (event: MouseEvent | TouchEvent): void => {\n const coords = this.getClientCoordinates(event);\n this.isDragging = true;\n this.startX = coords.clientX - this.element.offsetLeft;\n this.startY = coords.clientY - this.element.offsetTop;\n event.preventDefault();\n };\n\n /**\n * Handles the drag movement.\n */\n private drag = (event: MouseEvent | TouchEvent): void => {\n if (!this.isDragging) return;\n\n const coords = this.getClientCoordinates(event);\n let x = coords.clientX - this.startX;\n let y = coords.clientY - this.startY;\n\n // Constrain the movement within the bounds of the element's parent\n x = Math.max(this.boundRect.left, Math.min(x, this.boundRect.right - this.element.offsetWidth));\n y = Math.max(this.boundRect.top, Math.min(y, this.boundRect.bottom - this.element.offsetHeight));\n\n this.element.style.left = `${x}px`;\n this.element.style.top = `${y}px`;\n };\n\n /**\n * Stops the drag operation.\n */\n private stopDrag = (): void => {\n this.isDragging = false;\n };\n\n /**\n * Removes all event listeners and cleans up.\n */\n public destroy(): void {\n this.element.removeEventListener('mousedown', this.startDrag);\n this.element.removeEventListener('touchstart', this.startDrag);\n document.removeEventListener('mouseup', this.stopDrag);\n document.removeEventListener('touchend', this.stopDrag);\n document.removeEventListener('mousemove', this.drag);\n document.removeEventListener('touchmove', this.drag);\n }\n}\n\nexport default Draggable;\n","// ============================================================================\n// move.gl | Screensaver\n// ============================================================================\n// Copyright 2025 Scape Agency BV\n// Licensed under MIT License\n// ============================================================================\n\n/**\n * Screensaver Configuration Options\n */\nexport interface ScreensaverOptions {\n /** Inactivity timeout in milliseconds */\n timeout: number;\n /** URL for the video to play */\n videoUrl?: string;\n /** URL for the audio to play */\n audioUrl?: string;\n /** ID of the screensaver container element */\n containerId?: string;\n /** ID of the video element */\n videoId?: string;\n /** ID of the audio element */\n audioId?: string;\n}\n\n/**\n * Screensaver Class\n *\n * Handles the activation and deactivation of a screensaver based on\n * user inactivity. Provides methods to start and stop the screensaver,\n * manage media sources, and handle user interactions.\n *\n * @example\n * ```typescript\n * const screensaver = new Screensaver({\n * timeout: 300000, // 5 minutes\n * videoUrl: 'path/to/video.mp4',\n * audioUrl: 'path/to/audio.mp3'\n * });\n * screensaver.setVolume(0.5);\n * ```\n */\nexport class Screensaver {\n private timeoutId: number | undefined;\n private readonly timeout: number;\n private screensaverElement: HTMLElement | null = null;\n private videoElement: HTMLVideoElement | null = null;\n private audioElement: HTMLAudioElement | null = null;\n private isActive: boolean = false;\n private readonly options: ScreensaverOptions;\n\n /**\n * Creates a new Screensaver instance.\n * @param options - Configuration options for the screensaver.\n */\n constructor(options: ScreensaverOptions) {\n this.options = {\n containerId: 'screensaver',\n videoId: 'screensaverVideo',\n audioId: 'screensaverAudio',\n ...options\n };\n this.timeout = options.timeout;\n this.initializeElements();\n if (options.videoUrl && options.audioUrl) {\n this.loadMedia(options.videoUrl, options.audioUrl);\n }\n this.setupEventListeners();\n this.startScreensaverTimeout();\n }\n\n /**\n * Initializes HTML elements from the DOM.\n */\n private initializeElements(): void {\n this.screensaverElement = document.getElementById(this.options.containerId!);\n this.videoElement = document.getElementById(this.options.videoId!) as HTMLVideoElement | null;\n this.audioElement = document.getElementById(this.options.audioId!) as HTMLAudioElement | null;\n }\n\n /**\n * Loads media sources into the video and audio elements.\n * @param videoUrl - The source URL of the video.\n * @param audioUrl - The source URL of the audio.\n */\n private loadMedia(videoUrl: string, audioUrl: string): void {\n if (this.videoElement) {\n this.videoElement.src = videoUrl;\n }\n if (this.audioElement) {\n this.audioElement.src = audioUrl;\n }\n }\n\n /**\n * @notice Sets up event listeners for user interaction to prevent\n * screensaver activation.\n * @dev Listens for 'mousemove', 'keydown', and 'touchstart' events\n * to reset the screensaver timer.\n */\n private setupEventListeners() {\n ['mousemove', 'keydown', 'touchstart'].forEach(event => {\n document.addEventListener(event, this.resetScreensaver);\n });\n }\n\n /**\n * @notice Starts or restarts the screensaver timeout.\n * @dev Resets any existing timeout and sets a new timeout to activate\n * the screensaver.\n */\n private startScreensaverTimeout() {\n this.stopScreensaver(); // Stop existing screensaver if active\n this.timeoutId = window.setTimeout(\n () => this.activateScreensaver(), this.timeout\n );\n }\n\n /**\n * @notice Resets the screensaver timer and stops the screensaver if\n * active.\n * @dev Called upon user interactions detected by event listeners.\n */\n private resetScreensaver = () => {\n if (this.isActive) {\n this.stopScreensaver();\n }\n this.startScreensaverTimeout();\n };\n\n /**\n * Activates the screensaver, displaying elements and playing media.\n */\n private activateScreensaver = (): void => {\n if (this.screensaverElement) {\n this.screensaverElement.style.display = 'block';\n }\n this.videoElement?.play();\n this.audioElement?.play();\n this.isActive = true;\n };\n\n /**\n * Stops the screensaver and hides its elements.\n */\n public stopScreensaver(): void {\n if (this.screensaverElement) {\n this.screensaverElement.style.display = 'none';\n }\n this.videoElement?.pause();\n this.audioElement?.pause();\n this.isActive = false;\n\n if (this.timeoutId !== undefined) {\n clearTimeout(this.timeoutId);\n this.timeoutId = undefined;\n }\n }\n\n /**\n * Sets the volume for both video and audio elements.\n * @param volume - A number between 0.0 and 1.0 indicating the volume level.\n */\n public setVolume(volume: number): void {\n const clampedVolume = Math.max(0, Math.min(1, volume));\n if (this.videoElement) {\n this.videoElement.volume = clampedVolume;\n }\n if (this.audioElement) {\n this.audioElement.volume = clampedVolume;\n }\n }\n\n /**\n * Returns whether the screensaver is currently active.\n */\n public getIsActive(): boolean {\n return this.isActive;\n }\n\n /**\n * Cleans up event listeners and stops the screensaver.\n */\n public destroy(): void {\n this.stopScreensaver();\n ['mousemove', 'keydown', 'touchstart'].forEach(event => {\n document.removeEventListener(event, this.resetScreensaver);\n });\n }\n}\n\nexport default Screensaver;\n","// ============================================================================\n// move.gl | Virtual Keyboard\n// ============================================================================\n// Copyright 2025 Scape Agency BV\n// Licensed under MIT License\n// ============================================================================\n\n/**\n * Keyboard layout configuration\n */\nexport interface KeyboardLayout {\n [mode: string]: string[][];\n}\n\n/**\n * Virtual Keyboard Configuration Options\n */\nexport interface VirtualKeyboardOptions {\n /** Custom keyboard layout */\n layout?: KeyboardLayout;\n /** Callback when a key is pressed */\n onKeyPress?: (key: string) => void;\n}\n\n/**\n * Virtual Keyboard\n *\n * Manages the rendering and interaction of a virtual keyboard on the web.\n * Supports multiple layouts (default, shift, special) and handles both\n * mouse and keyboard inputs, including touch support.\n *\n * @example\n * ```typescript\n * const keyboard = new VirtualKeyboard('textInput', 'keyboard');\n * keyboard.switchMode('special');\n * ```\n */\nexport class VirtualKeyboard {\n\n private keys: { [mode: string]: string[][] } = {\n \"default\": [\n [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"0\"],\n [\"q\", \"w\", \"e\", \"r\", \"t\", \"y\", \"u\", \"i\", \"o\", \"p\"],\n [\"a\", \"s\", \"d\", \"f\", \"g\", \"h\", \"j\", \"k\", \"l\"],\n [\"z\", \"x\", \"c\", \"v\", \"b\", \"n\", \"m\", \"Backspace\"]\n ],\n \"shift\": [\n [\"!\", \"@\", \"#\", \"$\", \"%\", \"^\", \"&\", \"*\", \"(\", \")\"],\n [\"Q\", \"W\", \"E\", \"R\", \"T\", \"Y\", \"U\", \"I\", \"O\", \"P\"],\n [\"A\", \"S\", \"D\", \"F\", \"G\", \"H\", \"J\", \"K\", \"L\"],\n [\"Z\", \"X\", \"C\", \"V\", \"B\", \"N\", \"M\", \"Backspace\"]\n ],\n \"special\": [\n [\"[\", \"]\", \"{\", \"}\", \"#\", \"%\", \"^\", \"*\", \"+\", \"=\"],\n [\"_\", \"\\\\\", \"|\", \"~\", \"<\", \">\", \"€\", \"£\", \"¥\"],\n [\".\", \",\", \"?\", \"!\", \"'\", '\"', \":\", \";\", \"Backspace\"]\n ]\n };\n private currentMode = \"default\";\n private inputElement: HTMLInputElement;\n private keyboardElement: HTMLElement;\n\n /**\n * @notice Initializes the virtual keyboard with specific input and\n * keyboard element IDs.\n * @param inputId The ID of the HTML input element to which the keyboard\n * will be linked.\n * @param keyboardId The ID of the container element where the keyboard\n * will be rendered.\n */\n constructor(inputId: string, keyboardId: string) {\n this.inputElement = document.getElementById(\n inputId\n ) as HTMLInputElement;\n this.keyboardElement = document.getElementById(\n keyboardId\n ) as HTMLElement;\n this.renderKeyboard();\n this.attachEventListeners();\n }\n\n /**\n * @notice Renders the keyboard based on the current mode (default, shift,\n * or special).\n * @dev Dynamically creates HTML for keyboard keys and appends them to the\n * keyboardElement.\n */\n private renderKeyboard() {\n // Clear existing keys\n this.keyboardElement.innerHTML = \"\";\n this.keys[this.currentMode].forEach(row => {\n const rowElement = document.createElement(\"div\");\n rowElement.className = \"keyboard__row\";\n row.forEach(key => {\n const keyElement = document.createElement(\"div\");\n keyElement.textContent = key;\n // Assign a class for easier CSS styling\n keyElement.className = \"key\";\n keyElement.addEventListener(\n \"click\", () => this.handleKeyPress(key)\n );\n rowElement.appendChild(keyElement);\n });\n this.keyboardElement.appendChild(rowElement);\n });\n }\n\n /**\n * @notice Handles key presses on the virtual keyboard.\n * @param key The key character or function (like \"Backspace\") that was\n * pressed.\n */\n private handleKeyPress(key: string) {\n if (key === \"Backspace\") {\n this.inputElement.value = this.inputElement.value.slice(0, -1);\n } else if (key === \"Shift\" || key === \"CapsLock\") {\n this.toggleShift();\n } else {\n this.inputElement.value += key;\n }\n }\n\n /**\n * @notice Toggles the keyboard between \"default\" and \"shift\" modes.\n * @dev This method is called when the \"Shift\" or \"CapsLock\" key is pressed.\n */\n private toggleShift() {\n this.currentMode = this.currentMode === \"default\" ? \"shift\" : \"default\";\n this.renderKeyboard();\n }\n\n /**\n * @notice Attaches necessary event listeners to handle both physical\n * keyboard and touch inputs.\n */\n private attachEventListeners() {\n document.addEventListener(\"keydown\", this.handlePhysicalKeyPress);\n this.keyboardElement.addEventListener(\n \"touchstart\", this.handleTouchStart, false\n );\n }\n\n /**\n * @notice Handles physical keyboard events and maps them to virtual key\n * presses.\n * @param event The keyboard event captured from the user\"s physical\n * keyboard.\n */\n private handlePhysicalKeyPress = (event: KeyboardEvent) => {\n const key = event.key;\n if (key === \"Shift\" || key === \"CapsLock\") {\n this.toggleShift();\n event.preventDefault();\n } else if (key === \"Enter\" || key === \"Tab\") {\n // Optional: Implement behavior for Enter and Tab if needed\n } else {\n this.handleKeyPress(key);\n }\n };\n\n /**\n * @notice Handles touch events on the keyboard element.\n * @param event The touch event on the virtual keyboard.\n */\n private handleTouchStart = (event: TouchEvent) => {\n event.preventDefault(); // Prevents emulating mouse events\n const keyElement = event.target as HTMLElement;\n if (keyElement.classList.contains(\"key\")) {\n this.handleKeyPress(keyElement.textContent || \"\");\n }\n };\n\n /**\n * @notice Switches the keyboard layout to a specified mode.\n * @param mode The mode to which the keyboard layout should switch\n * (\"default\", \"shift\", or \"special\").\n */\n public switchMode(mode: string) {\n if (this.keys[mode]) {\n this.currentMode = mode;\n this.renderKeyboard();\n }\n }\n\n /**\n * Removes all event listeners and cleans up.\n */\n public destroy(): void {\n document.removeEventListener('keydown', this.handlePhysicalKeyPress);\n this.keyboardElement.removeEventListener('touchstart', this.handleTouchStart);\n this.keyboardElement.innerHTML = '';\n }\n}\n\nexport default VirtualKeyboard;\n","// ============================================================================\n// move.gl | Gesture Handlers\n// ============================================================================\n// Copyright 2025 Scape Agency BV\n// Licensed under MIT License\n// ============================================================================\n\n/**\n * Swipe direction type\n */\nexport type SwipeDirection = 'left' | 'right' | 'up' | 'down';\n\n/**\n * Gesture event callbacks\n */\nexport interface GestureCallbacks {\n onTap?: () => void;\n onSwipe?: (direction: SwipeDirection, deltaX: number, deltaY: number) => void;\n onPinch?: (scale: number) => void;\n onRotate?: (angle: number) => void;\n}\n\n/**\n * Touch Gesture Handler Class\n *\n * Manages touch interactions on a specified element, interpreting various\n * gestures like taps, swipes, and pinches.\n *\n * @example\n * ```typescript\n * const gesture = new TouchGestureHandler('myElement', {\n * onSwipe: (dir, dx, dy) => console.log(`Swiped ${dir}`),\n * onPinch: (scale) => console.log(`Pinch scale: ${scale}`)\n * });\n * ```\n */\nexport class TouchGestureHandler {\n private element: HTMLElement;\n private startTouches: Touch[] | null = null;\n private lastTouches: Touch[] | null = null;\n private isSwiping = false;\n private isPinching = false;\n private callbacks: GestureCallbacks;\n\n /**\n * Creates a new TouchGestureHandler instance.\n * @param elementId - The ID of the element to attach gesture handling to.\n * @param callbacks - Optional callback functions for gesture events.\n */\n constructor(elementId: string, callbacks: GestureCallbacks = {}) {\n const element = document.getElementById(elementId);\n if (!element) {\n throw new Error(`Element with id \"${elementId}\" not found`);\n }\n this.element = element;\n this.callbacks = callbacks;\n this.addTouchListeners();\n }\n\n private addTouchListeners(): void {\n this.element.addEventListener('touchstart', this.handleTouchStart, false);\n this.element.addEventListener('touchmove', this.handleTouchMove, false);\n this.element.addEventListener('touchend', this.handleTouchEnd, false);\n }\n\n private handleTouchStart = (event: TouchEvent): void => {\n if (event.touches.length === 1) {\n this.startTouches = Array.from(event.touches);\n } else if (event.touches.length > 1) {\n this.startTouches = Array.from(event.touches);\n this.isPinching = true;\n }\n };\n\n private handleTouchMove = (event: TouchEvent): void => {\n if (!this.startTouches) return;\n\n this.lastTouches = Array.from(event.touches);\n\n if (event.touches.length === 1 && !this.isPinching) {\n const dx = event.touches[0].clientX - this.startTouches[0].clientX;\n const dy = event.touches[0].clientY - this.startTouches[0].clientY;\n if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {\n this.isSwiping = true;\n }\n } else if (event.touches.length > 1 && this.isPinching && this.startTouches.length > 1) {\n const startDistance = this.getDistance(this.startTouches[0], this.startTouches[1]);\n const currentDistance = this.getDistance(event.touches[0], event.touches[1]);\n const scale = currentDistance / startDistance;\n this.callbacks.onPinch?.(scale);\n }\n };\n\n private handleTouchEnd = (): void => {\n if (this.isSwiping && this.startTouches && this.lastTouches) {\n const dx = this.lastTouches[0].clientX - this.startTouches[0].clientX;\n const dy = this.lastTouches[0].clientY - this.startTouches[0].clientY;\n const direction = this.getSwipeDirection(dx, dy);\n this.callbacks.onSwipe?.(direction, dx, dy);\n this.isSwiping = false;\n } else if (this.isPinching) {\n this.isPinching = false;\n } else {\n this.callbacks.onTap?.();\n }\n this.startTouches = null;\n this.lastTouches = null;\n };\n\n /**\n * Determines swipe direction based on deltas.\n */\n private getSwipeDirection(dx: number, dy: number): SwipeDirection {\n if (Math.abs(dx) > Math.abs(dy)) {\n return dx > 0 ? 'right' : 'left';\n }\n return dy > 0 ? 'down' : 'up';\n }\n\n /**\n * Calculates the distance between two touch points.\n */\n private getDistance(touch1: Touch, touch2: Touch): number {\n const dx = touch2.clientX - touch1.clientX;\n const dy = touch2.clientY - touch1.clientY;\n return Math.sqrt(dx * dx + dy * dy);\n }\n\n /**\n * Removes all event listeners and cleans up.\n */\n public destroy(): void {\n this.element.removeEventListener('touchstart', this.handleTouchStart);\n this.element.removeEventListener('touchmove', this.handleTouchMove);\n this.element.removeEventListener('touchend', this.handleTouchEnd);\n }\n}\n\n\n/**\n * Pointer event callbacks\n */\nexport interface PointerGestureCallbacks {\n onGestureStart?: (event: PointerEvent) => void;\n onGestureMove?: (deltaX: number, deltaY: number, event: PointerEvent) => void;\n onGestureEnd?: (event: PointerEvent) => void;\n}\n\n/**\n * Advanced Gesture Recognition Handler\n *\n * Handles complex gestures for interactive applications using pointer events.\n * Works with mouse, touch, and pen input.\n *\n * @example\n * ```typescript\n * const gesture = new AdvancedGestureRecognition('myElement', {\n * onGestureMove: (dx, dy) => console.log(`Moved ${dx}px, ${dy}px`)\n * });\n * ```\n */\nexport class AdvancedGestureRecognition {\n private element: HTMLElement;\n private ongoingTouches: Map<number, PointerEvent> = new Map();\n private callbacks: PointerGestureCallbacks;\n\n /**\n * Creates a new AdvancedGestureRecognition instance.\n * @param elementId - The ID of the element to attach gesture handling to.\n * @param callbacks - Optional callback functions for gesture events.\n */\n constructor(elementId: string, callbacks: PointerGestureCallbacks = {}) {\n const element = document.getElementById(elementId);\n if (!element) {\n throw new Error(`Element with id \"${elementId}\" not found`);\n }\n this.element = element;\n this.callbacks = callbacks;\n this.attachEventListeners();\n }\n\n private attachEventListeners(): void {\n this.element.addEventListener('pointerdown', this.handleGestureStart, { passive: false });\n this.element.addEventListener('pointermove', this.handleGestureMove, { passive: false });\n this.element.addEventListener('pointerup', this.handleGestureEnd, { passive: false });\n this.element.addEventListener('pointercancel', this.handleGestureEnd, { passive: false });\n }\n\n private handleGestureStart = (event: PointerEvent): void => {\n this.ongoingTouches.set(event.pointerId, event);\n this.callbacks.onGestureStart?.(event);\n };\n\n private handleGestureMove = (event: PointerEvent): void => {\n if (this.ongoingTouches.has(event.pointerId)) {\n const startEvent = this.ongoingTouches.get(event.pointerId)!;\n const dx = event.clientX - startEvent.clientX;\n const dy = event.clientY - startEvent.clientY;\n this.callbacks.onGestureMove?.(dx, dy, event);\n }\n };\n\n private handleGestureEnd = (event: PointerEvent): void => {\n this.ongoingTouches.delete(event.pointerId);\n this.callbacks.onGestureEnd?.(event);\n };\n\n /**\n * Removes all event listeners and cleans up.\n */\n public destroy(): void {\n this.element.removeEventListener('pointerdown', this.handleGestureStart);\n this.element.removeEventListener('pointermove', this.handleGestureMove);\n this.element.removeEventListener('pointerup', this.handleGestureEnd);\n this.element.removeEventListener('pointercancel', this.handleGestureEnd);\n }\n}\n\nexport default {\n TouchGestureHandler,\n AdvancedGestureRecognition\n};\n\n\n\n\n","// ============================================================================\n// move.gl | Video Overlay\n// ============================================================================\n// Copyright 2025 Scape Agency BV\n// Licensed under MIT License\n// ============================================================================\n\n/**\n * Video overlay options\n */\nexport interface VideoOverlayOptions {\n /** Fade transition duration in milliseconds */\n fadeTransitionDuration?: number;\n /** Whether to loop the video */\n loop?: boolean;\n /** Initial video source URL */\n initialSource?: string;\n}\n\n/**\n * Transparent Video Overlay Handler\n *\n * Manages a transparent video overlay, controlling its visibility,\n * playback, and effects.\n *\n * @example\n * ```typescript\n * const overlay = new TransparentVideoOverlay('myVideo', {\n * fadeTransitionDuration: 500,\n * loop: true\n * });\n * overlay.showOverlay();\n * ```\n */\nexport class TransparentVideoOverlay {\n private videoElement: HTMLVideoElement | null = null;\n private isVisible: boolean = false;\n private fadeTransitionDuration: number;\n private loop: boolean;\n\n /**\n * Creates a new TransparentVideoOverlay instance.\n * @param videoElementId - The ID of the video element to manage.\n * @param options - Optional configuration options.\n */\n constructor(videoElementId: string, options: VideoOverlayOptions = {}) {\n const element = document.getElementById(videoElementId);\n if (element instanceof HTMLVideoElement) {\n this.videoElement = element;\n } else {\n console.warn(`Element with id \"${videoElementId}\" is not a video element`);\n }\n\n this.fadeTransitionDuration = options.fadeTransitionDuration ?? 500;\n this.loop = options.loop ?? true;\n\n if (this.videoElement) {\n this.setupVideo();\n if (options.initialSource) {\n this.changeVideoSource(options.initialSource, false);\n }\n }\n }\n\n /**\n * Initializes video settings and event listeners.\n */\n private setupVideo(): void {\n if (!this.videoElement) return;\n\n if (this.loop) {\n this.videoElement.addEventListener('ended', () => {\n this.videoElement?.play();\n });\n }\n\n this.videoElement.addEventListener('loadeddata', () => {\n console.log('Video loaded successfully.');\n });\n\n this.videoElement.addEventListener('error', (e) => {\n console.error('Error loading video:', e);\n });\n\n // Set initial style for smooth transitions\n this.videoElement.style.transition = `opacity ${this.fadeTransitionDuration}ms ease`;\n }\n\n /**\n * Shows the video overlay with a fade-in effect.\n */\n public showOverlay(): void {\n if (!this.videoElement) return;\n\n this.videoElement.style.display = 'block';\n this.videoElement.style.opacity = '0';\n\n // Use requestAnimationFrame for smoother transition\n requestAnimationFrame(() => {\n if (this.videoElement) {\n this.videoElement.style.opacity = '1';\n this.videoElement.play().catch(err => {\n console.warn('Auto-play prevented:', err);\n });\n }\n });\n\n this.isVisible = true;\n }\n\n /**\n * Hides the video overlay with a fade-out effect.\n */\n public hideOverlay(): void {\n if (!this.videoElement) return;\n\n this.videoElement.style.opacity = '0';\n\n setTimeout(() => {\n if (this.videoElement) {\n this.videoElement.style.display = 'none';\n this.videoElement.pause();\n }\n }, this.fadeTransitionDuration);\n\n this.isVisible = false;\n }\n\n /**\n * Toggles the visibility of the video overlay.\n */\n public toggleOverlay(): void {\n if (this.isVisible) {\n this.hideOverlay();\n } else {\n this.showOverlay();\n }\n }\n\n /**\n * Changes the video source and optionally plays it immediately.\n * @param videoUrl - The URL of the new video source.\n * @param autoPlay - Whether the video should play immediately after loading.\n */\n public changeVideoSource(videoUrl: string, autoPlay: boolean = true): void {\n if (!this.videoElement) return;\n\n this.videoElement.src = videoUrl;\n this.videoElement.load();\n\n if (autoPlay) {\n this.showOverlay();\n }\n }\n\n /**\n * Gets the visibility state of the overlay.\n */\n public getIsVisible(): boolean {\n return this.isVisible;\n }\n\n /**\n * Cleans up the video overlay instance.\n */\n public destroy(): void {\n if (this.videoElement) {\n this.videoElement.pause();\n this.videoElement.src = '';\n this.videoElement = null;\n }\n }\n}\n\n/**\n * Checks if the browser supports HEVC alpha channel videos.\n * This is primarily supported in Safari.\n * @returns Whether HEVC alpha is supported.\n */\nexport function supportsHEVCAlpha(): boolean {\n const navigator = window.navigator;\n const ua = navigator.userAgent.toLowerCase();\n const hasMediaCapabilities = !!(\n navigator.mediaCapabilities &&\n navigator.mediaCapabilities.decodingInfo\n );\n const isSafari = (\n ua.indexOf('safari') !== -1 &&\n ua.indexOf('chrome') === -1 &&\n ua.indexOf('version/') !== -1\n );\n return isSafari && hasMediaCapabilities;\n}\n\n/**\n * Gets the appropriate video source based on browser support.\n * @param hevcSource - The HEVC/MOV source for Safari.\n * @param webmSource - The WebM source for other browsers.\n * @returns The appropriate video source URL.\n */\nexport function getOptimalVideoSource(hevcSource: string, webmSource: string): string {\n return supportsHEVCAlpha() ? hevcSource : webmSource;\n}\n\nexport default TransparentVideoOverlay;\n","// ============================================================================\n// move.gl | Loader Manager\n// ============================================================================\n// Copyright 2025 Scape Agency BV\n// Licensed under MIT License\n// ============================================================================\n\n/**\n * Loader Manager\n * ===========================================================================\n *\n * A TypeScript class for dynamically creating and managing CSS-based loader\n * animations with Shadow DOM encapsulation.\n *\n * @module LoaderManager\n * @author Scape Agency\n * @link https://move.gl\n * @since 0.1.0\n */\n\n\n// ============================================================================\n// Interfaces\n// ============================================================================\n\n/**\n * Configuration for a loader instance.\n */\nexport interface LoaderConfig {\n /** Unique identifier for the loader */\n id: string;\n /** HTML content for the loader (default: empty span) */\n content?: string;\n /** CSS styles for the loader */\n css: string;\n /** Optional HTML markup override */\n html?: string;\n}\n\n/**\n * Options for creating a loader element.\n */\nexport interface LoaderOptions {\n /** Container element or selector */\n container?: HTMLElement | string;\n /** Whether to use Shadow DOM (default: true) */\n useShadowDOM?: boolean;\n /** Additional CSS classes to add */\n className?: string;\n /** Custom size override */\n size?: number | string;\n /** Primary color override */\n color?: string;\n /** Secondary/accent color override */\n accentColor?: string;\n}\n\n/**\n * Loader preset categories.\n */\nexport type LoaderCategory =\n | 'spinner'\n | 'dots'\n | 'bars'\n | 'progress'\n | 'pulse'\n | 'bounce'\n | 'text'\n | 'skeleton'\n | 'custom';\n\n\n// ============================================================================\n// Loader Manager Class\n// ============================================================================\n\n/**\n * LoaderManager provides methods for creating, managing, and rendering\n * CSS-based loading animations with Shadow DOM encapsulation.\n *\n * @example\n * ```typescript\n * const manager = new LoaderManager();\n *\n * // Register a custom loader\n * manager.register({\n * id: 'my-spinner',\n * css: `.loader { width: 48px; height: 48px; ... }`\n * });\n *\n * // Create and show loader\n * const loader = manager.create('my-spinner', {\n * container: '#app',\n * color: '#FF3D00'\n * });\n *\n * // Later, remove it\n * manager.destroy(loader);\n * ```\n */\nexport class LoaderManager {\n\n // ========================================================================\n // Properties\n // ========================================================================\n\n /** Registry of loader configurations */\n private loaders: Map<string, LoaderConfig> = new Map();\n\n /** Active loader instances */\n private activeLoaders: Map<HTMLElement, { id: string; shadowRoot?: ShadowRoot }> = new Map();\n\n /** Default CSS variables for customization */\n private defaultVars = {\n '--loader-size': '48px',\n '--loader-color': '#FFF',\n '--loader-accent': '#FF3D00',\n '--loader-speed': '1s',\n };\n\n\n // ========================================================================\n // Constructor\n // ========================================================================\n\n /**\n * Creates a new LoaderManager instance.\n * @param preloadBuiltins - Whether to preload built-in loaders (default: true)\n */\n constructor(preloadBuiltins: boolean = true) {\n if (preloadBuiltins) {\n this.registerBuiltinLoaders();\n }\n }\n\n\n // ========================================================================\n // Public Methods\n // ========================================================================\n\n /**\n * Registers a loader configuration.\n * @param config - The loader configuration to register\n * @returns The LoaderManager instance for chaining\n */\n public register(config: LoaderConfig): this {\n this.loaders.set(config.id, config);\n return this;\n }\n\n /**\n * Registers multiple loader configurations.\n * @param configs - Array of loader configurations\n * @returns The LoaderManager instance for chaining\n */\n public registerAll(configs: LoaderConfig[]): this {\n configs.forEach(config => this.register(config));\n return this;\n }\n\n /**\n * Creates and mounts a loader element.\n * @param loaderId - ID of the registered loader to create\n * @param options - Creation options\n * @returns The created loader element\n */\n public create(loaderId: string, options: LoaderOptions = {}): HTMLElement {\n const config = this.loaders.get(loaderId);\n if (!config) {\n throw new Error(`Loader \"${loaderId}\" not found. Register it first.`);\n }\n\n const {\n container,\n useShadowDOM = true,\n className = '',\n size,\n color,\n accentColor,\n } = options;\n\n // Create wrapper element\n const wrapper = document.createElement('div');\n wrapper.className = `loader-wrapper ${className}`.trim();\n wrapper.setAttribute('data-loader-id', loaderId);\n\n // Apply CSS custom properties\n if (size) {\n wrapper.style.setProperty('--loader-size', typeof size === 'number' ? `${size}px` : size);\n }\n if (color) {\n wrapper.style.setProperty('--loader-color', color);\n }\n if (accentColor) {\n wrapper.style.setProperty('--loader-accent', accentColor);\n }\n\n // Create loader content\n if (useShadowDOM) {\n const shadowRoot = wrapper.attachShadow({ mode: 'open' });\n\n // Add styles\n const styleEl = document.createElement('style');\n styleEl.textContent = this.processCSS(config.css, options);\n shadowRoot.appendChild(styleEl);\n\n // Add loader element\n const loaderEl = document.createElement('span');\n loaderEl.className = 'loader';\n if (config.content) {\n loaderEl.innerHTML = config.content;\n }\n shadowRoot.appendChild(loaderEl);\n\n this.activeLoaders.set(wrapper, { id: loaderId, shadowRoot });\n } else {\n // Without Shadow DOM\n const styleEl = document.createElement('style');\n styleEl.textContent = this.scopeCSS(config.css, wrapper, loaderId);\n wrapper.appendChild(styleEl);\n\n const loaderEl = document.createElement('span');\n loaderEl.className = `loader loader-${loaderId}`;\n if (config.content) {\n loaderEl.innerHTML = config.content;\n }\n wrapper.appendChild(loaderEl);\n\n this.activeLoaders.set(wrapper, { id: loaderId });\n }\n\n // Mount to container if provided\n if (container) {\n const containerEl = typeof container === 'string'\n ? document.querySelector(container)\n : container;\n containerEl?.appendChild(wrapper);\n }\n\n return wrapper;\n }\n\n /**\n * Creates a full-screen overlay loader.\n * @param loaderId - ID of the registered loader\n * @param options - Creation options\n * @returns The created overlay element\n */\n public createOverlay(loaderId: string, options: Omit<LoaderOptions, 'container'> = {}): HTMLElement {\n const overlay = document.createElement('div');\n overlay.className = 'loader-overlay';\n overlay.style.cssText = `\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.7);\n z-index: 9999;\n `;\n\n const loader = this.create(loaderId, { ...options, container: overlay });\n document.body.appendChild(overlay);\n\n // Store reference for cleanup\n this.activeLoaders.set(overlay, { id: `overlay-${loaderId}` });\n\n return overlay;\n }\n\n /**\n * Shows a loader in an existing element, replacing its content.\n * @param loaderId - ID of the registered loader\n * @param target - Target element or selector\n * @param options - Creation options\n * @returns The created loader element\n */\n public showIn(loaderId: string, target: HTMLElement | string, options: LoaderOptions = {}): HTMLElement {\n const targetEl = typeof target === 'string'\n ? document.querySelector<HTMLElement>(target)\n : target;\n\n if (!targetEl) {\n throw new Error(`Target element not found: ${target}`);\n }\n\n // Store original content\n const originalContent = targetEl.innerHTML;\n targetEl.setAttribute('data-original-content', originalContent);\n targetEl.innerHTML = '';\n\n // Create loader\n const loader = this.create(loaderId, { ...options, container: targetEl });\n\n return loader;\n }\n\n /**\n * Hides a loader and restores original content.\n * @param target - Target element or selector\n */\n public hideIn(target: HTMLElement | string): void {\n const targetEl = typeof target === 'string'\n ? document.querySelector<HTMLElement>(target)\n : target;\n\n if (!targetEl) return;\n\n const originalContent = targetEl.getAttribute('data-original-content');\n if (originalContent !== null) {\n targetEl.innerHTML = originalContent;\n targetEl.removeAttribute('data-original-content');\n }\n }\n\n /**\n * Destroys a loader element.\n * @param loader - The loader element to destroy\n */\n public destroy(loader: HTMLElement): void {\n this.activeLoaders.delete(loader);\n loader.remove();\n }\n\n /**\n * Destroys all active loaders.\n */\n public destroyAll(): void {\n this.activeLoaders.forEach((_, loader) => this.destroy(loader));\n }\n\n /**\n * Gets a list of all registered loader IDs.\n * @returns Array of loader IDs\n */\n public getRegisteredLoaders(): string[] {\n return Array.from(this.loaders.keys());\n }\n\n /**\n * Checks if a loader is registered.\n * @param loaderId - Loader ID to check\n * @returns True if registered\n */\n public has(loaderId: string): boolean {\n return this.loaders.has(loaderId);\n }\n\n /**\n * Gets the configuration for a registered loader.\n * @param loaderId - Loader ID\n * @returns The loader configuration or undefined\n */\n public getConfig(loaderId: string): LoaderConfig | undefined {\n return this.loaders.get(loaderId);\n }\n\n\n // ========================================================================\n // Private Methods\n // ========================================================================\n\n /**\n * Processes CSS with variable replacements.\n */\n private processCSS(css: string, options: LoaderOptions): string {\n let processed = css;\n\n // Replace hardcoded colors with CSS variables\n processed = processed.replace(/#FFF\\b/gi, 'var(--loader-color, #FFF)');\n processed = processed.replace(/#FF3D00\\b/gi, 'var(--loader-accent, #FF3D00)');\n processed = processed.replace(/48px/g, 'var(--loader-size, 48px)');\n\n return processed;\n }\n\n /**\n * Scopes CSS to a specific element (for non-Shadow DOM usage).\n */\n private scopeCSS(css: string, wrapper: HTMLElement, loaderId: string): string {\n // Replace .loader with scoped selector\n return css.replace(/\\.loader/g, `.loader-${loaderId}`);\n }\n\n /**\n * Registers built-in loader presets.\n */\n private registerBuiltinLoaders(): void {\n // Spinner loaders\n this.register({\n id: 'spinner',\n css: `.loader {\n width: 48px;\n height: 48px;\n border: 5px solid #FFF;\n border-bottom-color: #FF3D00;\n border-radius: 50%;\n display: inline-block;\n box-sizing: border-box;\n animation: rotation 1s linear infinite;\n }\n @keyframes rotation {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n }`\n });\n\n this.register({\n id: 'spinner-dual',\n css: `.loader {\n width: 48px;\n height: 48px;\n border: 5px solid #FFF;\n border-bottom-color: transparent;\n border-radius: 50%;\n display: inline-block;\n box-sizing: border-box;\n animation: rotation 1s linear infinite;\n }\n @keyframes rotation {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n }`\n });\n\n // Dots loaders\n this.register({\n id: 'dots-bounce',\n css: `.loader, .loader:before, .loader:after {\n border-radius: 50%;\n width: 2.5em;\n height: 2.5em;\n animation-fill-mode: both;\n animation: bblFadInOut 1.8s infinite ease-in-out;\n }\n .loader {\n color: #FFF;\n font-size: 7px;\n position: relative;\n text-indent: -9999em;\n transform: translateZ(0);\n animation-delay: -0.16s;\n }\n .loader:before, .loader:after {\n content: '';\n position: absolute;\n top: 0;\n }\n .loader:before {\n left: -3.5em;\n animation-delay: -0.32s;\n }\n .loader:after {\n left: 3.5em;\n }\n @keyframes bblFadInOut {\n 0%, 80%, 100% { box-shadow: 0 2.5em 0 -1.3em }\n 40% { box-shadow: 0 2.5em 0 0 }\n }`\n });\n\n this.register({\n id: 'dots-flash',\n css: `.loader {\n width: 16px;\n height: 16px;\n border-radius: 50%;\n background-color: #fff;\n box-shadow: 32px 0 #fff, -32px 0 #fff;\n position: relative;\n animation: flash 0.5s ease-out infinite alternate;\n }\n @keyframes flash {\n 0% {\n background-color: #FFF2;\n box-shadow: 32px 0 #FFF2, -32px 0 #FFF;\n }\n 50% {\n background-color: #FFF;\n box-shadow: 32px 0 #FFF2, -32px 0 #FFF2;\n }\n 100% {\n background-color: #FFF2;\n box-shadow: 32px 0 #FFF, -32px 0 #FFF2;\n }\n }`\n });\n\n // Progress bar loaders\n this.register({\n id: 'progress-bar',\n css: `.loader {\n width: 100%;\n height: 4.8px;\n display: inline-block;\n position: relative;\n background: rgba(255, 255, 255, 0.15);\n overflow: hidden;\n }\n .loader::after {\n content: '';\n width: 96px;\n height: 4.8px;\n background: #FFF;\n position: absolute;\n top: 0;\n left: 0;\n box-sizing: border-box;\n animation: hitZak 1s linear infinite alternate;\n }\n @keyframes hitZak {\n 0% { left: 0; transform: translateX(-1%); }\n 100% { left: 100%; transform: translateX(-99%); }\n }`\n });\n\n this.register({\n id: 'progress-fill',\n css: `.loader {\n width: 100%;\n height: 4.8px;\n display: inline-block;\n position: relative;\n background: rgba(255, 255