rspace-online/shared/tour-engine.ts

278 lines
8.3 KiB
TypeScript

/**
* Shared guided-tour engine for shadow-DOM rApp components.
*
* Designed to survive full innerHTML replacement — call `renderOverlay()`
* at the end of each render cycle to re-inject the tour UI.
*/
export interface TourStep {
target: string; // CSS selector within shadow root
title: string;
message: string;
advanceOnClick?: boolean; // auto-advance when target is clicked
}
export class TourEngine {
private shadowRoot: ShadowRoot;
private steps: TourStep[];
private storageKey: string;
private getContainer: () => HTMLElement | null;
private _active = false;
private _step = 0;
private _clickHandler: (() => void) | null = null;
private _clickTarget: HTMLElement | null = null;
get isActive() { return this._active; }
constructor(
shadowRoot: ShadowRoot,
steps: TourStep[],
storageKey: string,
getContainer: () => HTMLElement | null,
) {
this.shadowRoot = shadowRoot;
this.steps = steps;
this.storageKey = storageKey;
this.getContainer = getContainer;
}
/** Start (or restart) the tour from step 0. */
start() {
this._active = true;
this._step = 0;
this.renderOverlay();
}
/** Advance to next step or finish if at the end. */
advance() {
this._detachClickHandler();
this._step++;
if (this._step >= this.steps.length) {
this.end();
} else {
this.renderOverlay();
}
}
/** End the tour and remove the overlay. */
end() {
this._detachClickHandler();
this._active = false;
this._step = 0;
localStorage.setItem(this.storageKey, "1");
this.shadowRoot.getElementById("rspace-tour-overlay")?.remove();
}
/**
* Re-inject / update the tour overlay.
* Call this at the end of every host render() so the overlay survives
* full innerHTML replacement. Safe to call when tour is inactive (no-op).
*/
renderOverlay() {
if (!this._active) return;
// Ensure styles exist
this._ensureStyles();
// Skip steps whose target doesn't exist (e.g. mic button when unsupported)
const step = this.steps[this._step];
const targetEl = this.shadowRoot.querySelector(step.target) as HTMLElement | null;
if (!targetEl && this._step < this.steps.length - 1) {
this._step++;
this.renderOverlay();
return;
}
// Get or create overlay element
let overlay = this.shadowRoot.getElementById("rspace-tour-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "rspace-tour-overlay";
overlay.className = "rspace-tour-overlay";
const container = this.getContainer();
if (container) container.appendChild(overlay);
else this.shadowRoot.appendChild(overlay);
}
// Compute spotlight position relative to container
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
if (targetEl) {
const container = this.getContainer() || this.shadowRoot.host as HTMLElement;
const containerRect = container.getBoundingClientRect();
const rect = targetEl.getBoundingClientRect();
spotX = rect.left - containerRect.left - 6;
spotY = rect.top - containerRect.top - 6;
spotW = rect.width + 12;
spotH = rect.height + 12;
}
const isLast = this._step >= this.steps.length - 1;
const stepNum = this._step + 1;
const totalSteps = this.steps.length;
// Position tooltip below target, clamped within container
const tooltipTop = spotY + spotH + 12;
const tooltipLeft = Math.max(8, spotX);
overlay.innerHTML = `
<div class="rspace-tour-backdrop" style="clip-path: polygon(
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
)"></div>
<div class="rspace-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="rspace-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="rspace-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="rspace-tour-tooltip__title">${step.title}</div>
<div class="rspace-tour-tooltip__msg">${step.message}</div>
<div class="rspace-tour-tooltip__nav">
${this._step > 0 ? '<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="rspace-tour-tooltip__hint">or click the button above</span>`
: `<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
// Wire tour navigation buttons
overlay.querySelectorAll("[data-tour]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.tour;
if (action === "next") this.advance();
else if (action === "prev") {
this._detachClickHandler();
this._step = Math.max(0, this._step - 1);
this.renderOverlay();
}
else if (action === "skip") this.end();
});
});
// For advanceOnClick steps, attach listener on the target
this._detachClickHandler();
if (step.advanceOnClick && targetEl) {
this._clickHandler = () => {
this._detachClickHandler();
setTimeout(() => this.advance(), 300);
};
this._clickTarget = targetEl;
targetEl.addEventListener("click", this._clickHandler);
}
}
/** Remove the click-to-advance listener from the previous target. */
private _detachClickHandler() {
if (this._clickHandler && this._clickTarget) {
this._clickTarget.removeEventListener("click", this._clickHandler);
}
this._clickHandler = null;
this._clickTarget = null;
}
/** Inject the shared tour CSS once into the shadow root. */
private _ensureStyles() {
if (this.shadowRoot.querySelector("[data-tour-styles]")) return;
const style = document.createElement("style");
style.setAttribute("data-tour-styles", "");
style.textContent = TOUR_CSS;
this.shadowRoot.appendChild(style);
}
}
const TOUR_CSS = `
.rspace-tour-overlay {
position: absolute; inset: 0; z-index: 10000;
pointer-events: none;
}
.rspace-tour-backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: auto;
transition: clip-path 0.3s ease;
}
.rspace-tour-spotlight {
position: absolute;
border: 2px solid var(--rs-primary, #06b6d4);
border-radius: 8px;
box-shadow: 0 0 0 4px rgba(6, 182, 212, 0.25);
pointer-events: none;
transition: all 0.3s ease;
}
.rspace-tour-tooltip {
position: absolute;
width: min(320px, calc(100% - 24px));
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-border, #334155);
border-radius: 12px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
color: var(--rs-text-primary, #f1f5f9);
pointer-events: auto;
animation: rspace-tour-pop 0.25s ease-out;
}
@keyframes rspace-tour-pop {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.rspace-tour-tooltip__step {
font-size: 0.7rem;
color: var(--rs-text-muted, #64748b);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rspace-tour-tooltip__title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 6px;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rspace-tour-tooltip__msg {
font-size: 0.85rem;
color: var(--rs-text-secondary, #94a3b8);
line-height: 1.5;
margin-bottom: 12px;
}
.rspace-tour-tooltip__nav {
display: flex;
align-items: center;
gap: 8px;
}
.rspace-tour-tooltip__btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: background 0.15s, transform 0.1s;
}
.rspace-tour-tooltip__btn:hover { transform: translateY(-1px); }
.rspace-tour-tooltip__btn--next {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
color: white;
}
.rspace-tour-tooltip__btn--prev {
background: var(--rs-btn-secondary-bg, #334155);
color: var(--rs-text-secondary, #94a3b8);
}
.rspace-tour-tooltip__btn--skip {
background: none;
color: var(--rs-text-muted, #64748b);
margin-left: auto;
}
.rspace-tour-tooltip__btn--skip:hover { color: var(--rs-text-primary, #f1f5f9); }
.rspace-tour-tooltip__hint {
font-size: 0.72rem;
color: var(--rs-text-muted, #64748b);
font-style: italic;
}
`;