Skip to content

useSpotlightOverlay Composable ​

javascript
import { ref, reactive, computed } from 'vue'

// Initial state
const createInitialState = () => ({
	positionX: -100000,
	positionY: -100000,
	previousPositionX: -100000,
	previousPositionY: -100000,
	arrowPosition: 'top',
	previousArrowPosition: 'top',
	showFloatingInstructionBar: false,
	barConfig: {},
	highlightX: -1,
	highlightY: -1,
	highlightRadius: 0,
	clipPathX: 0,
	clipPathY: 0,
	clipPathHeight: 10000,
	clipPathWidth: 10000,
	highlightedElements: [],
	showRenderReadyInstructionBar: false,
	highlightAreaBorderStyles: {
		borderRadius: '0',
		borderWidth: '0',
	},
	isAnimating: false,
	animationDuration: 300 // milliseconds
})

// Reactive state
const state = reactive(createInitialState())

// Utility functions
const calculateHighlightPosition = (rect) => {
	const highlightX = rect.left + rect.width / 2
	const highlightY = rect.top + rect.height / 2
	const highlightRadius = Math.max(rect.width / 2, rect.height / 2)
	return { highlightX, highlightY, highlightRadius }
}

const calculateClipPathPosition = (rect) => {
	const clipPathX = rect.left
	const clipPathY = rect.top
	const clipPathHeight = rect.height
	const clipPathWidth = rect.width
	return { clipPathX, clipPathY, clipPathHeight, clipPathWidth }
}

const calculateArrowPosition = (rect, arrowPosition) => {
	let positionX = rect.left + rect.width / 2
	let positionY = rect.top + rect.height / 2

	if (arrowPosition.startsWith('top')) {
		positionY = rect.bottom
	} else if (arrowPosition.startsWith('bottom')) {
		positionY = rect.top
	} else if (arrowPosition === 'right') {
		positionX = rect.left
	} else if (arrowPosition === 'left') {
		positionX = rect.right
	} else if (arrowPosition === 'leftTop') {
		positionY = rect.top + 16
		positionX = rect.right
	} else if (arrowPosition === 'leftBottom') {
		positionY = rect.bottom - 16
		positionX = rect.right
	} else if (arrowPosition === 'RightTop') {
		positionY = rect.top + 16
		positionX = rect.left
	} else if (arrowPosition === 'RightBottom') {
		positionY = rect.bottom - 16
		positionX = rect.left
	}

	return { positionX, positionY }
}

// Actions
const setPosition = (data) => {
	// Store previous position before updating
	state.previousPositionX = state.positionX
	state.previousPositionY = state.positionY
	
	state.positionX = data.positionX
	state.positionY = data.positionY
}

const setPositionWithAnimation = (data) => {
	// Store previous position before updating
	state.previousPositionX = state.positionX
	state.previousPositionY = state.positionY
	
	// Set animation flag
	state.isAnimating = true
	
	// Update position
	state.positionX = data.positionX
	state.positionY = data.positionY
	
	// Clear animation flag after duration
	setTimeout(() => {
		state.isAnimating = false
	}, state.animationDuration)
}

const setHighlight = (data) => {
	state.highlightX = data.highlightX
	state.highlightY = data.highlightY
	state.highlightRadius = data.highlightRadius
}

const setClipPath = (data) => {
	state.clipPathX = data.clipPathX
	state.clipPathY = data.clipPathY
	state.clipPathHeight = data.clipPathHeight
	state.clipPathWidth = data.clipPathWidth
}

const addHighlightedElements = ({ elementId, className }) => {
	// Add element to highlighted elements array
	state.highlightedElements.push({ elementId, className })
	// Apply the className to the element by elementId
	const element = document.getElementById(elementId)
	if (element) {
		element.classList.add(className)
	}
}

const resetHighlightedElements = () => {
	// Reset the array and remove the class from all highlighted elements
	state.highlightedElements.forEach(({ elementId, className }) => {
		const element = document.getElementById(elementId)
		if (element) {
			element.classList.remove(className)
		}
	})
	// Clear the highlighted elements array
	state.highlightedElements = []
}

const setInstructionBarConfig = (data) => {
	// Store previous arrow position
	state.previousArrowPosition = state.arrowPosition
	
	if (data.position) {
		// Use animation if the bar is already visible and position is changing
		if (state.showFloatingInstructionBar && 
			(state.positionX !== data.position.positionX || state.positionY !== data.position.positionY)) {
			setPositionWithAnimation(data.position)
		} else {
			setPosition(data.position)
		}
	}

	if (data.clipPath) {
		setClipPath(data.clipPath)
	} else {
		setClipPath({
			clipPathX: 0,
			clipPathY: 0,
			clipPathHeight: 0,
			clipPathWidth: 0,
		})
	}

	if (data.highlight) {
		setHighlight(data.highlight)
	} else {
		setHighlight({ highlightX: -1, highlightY: -1, highlightRadius: 0 })
	}

	state.arrowPosition = data.arrowPosition
	state.showFloatingInstructionBar = data.isVisible
	state.barConfig = { ...data.config, elementId: data.elementId }
}

