278 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
`;
|