From ea727aad2ee5d9f08a94c8755597a27b842fad95 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 3 Sep 2025 21:44:23 +0800 Subject: [PATCH] feat(onboarding): enhance target highlighting with mask and pulsing effect --- static/css/onboarding.css | 33 +++++++- static/js/managers/OnboardingManager.js | 103 ++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/static/css/onboarding.css b/static/css/onboarding.css index 49bc01fc..88e1ece0 100644 --- a/static/css/onboarding.css +++ b/static/css/onboarding.css @@ -8,6 +8,9 @@ background: rgba(0, 0, 0, 0.8); z-index: var(--z-overlay); display: none; + /* Use mask to create cutout for highlighted element */ + mask-composite: subtract; + -webkit-mask-composite: subtract; } .onboarding-overlay.active { @@ -19,10 +22,26 @@ background: transparent; border: 3px solid var(--lora-accent); border-radius: var(--border-radius-base); - box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.8); z-index: calc(var(--z-overlay) + 1); pointer-events: none; transition: all 0.3s ease; + /* Add glow effect */ + box-shadow: + 0 0 0 2px rgba(24, 144, 255, 0.3), + 0 0 20px rgba(24, 144, 255, 0.2), + inset 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +/* Target element highlighting */ +.onboarding-target-highlight { + position: relative; + z-index: calc(var(--z-overlay) + 2) !important; + pointer-events: auto !important; +} + +/* Ensure highlighted elements are interactive */ +.onboarding-target-highlight * { + pointer-events: auto !important; } .onboarding-popup { @@ -33,7 +52,7 @@ padding: var(--space-3); min-width: 320px; max-width: 400px; - z-index: calc(var(--z-overlay) + 2); + z-index: calc(var(--z-overlay) + 3); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); backdrop-filter: blur(10px); } @@ -201,10 +220,16 @@ @keyframes onboarding-pulse { 0%, 100% { - box-shadow: 0 0 0 0 var(--lora-accent); + box-shadow: + 0 0 0 2px rgba(24, 144, 255, 0.4), + 0 0 20px rgba(24, 144, 255, 0.3), + inset 0 0 0 1px rgba(255, 255, 255, 0.1); } 50% { - box-shadow: 0 0 0 8px transparent; + box-shadow: + 0 0 0 4px rgba(24, 144, 255, 0.6), + 0 0 30px rgba(24, 144, 255, 0.4), + inset 0 0 0 1px rgba(255, 255, 255, 0.2); } } diff --git a/static/js/managers/OnboardingManager.js b/static/js/managers/OnboardingManager.js index 8e44fab9..1c08e700 100644 --- a/static/js/managers/OnboardingManager.js +++ b/static/js/managers/OnboardingManager.js @@ -9,6 +9,7 @@ export class OnboardingManager { this.overlay = null; this.spotlight = null; this.popup = null; + this.currentTarget = null; // Track current highlighted element // Available languages with SVG flags (using flag-icons) this.languages = [ @@ -252,16 +253,15 @@ export class OnboardingManager { return; } - // Position spotlight + // Clear previous target highlighting + this.clearTargetHighlight(); + + // Position spotlight and create mask if (target && step.target !== 'body') { - const rect = target.getBoundingClientRect(); - this.spotlight.style.left = `${rect.left - 5}px`; - this.spotlight.style.top = `${rect.top - 5}px`; - this.spotlight.style.width = `${rect.width + 10}px`; - this.spotlight.style.height = `${rect.height + 10}px`; - this.spotlight.style.display = 'block'; + this.highlightTarget(target); } else { this.spotlight.style.display = 'none'; + this.clearOverlayMask(); } // Update popup content @@ -344,6 +344,92 @@ export class OnboardingManager { popup.style.transform = 'none'; } + // Highlight target element with mask approach + highlightTarget(target) { + const rect = target.getBoundingClientRect(); + const padding = 4; // Padding around the target element + const offset = 3; // Shift spotlight up and left by 3px + + // Position spotlight + this.spotlight.style.left = `${rect.left - padding - offset}px`; + this.spotlight.style.top = `${rect.top - padding - offset}px`; + this.spotlight.style.width = `${rect.width + padding * 2}px`; + this.spotlight.style.height = `${rect.height + padding * 2}px`; + this.spotlight.style.display = 'block'; + + // Create mask for overlay to cut out the highlighted area + this.createOverlayMask(rect, padding, offset); + + // Add highlight class to target and ensure it's interactive + target.classList.add('onboarding-target-highlight'); + this.currentTarget = target; + + // Add pulsing animation + this.spotlight.classList.add('onboarding-highlight'); + } + + // Create mask for overlay to cut out highlighted area + createOverlayMask(rect, padding, offset = 0) { + const x = rect.left - padding - offset; + const y = rect.top - padding - offset; + const width = rect.width + padding * 2; + const height = rect.height + padding * 2; + + // Create SVG mask + const maskId = 'onboarding-mask'; + let maskSvg = document.getElementById(maskId); + + if (!maskSvg) { + maskSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + maskSvg.id = maskId; + maskSvg.style.position = 'absolute'; + maskSvg.style.top = '0'; + maskSvg.style.left = '0'; + maskSvg.style.width = '100%'; + maskSvg.style.height = '100%'; + maskSvg.style.pointerEvents = 'none'; + document.body.appendChild(maskSvg); + } + + // Clear existing mask content + maskSvg.innerHTML = ` + + + + + + + `; + + // Apply mask to overlay + this.overlay.style.mask = 'url(#overlay-mask)'; + this.overlay.style.webkitMask = 'url(#overlay-mask)'; + } + + // Clear overlay mask + clearOverlayMask() { + this.overlay.style.mask = 'none'; + this.overlay.style.webkitMask = 'none'; + + const maskSvg = document.getElementById('onboarding-mask'); + if (maskSvg) { + maskSvg.remove(); + } + } + + // Clear target highlighting + clearTargetHighlight() { + if (this.currentTarget) { + this.currentTarget.classList.remove('onboarding-target-highlight'); + this.currentTarget = null; + } + + if (this.spotlight) { + this.spotlight.classList.remove('onboarding-highlight'); + } + } + // Navigate to next step nextStep() { this.showStep(this.currentStep + 1); @@ -370,6 +456,9 @@ export class OnboardingManager { // Clean up overlay elements cleanup() { + this.clearTargetHighlight(); + this.clearOverlayMask(); + if (this.overlay) { document.body.removeChild(this.overlay); this.overlay = null;