diff --git a/assets/app/app.css b/assets/app/app.css index a43b3f6a7..eedc002eb 100644 --- a/assets/app/app.css +++ b/assets/app/app.css @@ -79,3 +79,69 @@ body { background-color: var(--search-highlight-bg); color: var(--search-highlight-text); } + +/* Tour */ +@keyframes tour-highlight-pulse { + 0% { + outline-color: var(--swm-pink-100); + outline-offset: -3px; + box-shadow: 0 0 0 0 rgba(255, 98, 89, 0.5); + } + 50% { + outline-color: var(--swm-pink-80); + outline-offset: 2px; + box-shadow: 0 0 16px 4px rgba(255, 98, 89, 0.25); + } + 100% { + outline-color: var(--swm-pink-100); + outline-offset: -3px; + box-shadow: 0 0 0 0 rgba(255, 98, 89, 0.5); + } +} + +@keyframes tour-spotlight-pop { + 0% { + transform: scale(0.97); + box-shadow: 0 0 0 0 rgba(255, 98, 89, 0.6); + } + 40% { + transform: scale(1.02); + box-shadow: 0 0 24px 8px rgba(255, 98, 89, 0.3); + } + 100% { + transform: scale(1); + box-shadow: 0 0 12px 4px rgba(255, 98, 89, 0.15); + } +} + +@keyframes tour-overlay-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.tour-highlight { + outline: 3px solid var(--swm-pink-100); + outline-offset: -3px; + animation: tour-highlight-pulse 1.5s ease-in-out infinite; +} + +.tour-overlay { + position: fixed; + inset: 0; + z-index: 9998; + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + animation: tour-overlay-fade 0.3s ease-out; +} + +.tour-spotlight-target { + position: relative; + z-index: 9999; + pointer-events: auto; + animation: tour-spotlight-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + box-shadow: 0 0 12px 4px rgba(255, 98, 89, 0.15); +} diff --git a/assets/app/app.js b/assets/app/app.js index d58aa1102..f8fe08ca6 100644 --- a/assets/app/app.js +++ b/assets/app/app.js @@ -21,6 +21,7 @@ import OpenComponentsTree from './hooks/open_components_tree'; import CloseSidebarOnResize from './hooks/close_sidebar_on_resize'; import CodeMirrorTextarea from './hooks/code_mirror_textarea'; import SurveyBanner from './hooks/survey_banner'; +import Tour from './hooks/tour'; import topbar from './vendor/topbar'; @@ -53,6 +54,7 @@ function createHooks() { OpenComponentsTree, CloseSidebarOnResize, SurveyBanner, + Tour, }; } diff --git a/assets/app/hooks/tour.js b/assets/app/hooks/tour.js new file mode 100644 index 000000000..5d169255e --- /dev/null +++ b/assets/app/hooks/tour.js @@ -0,0 +1,212 @@ +const OVERLAY_ID = 'tour-overlay'; +const PENDING_ACTION_KEY = 'lvdbg-tour-pending'; + +const classGuardians = new Set(); + +function clearAll() { + const overlay = document.getElementById(OVERLAY_ID); + if (overlay) overlay.remove(); + + classGuardians.forEach((guardian) => guardian.disconnect()); + classGuardians.clear(); + + document + .querySelectorAll('.tour-highlight, .tour-spotlight-target') + .forEach((el) => { + el.classList.remove('tour-highlight', 'tour-spotlight-target'); + }); +} + +function guardClass(target, className) { + const observer = new MutationObserver(() => { + if (!target.classList.contains(className)) { + target.classList.add(className); + } + }); + + observer.observe(target, { + attributes: true, + attributeFilter: ['class'], + }); + + classGuardians.add(observer); +} + +function highlight(target) { + target.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + target.classList.add('tour-highlight'); + + guardClass(target, 'tour-highlight'); +} + +function createOverlay() { + if (!document.getElementById(OVERLAY_ID)) { + const overlay = document.createElement('div'); + overlay.id = OVERLAY_ID; + overlay.className = 'tour-overlay'; + document.body.appendChild(overlay); + } +} + +function spotlight(target) { + createOverlay(); + + target.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + target.classList.add('tour-spotlight-target'); + + guardClass(target, 'tour-spotlight-target'); +} + +const Tour = { + mounted() { + this._cleanups = new Set(); + const pending = sessionStorage.getItem(PENDING_ACTION_KEY); + if (pending) { + sessionStorage.removeItem(PENDING_ACTION_KEY); + this._applyAction(JSON.parse(pending)); + } + + this.handleEvent('tour-action', (payload) => { + this._applyAction(payload); + }); + }, + + _applyAction(payload) { + const { + action, + target: selector, + dismiss, + url, + then: nextAction, + clear = true, + } = payload; + + if (clear || action === 'clear') { + this._cleanupListeners(); + clearAll(); + } + + if (action === 'clear') return; + + if (action === 'redirect') { + if (nextAction) { + sessionStorage.setItem(PENDING_ACTION_KEY, JSON.stringify(nextAction)); + } + this.pushEvent('tour-redirect', { url }); + return; + } + + const target = document.querySelector(selector); + if (!target) { + this._waitForTarget(selector, () => + this._applyToTarget(selector, payload) + ); + return; + } + + this._applyToTarget(selector, payload); + }, + + _applyToTarget(selector, payload) { + const { action, dismiss } = payload; + const target = document.querySelector(selector); + if (!target) return; + + switch (action) { + case 'highlight': + highlight(target); + break; + case 'spotlight': + spotlight(target); + break; + default: + console.warn(`[Tour] Unknown action: ${action}`); + return; + } + + if (dismiss === 'click-anywhere') { + this._setupClickAnywhereDismiss(); + } else if (dismiss === 'click-target') { + this._setupClickTargetDismiss(target, selector); + } + }, + + _waitForTarget(selector, callback) { + const observer = new MutationObserver(() => { + if (document.querySelector(selector)) { + observer.disconnect(); + this._cleanups.delete(cleanup); + callback(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + const cleanup = () => observer.disconnect(); + this._cleanups.add(cleanup); + }, + + _cleanupListeners() { + this._cleanups.forEach((cleanup) => cleanup()); + this._cleanups.clear(); + }, + + _setupClickAnywhereDismiss() { + const controller = new AbortController(); + + const handler = () => { + clearAll(); + this._cleanupListeners(); + this.pushEvent('step-completed', { target: 'anywhere' }); + }; + + const cleanup = () => controller.abort(); + this._cleanups.add(cleanup); + + setTimeout(() => { + if (!controller.signal.aborted) { + document.addEventListener('click', handler, { + once: true, + signal: controller.signal, + }); + } + }, 0); + }, + + _setupClickTargetDismiss(target, selector) { + const overlay = document.getElementById(OVERLAY_ID); + + const handler = () => { + clearAll(); + this._cleanupListeners(); + this.pushEvent('step-completed', { target: selector }); + }; + + const overlayHandler = (e) => e.stopPropagation(); + if (overlay) { + overlay.addEventListener('click', overlayHandler); + } + + target.addEventListener('click', handler, { once: true }); + + const cleanup = () => { + target.removeEventListener('click', handler); + if (overlay) overlay.removeEventListener('click', overlayHandler); + }; + + this._cleanups.add(cleanup); + }, + + destroyed() { + this._cleanupListeners(); + clearAll(); + }, +}; + +export default Tour; diff --git a/assets/client/client.js b/assets/client/client.js index 8492c9790..749b84799 100644 --- a/assets/client/client.js +++ b/assets/client/client.js @@ -42,6 +42,11 @@ window.document.addEventListener('DOMContentLoaded', async () => { initTooltip(); initDebugMenu(metaTag, sessionURL, debugChannel); initHighlight(debugChannel); + + document.addEventListener('lvdbg:tour', (e) => { + const { command, ...payload } = e.detail; + debugChannel.push(command, payload); + }); } console.info(`LiveDebugger available at: ${baseURL}`); diff --git a/dev/live_views/main.ex b/dev/live_views/main.ex index 18fef38d3..2283c713f 100644 --- a/dev/live_views/main.ex +++ b/dev/live_views/main.ex @@ -2,6 +2,8 @@ defmodule LiveDebuggerDev.LiveViews.Main do use DevWeb, :live_view alias LiveDebuggerDev.LiveComponents + alias LiveDebugger.Tour + alias LiveDebugger.TourElements @long_text """ Lorem ipsum dolor sit amet, consectetur adipiscing elit. @@ -33,7 +35,9 @@ defmodule LiveDebuggerDev.LiveViews.Main do |> assign(long_assign: @long_text) |> assign(deep_assign: %{b: %{c: %{d: %{e: %{f: %{g: "deep value"}}}}}}) |> assign(message: nil) + |> assign(current_step: "clear") + Tour.redirect("/settings") {:ok, socket, temporary_assigns: [message: nil]} end @@ -46,6 +50,59 @@ defmodule LiveDebuggerDev.LiveViews.Main do