const resetInstructionBarConfig = () => {
	setPosition({
		positionX: -100000,
		positionY: -100000,
		previousPositionX: -100000,
		previousPositionY: -100000,
	})

	state.showFloatingInstructionBar = false
	state.barConfig = {}
	state.clipPathX = 0,
	state.clipPathY = 0,
	state.clipPathHeight = 10000,
	state.clipPathWidth = 10000,
	resetHighlightedElements()
}

const setHighlightAreaBorderStyles = (data) => {
	state.highlightAreaBorderStyles = {
		...createInitialState().highlightAreaBorderStyles,
		...data
	}
}

const setInstructionBarConfigByElementId = (data) => {
	if (!data || !data.elementId) return

	let payload = { ...data }

	const element = document.getElementById(data.elementId)
	if (!element) return
	// Extract border styles from the element and set them in the state
	const computedStyles = window.getComputedStyle(element)
	const elementBorderRadius = computedStyles.getPropertyValue('border-radius')
	let borderRadius = '0'
	if(elementBorderRadius.includes('%')) {
		borderRadius = `calc(${elementBorderRadius})`
	} else {
		borderRadius = `calc(${elementBorderRadius} + 9999px)`
	}
	setHighlightAreaBorderStyles({
		// borderRadius: `calc(${computedStyles.getPropertyValue('border-radius')}px + 10000px)`,
		borderRadius,
		borderWidth: '9999px',
	})

	const rect = element.getBoundingClientRect()

	const { highlightX, highlightY, highlightRadius } = calculateHighlightPosition(rect)
	const { positionX, positionY } = calculateArrowPosition(rect, data.arrowPosition)
	const { clipPathX, clipPathY, clipPathHeight, clipPathWidth } = calculateClipPathPosition(rect)

	payload.position = { positionX, positionY }
	payload.highlight = { highlightX, highlightY, highlightRadius }
	payload.clipPath = { clipPathX, clipPathY, clipPathHeight, clipPathWidth }
	highlightElement({ elementId: data.elementId, className: (data.elementHighlightClassName || 'tour-highlight') })

	if (payload.config && payload.config.button) {
		let buttonCallback = () => {}

		if (payload.config.button.callback) {
			buttonCallback = payload.config.button.callback
		}

		payload.config.button.callback = () => {
			element.click()
			buttonCallback()
			resetInstructionBarConfig()
		}
	}

	if (data.elementToHighLightId) {
		resetHighlightedElements()

		const elementToHighlight = document.getElementById(data.elementToHighLightId)
		if (elementToHighlight) {
			const highlightRect = elementToHighlight.getBoundingClientRect()

			const { highlightX, highlightY, highlightRadius } = calculateHighlightPosition(highlightRect)
			const { positionX, positionY } = calculateArrowPosition(highlightRect, data.arrowPosition)

			payload.position = { positionX, positionY }
			payload.highlight = { highlightX, highlightY, highlightRadius }
			highlightElement({ elementId: data.elementToHighLightId, className: (data.elementHighlightClassName || 'tour-highlight') })
		}
	}

	setInstructionBarConfig(payload)
}

const highlightElement = ({ elementId, className }) => {
	addHighlightedElements({ elementId, className })
}

// Computed properties (getters)
const getters = {
	positionX: computed(() => state.positionX),
	positionY: computed(() => state.positionY),
	previousPositionX: computed(() => state.previousPositionX),
	previousPositionY: computed(() => state.previousPositionY),
	arrowPosition: computed(() => state.arrowPosition),
	previousArrowPosition: computed(() => state.previousArrowPosition),
	showFloatingInstructionBar: computed(() => state.showFloatingInstructionBar),
	barConfig: computed(() => state.barConfig),
	highlightX: computed(() => state.highlightX),
	highlightY: computed(() => state.highlightY),
	highlightRadius: computed(() => state.highlightRadius),
	clipPathX: computed(() => state.clipPathX),
	clipPathY: computed(() => state.clipPathY),
	clipPathHeight: computed(() => state.clipPathHeight),
	clipPathWidth: computed(() => state.clipPathWidth),
	highlightedElements: computed(() => state.highlightedElements),
	showRenderReadyInstructionBar: computed(() => state.showRenderReadyInstructionBar),
	highlightAreaBorderStyles: computed(() => state.highlightAreaBorderStyles),
	isAnimating: computed(() => state.isAnimating),
	animationDuration: computed(() => state.animationDuration)
}

// Main composable function
export default function useSpotlightOverlay() {
	return {
		// State
		state,
		
		// Getters
		...getters,
		
		// Actions
		setInstructionBarConfig,
		resetInstructionBarConfig,
		setInstructionBarConfigByElementId,
		highlightElement,
		
		// Utility functions
		setPosition,
		setPositionWithAnimation,
		setHighlight,
		setClipPath,
		addHighlightedElements,
		resetHighlightedElements
	}
}