Skip to content
66 changes: 66 additions & 0 deletions assets/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 2 additions & 0 deletions assets/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -53,6 +54,7 @@ function createHooks() {
OpenComponentsTree,
CloseSidebarOnResize,
SurveyBanner,
Tour,
};
}

Expand Down
212 changes: 212 additions & 0 deletions assets/app/hooks/tour.js
Original file line number Diff line number Diff line change
@@ -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);
},
Comment thread
srzeszut marked this conversation as resolved.

_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;
5 changes: 5 additions & 0 deletions assets/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
57 changes: 57 additions & 0 deletions dev/live_views/main.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -46,6 +50,59 @@ defmodule LiveDebuggerDev.LiveViews.Main do
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1 border-2 border-purple-300 rounded p-2">
<span class="text-sm font-bold text-purple-600">Tour API Demo</span>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-500">Actions:</span>
<.button
color="purple"
phx-click={:navbar |> TourElements.id() |> Tour.spotlight_JS(dismiss: "click-anywhere")}
>
Spotlight Navbar
</.button>
<.button color="purple" phx-click={Tour.highlight_JS(:navbar_connected, clear: false)}>
Highlight PID
</.button>
<.button color="blue" phx-click={Tour.spotlight_JS(:callback_traces_first_trace)}>
Spotlight "Send Event"
</.button>
<.button color="gray" phx-click={Tour.clear_JS()}>
Clear
</.button>
</div>

<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-500">Redirect:</span>
<.button
color="purple"
phx-click={
Tour.redirect_JS("/settings", then: Tour.step(:highlight, :refresh_tracing_button))
}
>
Redirect to Settings + Highlight
</.button>
<.button
color="purple"
phx-click={
Tour.redirect_JS("/",
then: Tour.step(:spotlight, :navbar, dismiss: "click-anywhere")
)
}
>
Redirect to Discovery + Spotlight
</.button>
</div>

<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-500">Settings:</span>
<.button color="green" phx-click={Tour.enable_settings_JS()}>
Enable Settings
</.button>
<.button color="red" phx-click={Tour.disable_settings_JS()}>
Disable Settings
</.button>
</div>
</div>
<div class="flex items-center gap-2">
<.button id="append-message" phx-click="append-message" color="green">
Append message
Expand Down
2 changes: 1 addition & 1 deletion lib/live_debugger/api/settings_storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ defmodule LiveDebugger.API.SettingsStorage do
# User still can change it in settings, and until next app restart it will be used.
SettingsStorage.available_settings()
|> Enum.map(fn setting ->
{setting, Application.get_env(:live_debugger, setting, fetch_setting(setting))}
{setting, fetch_setting(setting)}
end)
|> Enum.each(fn {setting, value} -> save(setting, value) end)

Expand Down
Loading