/** * Draggable Avatar Component for UP Connector * Integrates with @lukso/transaction-view-core IconView */ import type { AddressData, AvatarOptions } from './types.js' const DEFAULT_AVATAR_OPTIONS: Required< Omit< AvatarOptions, | 'address' | 'resolved' | 'label' | 'chainId' | 'componentLoader' | 'onPositionChange' | 'onHide' | 'onShow' | 'onClick' | 'onAddressClick' | 'onTransactionStart' | 'onTransactionComplete' > > = { // IconView integration options size: 'medium', forceUnresolved: false, // Fallback options fallbackColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', fallbackText: '?', fallbackImage: '', // Avatar container options avatarSize: 'medium' as const, // Behavior options initialPosition: 'top-left', hideThreshold: 20, hideOffset: 30, // Animation options transitionDuration: '0.3s', transitionEasing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', // Container container: typeof document !== 'undefined' ? document.body : (null as any), } interface SnapPosition { x: number y: number side: 'left' | 'right' name: string } export class DraggableAvatar { private options: AvatarOptions & typeof DEFAULT_AVATAR_OPTIONS private overlay!: HTMLElement private element!: HTMLElement private iconViewElement?: HTMLElement private snapPreviews: Map = new Map() // Drag state private isDragging = false private hasDragged = false private startX = 0 private startY = 0 private initialX = 0 private initialY = 0 private dragThreshold = 15 // pixels - movement below this is still considered a click /** * Convert size name to pixel value * Based on Tailwind classes: x-small=24px, small=40px, medium=56px, large=80px, x-large=96px, 2x-large=120px */ private getPixelSize( size: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | '2x-large' ): number { switch (size) { case 'x-small': return 24 case 'small': return 40 case 'medium': return 56 case 'large': return 80 case 'x-large': return 96 case '2x-large': return 120 } } /** * Get identicon overhang for a given profile size * The identicon hangs off the bottom-right corner by half its size */ private getIdenticonOverhang( profileSize: | 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | '2x-large' ): number { switch (profileSize) { case 'x-small': return 6 // w-3 = 12px, overhang = 6px case 'small': return 8 // w-4 = 16px, overhang = 8px case 'medium': return 10 // w-5 = 20px, overhang = 10px case 'large': return 12 // w-6 = 24px, overhang = 12px case 'x-large': return 14 // w-7 = 28px, overhang = 14px case '2x-large': return 18 // w-9 = 36px, overhang = 18px } } // Position state private currentPosition?: SnapPosition private isHidden = false private iconViewAvailable = false // Animation state private isThrobbing = false constructor(options: AvatarOptions = {}) { this.options = { ...DEFAULT_AVATAR_OPTIONS, ...options } // Validate options if ( !this.options.address && !this.options.fallbackText && !this.options.fallbackImage ) { console.warn( 'DraggableAvatar: No address, fallbackText, or fallbackImage provided. Avatar may be empty.' ) } this.init() } private async init(): Promise { this.createStyles() await this.checkIconViewAvailability() this.createElement() this.attachEventListeners() this.setInitialPosition() } /** * Safely check for and load IconView component */ private async checkIconViewAvailability(): Promise { // First check if already registered if ( typeof customElements !== 'undefined' && customElements.get('icon-view') ) { this.iconViewAvailable = true return true } // Try to load if not available and we have an address if (this.options.address && !this.iconViewAvailable) { try { // Try custom loader first if (this.options.componentLoader) { await this.options.componentLoader() } // Try global loader else if ( typeof window !== 'undefined' && (window as any).loadTransactionViewComponents ) { await (window as any).loadTransactionViewComponents() } // Try direct import as fallback else { try { await import('@lukso/transaction-view-core') } catch (importError) { console.warn( 'Failed to import @lukso/transaction-view-core:', importError ) } } this.iconViewAvailable = typeof customElements !== 'undefined' && customElements.get('icon-view') !== undefined } catch (error) { console.warn('Failed to load IconView component:', error) this.iconViewAvailable = false } } return this.iconViewAvailable } private createStyles(): void { if (typeof document === 'undefined') return // Calculate sizes before template literal const avatarPixelSize = this.getPixelSize(this.options.avatarSize) const _overhang = this.getIdenticonOverhang(this.options.avatarSize) // Debug: Log our calculations // Remove existing styles to allow updates const existingStyles = document.querySelector('#up-connector-avatar-styles') if (existingStyles) { existingStyles.remove() } const style = document.createElement('style') style.id = 'up-connector-avatar-styles' style.textContent = ` .up-avatar-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 9999; overflow: hidden; } .up-avatar { position: absolute; width: ${avatarPixelSize}px; height: ${avatarPixelSize}px; border-radius: 50%; cursor: move; z-index: 1000; overflow: visible; display: flex; align-items: center; justify-content: center; user-select: none; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.1); transition: all ${this.options.transitionDuration} ${this.options.transitionEasing}; touch-action: none; overflow: visible; background: ${this.options.fallbackColor}; border: 3px solid rgba(255, 255, 255, 0.5) !important; outline: none !important; backdrop-filter: blur(10px); pointer-events: auto; } .up-avatar * { user-select: none !important; pointer-events: none !important; } .up-avatar img { draggable: false !important; } .up-avatar:hover { transform: scale(1.05); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 4px 12px rgba(0, 0, 0, 0.15); } .up-avatar:focus { outline: none !important; border: 3px solid rgba(255, 255, 255, 0.6) !important; } .up-avatar.dragging { transform: scale(1.1); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3), 0 6px 16px rgba(0, 0, 0, 0.2); cursor: grabbing; } .up-avatar.hidden-left { left: -${this.options.hideOffset}px !important; opacity: 0.8; } .up-avatar.hidden-right { right: -${this.options.hideOffset}px !important; left: auto !important; opacity: 0.8; } .up-avatar.hidden-left:hover, .up-avatar.hidden-right:hover { transform: translateX(0) scale(1.05); opacity: 1; } .up-avatar.hidden-left:hover { left: -12px !important; } .up-avatar.hidden-right:hover { right: -12px !important; } /* IconView positioned to allow identicon to extend beyond border */ .up-avatar icon-view { position: absolute !important; top: 0 !important; left: 0 !important; width: ${avatarPixelSize}px !important; height: ${avatarPixelSize}px !important; display: block !important; z-index: 1 !important; } /* Additional auto-sizing for IconView components */ .up-avatar icon-view * { max-width: ${avatarPixelSize}px !important; max-height: ${avatarPixelSize}px !important; } /* Let IconView handle its own internal sizing */ .up-avatar icon-view img, .up-avatar icon-view .icon, .up-avatar icon-view .profile-image { max-width: 100% !important; max-height: 100% !important; width: auto !important; height: auto !important; object-fit: cover; draggable: false !important; user-select: none !important; pointer-events: none !important; } /* Fallback content */ .up-avatar .fallback-content { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: ${Math.floor(avatarPixelSize * 0.4)}px; font-family: system-ui, -apple-system, sans-serif; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } .up-avatar .fallback-content img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; draggable: false !important; user-select: none !important; pointer-events: none !important; } /* Snap preview */ .up-avatar-preview { position: absolute; width: ${avatarPixelSize}px; height: ${avatarPixelSize}px; border-radius: 50%; z-index: 999; pointer-events: none; background: rgba(102, 126, 234, 0.1); backdrop-filter: blur(5px); box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3), 0 0 0 4px rgba(102, 126, 234, 0.3), 0 2px 8px rgba(0, 0, 0, 0.2); opacity: 0.5; transition: opacity 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; } .up-avatar-preview.active { background: rgba(102, 126, 234, 0.2); box-shadow: 0 0 0 2px white, 0 0 0 4px #667eea, 0 4px 12px rgba(0, 0, 0, 0.3); opacity: 1; } /* Hide IconView labels in avatar mode */ .up-avatar icon-view .label { display: none; } /* Connection indicator */ .up-avatar::after { content: ''; position: absolute; bottom: 2px; right: 2px; width: 16px; height: 16px; border-radius: 50%; background: #4ade80; border: 2px solid white; opacity: 0; transform: scale(0); transition: all 0.2s ease; } .up-avatar.connected::after { opacity: 1; transform: scale(1); } /* Throb animation for transactions */ .up-avatar.throbbing { animation: avatar-throb 2s ease-in-out infinite; } @keyframes avatar-throb { 0%, 100% { transform: scale(1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border-color: rgba(255, 255, 255, 0.1); } 50% { transform: scale(1.1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 4px rgba(102, 126, 234, 0.4), 0 0 0 8px rgba(102, 126, 234, 0.2), 0 0 20px rgba(102, 126, 234, 0.3); border-color: rgba(102, 126, 234, 0.8); } } .up-avatar.throbbing::before { content: ''; position: absolute; top: -4px; left: -4px; right: -4px; bottom: -4px; border: 2px solid transparent; border-radius: 50%; background: conic-gradient(from 0deg, transparent, #667eea, #764ba2, transparent); background-size: 200% 200%; animation: avatar-rotate 1.5s linear infinite; z-index: -1; } @keyframes avatar-rotate { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } ` document.head.appendChild(style) } private createElement(): void { if (typeof document === 'undefined') return // Create fixed overlay container this.overlay = document.createElement('div') this.overlay.className = 'up-avatar-overlay' // Create avatar element this.element = document.createElement('div') this.element.className = 'up-avatar' this.element.setAttribute('role', 'button') this.element.setAttribute('tabindex', '0') this.element.setAttribute('aria-label', 'Universal Profile avatar') this.createContent() // Append avatar to overlay, then overlay to container this.overlay.appendChild(this.element) this.options.container.appendChild(this.overlay) } private createContent(): void { if (!this.element) return // Clear existing content this.element.innerHTML = '' if (this.options.address && this.iconViewAvailable) { // Use IconView component this.iconViewElement = document.createElement('icon-view') this.iconViewElement.setAttribute('address', this.options.address) if (this.options.chainId) { this.iconViewElement.setAttribute( 'chain-id', this.options.chainId.toString() ) } if (this.options.resolved) { ;(this.iconViewElement as any).resolved = this.options.resolved } // Set size - avatar size now matches IconView sizes exactly this.iconViewElement.setAttribute('size', this.options.avatarSize) // Let CSS handle the positioning and sizing if (this.options.label) { this.iconViewElement.setAttribute('label', this.options.label) } if (this.options.forceUnresolved) { this.iconViewElement.setAttribute('force-unresolved', '') } // Listen for address-click events this.iconViewElement.addEventListener('address-click', (event) => { event.stopPropagation() if (this.options.onAddressClick) { this.options.onAddressClick(event as CustomEvent) } }) this.element.appendChild(this.iconViewElement) } else { // Use fallback content this.createFallbackContent() } } private createFallbackContent(): void { if (!this.element) return const fallbackDiv = document.createElement('div') fallbackDiv.className = 'fallback-content' if (this.options.fallbackImage) { const img = document.createElement('img') img.src = this.options.fallbackImage img.alt = 'Avatar' img.onerror = () => { // Fallback to text if image fails to load img.remove() fallbackDiv.textContent = this.options.fallbackText } fallbackDiv.appendChild(img) } else { fallbackDiv.textContent = this.options.fallbackText } this.element.appendChild(fallbackDiv) } private attachEventListeners(): void { if (!this.element) return // Mouse events this.element.addEventListener('mousedown', this.handleMouseDown.bind(this)) document.addEventListener('mousemove', this.handleMouseMove.bind(this)) document.addEventListener('mouseup', this.handleMouseUp.bind(this)) // Touch events this.element.addEventListener( 'touchstart', this.handleTouchStart.bind(this), { passive: false } ) document.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false, }) document.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false, }) // Click events this.element.addEventListener('click', this.handleClick.bind(this)) // Window resize window.addEventListener('resize', this.handleResize.bind(this)) } private getSnapPositions(): SnapPosition[] { const margin = 20 const size = this.getPixelSize(this.options.avatarSize) const positions = [ { x: margin, y: margin, side: 'left' as const, name: 'top-left' }, { x: window.innerWidth - size - margin, y: margin, side: 'right' as const, name: 'top-right', }, { x: margin, y: window.innerHeight - size - margin, side: 'left' as const, name: 'bottom-left', }, { x: window.innerWidth - size - margin, y: window.innerHeight - size - margin, side: 'right' as const, name: 'bottom-right', }, ] return positions } private findClosestSnapPosition(x: number, y: number): SnapPosition { const positions = this.getSnapPositions() let closest = positions[0] let minDistance = Infinity positions.forEach((pos) => { const distance = Math.sqrt((x - pos.x) ** 2 + (y - pos.y) ** 2) if (distance < minDistance) { minDistance = distance closest = pos } }) return closest } private setInitialPosition(): void { const positions = this.getSnapPositions() const initialPos = positions.find((p) => p.name === this.options.initialPosition) || positions[0] this.snapToPosition(initialPos) } private snapToPosition(position: SnapPosition, shouldHide = false): void { if (!this.element) return this.element.className = 'up-avatar' this.currentPosition = position this.isHidden = shouldHide if (shouldHide) { if (position.side === 'left') { this.element.classList.add('hidden-left') this.element.style.left = `-${this.options.hideOffset}px` this.element.style.right = 'auto' this.element.style.top = `${position.y}px` this.options.onHide?.() } else if (position.side === 'right') { this.element.classList.add('hidden-right') this.element.style.right = `-${this.options.hideOffset}px` this.element.style.left = 'auto' this.element.style.top = `${position.y}px` this.options.onHide?.() } } else { this.element.style.left = `${position.x}px` this.element.style.right = 'auto' this.element.style.top = `${position.y}px` if (this.isHidden) { this.options.onShow?.() } } this.options.onPositionChange?.(position.name) } private showAllSnapPreviews(): void { if (typeof document === 'undefined') return const positions = this.getSnapPositions() // Create preview for each position if it doesn't exist positions.forEach((pos) => { if (!this.snapPreviews.has(pos.name)) { const preview = document.createElement('div') preview.className = 'up-avatar-preview' preview.style.left = `${pos.x}px` preview.style.top = `${pos.y}px` this.overlay.appendChild(preview) this.snapPreviews.set(pos.name, preview) } }) } private updateActiveSnapPreview( activePosition: SnapPosition, shouldHide = false ): void { if (typeof document === 'undefined') return // Determine the active position key const activeKey = activePosition.name // Update all previews this.snapPreviews.forEach((preview, key) => { if (key === activeKey) { // Highlight the active one preview.classList.add('active') // Update position for hidden state if (shouldHide) { if (activePosition.side === 'left') { preview.style.left = `-${this.options.hideOffset}px` preview.style.right = 'auto' preview.style.top = `${activePosition.y}px` } else if (activePosition.side === 'right') { preview.style.right = `-${this.options.hideOffset}px` preview.style.left = 'auto' preview.style.top = `${activePosition.y}px` } } else { preview.style.left = `${activePosition.x}px` preview.style.right = 'auto' preview.style.top = `${activePosition.y}px` } } else { // Dim the others preview.classList.remove('active') // Reset to normal position (not hidden) const pos = this.getSnapPositions().find((p) => p.name === key) if (pos) { preview.style.left = `${pos.x}px` preview.style.right = 'auto' preview.style.top = `${pos.y}px` } } }) } private hideAllSnapPreviews(): void { this.snapPreviews.forEach((preview) => { preview.remove() }) this.snapPreviews.clear() } // Event handlers private handleMouseDown(e: MouseEvent): void { // Only handle left mouse button (button 0) if (e.button !== 0) { return } this.isDragging = true this.hasDragged = false // Reset for new interaction this.startX = e.clientX this.startY = e.clientY const rect = this.element.getBoundingClientRect() this.initialX = rect.left this.initialY = rect.top this.element.classList.add('dragging') this.element.style.transition = 'none' // Show all snap previews when drag starts this.showAllSnapPreviews() } private handleMouseMove(e: MouseEvent): void { if (!this.isDragging) return e.preventDefault() const currentX = this.initialX + (e.clientX - this.startX) const currentY = this.initialY + (e.clientY - this.startY) // Check if movement exceeds threshold const deltaX = Math.abs(e.clientX - this.startX) const deltaY = Math.abs(e.clientY - this.startY) const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY) if (totalMovement > this.dragThreshold) { this.hasDragged = true // Only mark as dragged if we exceed threshold } // Always update position while dragging (for visual feedback) this.element.className = 'up-avatar dragging' this.element.style.left = `${currentX}px` this.element.style.right = 'auto' this.element.style.top = `${currentY}px` const closestPosition = this.findClosestSnapPosition(currentX, currentY) const shouldHideLeft = currentX < -this.options.hideThreshold const shouldHideRight = currentX > window.innerWidth - this.getPixelSize(this.options.avatarSize) + this.options.hideThreshold // Update active preview this.updateActiveSnapPreview( closestPosition, shouldHideLeft || shouldHideRight ) } private handleMouseUp(): void { if (this.isDragging) { this.isDragging = false this.element.classList.remove('dragging') this.element.style.transition = `all ${this.options.transitionDuration} ${this.options.transitionEasing}` // Hide all snap previews this.hideAllSnapPreviews() const rect = this.element.getBoundingClientRect() const currentX = rect.left const currentY = rect.top const shouldHideLeft = currentX < -this.options.hideThreshold const shouldHideRight = currentX > window.innerWidth - this.getPixelSize(this.options.avatarSize) + this.options.hideThreshold const closestPosition = this.findClosestSnapPosition(currentX, currentY) this.snapToPosition(closestPosition, shouldHideLeft || shouldHideRight) // Reset hasDragged after click event has had a chance to fire setTimeout(() => { this.hasDragged = false }, 0) } } private handleTouchStart(e: TouchEvent): void { e.preventDefault() e.stopPropagation() this.isDragging = true this.hasDragged = false // Reset for new interaction const touch = e.touches[0] this.startX = touch.clientX this.startY = touch.clientY const rect = this.element.getBoundingClientRect() this.initialX = rect.left this.initialY = rect.top this.element.classList.add('dragging') this.element.style.transition = 'none' // Show all snap previews when drag starts this.showAllSnapPreviews() } private handleTouchMove(e: TouchEvent): void { if (!this.isDragging) return e.preventDefault() e.stopPropagation() const touch = e.touches[0] const currentX = this.initialX + (touch.clientX - this.startX) const currentY = this.initialY + (touch.clientY - this.startY) // Check if movement exceeds threshold const deltaX = Math.abs(touch.clientX - this.startX) const deltaY = Math.abs(touch.clientY - this.startY) const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY) if (totalMovement > this.dragThreshold) { this.hasDragged = true // Only mark as dragged if we exceed threshold } // Always update position while dragging (for visual feedback) this.element.className = 'up-avatar dragging' this.element.style.left = `${currentX}px` this.element.style.right = 'auto' this.element.style.top = `${currentY}px` const closestPosition = this.findClosestSnapPosition(currentX, currentY) const shouldHideLeft = currentX < -this.options.hideThreshold const shouldHideRight = currentX > window.innerWidth - this.getPixelSize(this.options.avatarSize) + this.options.hideThreshold // Update active preview this.updateActiveSnapPreview( closestPosition, shouldHideLeft || shouldHideRight ) } private handleTouchEnd(e: TouchEvent): void { if (this.isDragging) { e.preventDefault() e.stopPropagation() this.isDragging = false this.element.classList.remove('dragging') this.element.style.transition = `all ${this.options.transitionDuration} ${this.options.transitionEasing}` // Hide all snap previews this.hideAllSnapPreviews() const rect = this.element.getBoundingClientRect() const currentX = rect.left const currentY = rect.top const shouldHideLeft = currentX < -this.options.hideThreshold const shouldHideRight = currentX > window.innerWidth - this.getPixelSize(this.options.avatarSize) + this.options.hideThreshold const closestPosition = this.findClosestSnapPosition(currentX, currentY) this.snapToPosition(closestPosition, shouldHideLeft || shouldHideRight) // Reset hasDragged after click event has had a chance to fire setTimeout(() => { this.hasDragged = false }, 0) } } private handleClick(e: MouseEvent): void { // Only handle left mouse button (button 0) if (e.button !== 0) { return } // Only trigger click if we haven't dragged during this interaction if (!this.hasDragged) { if (this.options.onClick) { // Get resolved data from IconView if available const resolvedData = (this.iconViewElement as any)?.resolved || null this.options.onClick(e, { address: this.options.address, resolved: resolvedData, }) } } } private handleResize(): void { if (this.currentPosition) { const positions = this.getSnapPositions() const newPosition = positions.find((p) => p.name === this.currentPosition?.name) || positions[0] this.snapToPosition(newPosition, this.isHidden) } } // Public API methods public setPosition(positionName: string): void { const positions = this.getSnapPositions() const position = positions.find((p) => p.name === positionName) if (position) { this.snapToPosition(position) } } public hide(): void { if (this.currentPosition) { this.snapToPosition(this.currentPosition, true) } } public show(): void { if (this.currentPosition) { this.snapToPosition(this.currentPosition, false) } } public setConnected(connected: boolean): void { if (this.element) { if (connected) { this.element.classList.add('connected') } else { this.element.classList.remove('connected') } } } public updateAddress( address: string, resolved?: AddressData, chainId?: number ): void { this.options.address = address this.options.resolved = resolved this.options.chainId = chainId if (this.iconViewElement) { this.iconViewElement.setAttribute('address', address) if (chainId) { this.iconViewElement.setAttribute('chain-id', chainId.toString()) } if (resolved) { ;(this.iconViewElement as any).resolved = resolved } } else { // Recreate content if switching from fallback to IconView this.createContent() } } public updateResolved(resolved: AddressData): void { this.options.resolved = resolved if (this.iconViewElement) { ;(this.iconViewElement as any).resolved = resolved } } public updateSize( newSize: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | '2x-large' ): void { if (this.options.avatarSize === newSize) return this.options.avatarSize = newSize // Recreate styles with new size this.createStyles() // Update element size using pixel conversion const pixelSize = this.getPixelSize(newSize) if (this.element) { this.element.style.width = `${pixelSize}px` this.element.style.height = `${pixelSize}px` } // Force IconView to resize if available if (this.iconViewElement) { // Size attribute now matches avatarSize directly this.iconViewElement.setAttribute('size', newSize) // Force IconView to re-render with new size const iconView = this.iconViewElement as any if (iconView.requestUpdate) { iconView.requestUpdate() } // Remove any inline styles to let CSS handle sizing this.iconViewElement.style.width = '' this.iconViewElement.style.height = '' // Update any nested lukso-profile elements const profiles = this.iconViewElement.querySelectorAll('lukso-profile') profiles.forEach((profile) => { const element = profile as HTMLElement element.style.width = '' element.style.height = '' // Update the size attribute on lukso-profile element.setAttribute('size', newSize) }) } // Recreate content to update IconView sizing this.createContent() } public getElement(): HTMLElement | null { return this.element || null } public getPosition(): { x: number; y: number; name: string } | null { if (!this.element || !this.currentPosition) return null const rect = this.element.getBoundingClientRect() return { x: rect.left, y: rect.top, name: this.currentPosition.name, } } public startThrob(): void { if (!this.element || this.isThrobbing) return this.isThrobbing = true this.element.classList.add('throbbing') } public stopThrob(): void { if (!this.element || !this.isThrobbing) return this.isThrobbing = false this.element.classList.remove('throbbing') } public getAvatarRect(): DOMRect | null { return this.element ? this.element.getBoundingClientRect() : null } public destroy(): void { this.stopThrob() if (this.overlay) { this.overlay.remove() } // Remove styles if this was the last avatar overlay if ( typeof document !== 'undefined' && !document.querySelector('.up-avatar-overlay') ) { const styles = document.querySelector('#up-connector-avatar-styles') styles?.remove() } } } /** * Factory function for creating draggable avatars */ export async function createAvatar( options: AvatarOptions = {} ): Promise { const avatar = new DraggableAvatar(options) // The init() call is already handled in the constructor return avatar } // Export the class as default export default DraggableAvatar