feat(tours+solo): add tours to remaining modules and solo mode toggle
Add guided tours to 6 modules that were missing them: - Shadow DOM: rsocials dashboard, crowdsurf, rdata (TourEngine) - Light DOM: rsplat, rbnb, rvnb (new LightTourEngine class) Add solo mode toggle to collab overlay — click the presence badge to hide your cursor/presence from others. Persists via localStorage, dispatches event to canvas PresenceManager. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e933e6238b
commit
524356d233
|
|
@ -45,6 +45,7 @@ export class PresenceManager extends EventTarget {
|
|||
#panX = 0;
|
||||
#panY = 0;
|
||||
#scale = 1;
|
||||
#soloModeHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
constructor(container: HTMLElement, peerId: string, username?: string) {
|
||||
super();
|
||||
|
|
@ -57,6 +58,18 @@ export class PresenceManager extends EventTarget {
|
|||
|
||||
// Start fade check interval
|
||||
this.#fadeInterval = window.setInterval(() => this.#checkFades(), 1000);
|
||||
|
||||
// Listen for solo-mode-change events from collab overlay
|
||||
this.#soloModeHandler = (e: Event) => {
|
||||
const solo = (e as CustomEvent).detail?.solo ?? false;
|
||||
this.setVisible(!solo);
|
||||
};
|
||||
document.addEventListener('solo-mode-change', this.#soloModeHandler);
|
||||
|
||||
// Apply initial solo mode state
|
||||
if (localStorage.getItem('rspace_solo_mode') === '1') {
|
||||
this.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
get localPeerId() {
|
||||
|
|
@ -171,6 +184,10 @@ export class PresenceManager extends EventTarget {
|
|||
for (const [peerId] of this.#users) {
|
||||
this.removeUser(peerId);
|
||||
}
|
||||
if (this.#soloModeHandler) {
|
||||
document.removeEventListener('solo-mode-change', this.#soloModeHandler);
|
||||
this.#soloModeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
#refreshCursors() {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* Multiplayer: uses CrowdSurfLocalFirstClient for real-time sync via Automerge.
|
||||
*/
|
||||
|
||||
import { TourEngine } from '../../../shared/tour-engine';
|
||||
import type { TourStep } from '../../../shared/tour-engine';
|
||||
import { CrowdSurfLocalFirstClient } from '../local-first-client';
|
||||
import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas';
|
||||
import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas';
|
||||
|
|
@ -26,9 +28,17 @@ function getMyDid(): string | null {
|
|||
|
||||
type ViewTab = 'discover' | 'create' | 'rank' | 'profile';
|
||||
|
||||
const CROWDSURF_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.cs-nav-btn[data-tab="discover"]', title: 'Discover', message: 'Swipe through nearby activities — join ones that interest you.' },
|
||||
{ target: '#cs-current-card', title: 'Swipe Cards', message: 'Swipe right to join or left to skip. You can also tap the buttons below.' },
|
||||
{ target: '.cs-nav-btn[data-tab="create"]', title: 'Create', message: 'Propose a new activity for your community.' },
|
||||
{ target: '.cs-nav-btn[data-tab="rank"]', title: 'Rank', message: 'Elo pairwise ranking to surface the best activities.' },
|
||||
];
|
||||
|
||||
class FolkCrowdSurfDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space: string;
|
||||
private _tour!: TourEngine;
|
||||
|
||||
// State
|
||||
private activeTab: ViewTab = 'discover';
|
||||
|
|
@ -66,6 +76,12 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
super();
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
this.space = this.getAttribute('space') || 'demo';
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
CROWDSURF_TOUR_STEPS,
|
||||
'crowdsurf_tour_done',
|
||||
() => this.shadow.querySelector('.cs-app') as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -109,6 +125,9 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
this.loading = false;
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
if (!localStorage.getItem('crowdsurf_tour_done')) {
|
||||
setTimeout(() => this._tour.start(), 800);
|
||||
}
|
||||
|
||||
// Check expiry every 30s
|
||||
this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000);
|
||||
|
|
@ -181,8 +200,13 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
this.loading = false;
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
if (!localStorage.getItem('crowdsurf_tour_done')) {
|
||||
setTimeout(() => this._tour.start(), 800);
|
||||
}
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
// ── Swipe mechanics ──
|
||||
|
||||
private getActivePrompts(): CrowdSurfPrompt[] {
|
||||
|
|
@ -333,6 +357,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
</button>
|
||||
</nav>
|
||||
</div>`;
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
private renderActiveView(isLive: boolean): string {
|
||||
|
|
@ -356,6 +381,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
<span class="cs-title">CrowdSurf</span>
|
||||
${isLive ? '<span class="cs-live"><span class="cs-live-dot"></span>LIVE</span>' : ''}
|
||||
${this.space === 'demo' ? '<span class="cs-demo-badge">DEMO</span>' : ''}
|
||||
<button style="margin-left:auto;padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;font-family:inherit;" id="btn-tour">Tour</button>
|
||||
</div>
|
||||
|
||||
<div class="cs-discover">
|
||||
|
|
@ -740,6 +766,9 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
|||
|
||||
// Rank: start
|
||||
this.shadow.querySelector('[data-action="rank-start"]')?.addEventListener('click', () => this.loadRankPair());
|
||||
|
||||
// Tour button
|
||||
this.shadow.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
|
||||
}
|
||||
|
||||
private setupSwipeGestures(card: HTMLElement) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@
|
|||
* host dashboard (my listings + incoming requests), stay request sidebar.
|
||||
*/
|
||||
|
||||
import { LightTourEngine } from '../../../shared/tour-engine';
|
||||
import type { TourStep } from '../../../shared/tour-engine';
|
||||
import './folk-listing';
|
||||
import './folk-stay-request';
|
||||
|
||||
const BNB_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' },
|
||||
{ target: '#bnb-content', title: 'Browse Stays', message: 'Tap a listing card to see details and stay requests.' },
|
||||
{ target: '.bnb-view__toggle[data-view="map"]', title: 'Map View', message: 'See all listings on an interactive map.' },
|
||||
];
|
||||
|
||||
// ── Leaflet CDN Loader ──
|
||||
|
||||
let _leafletReady = false;
|
||||
|
|
@ -56,6 +64,7 @@ class FolkBnbView extends HTMLElement {
|
|||
#economyFilter = '';
|
||||
#map: any = null;
|
||||
#mapContainer: HTMLElement | null = null;
|
||||
#tour: LightTourEngine | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.#space = this.getAttribute('space') || 'demo';
|
||||
|
|
@ -129,6 +138,7 @@ class FolkBnbView extends HTMLElement {
|
|||
<button class="bnb-view__toggle" data-view="map" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'map' ? 'rgba(245,158,11,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
|
||||
\u{1F5FA} Map
|
||||
</button>
|
||||
<button id="btn-tour" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.78rem">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -166,6 +176,13 @@ class FolkBnbView extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.#wireEvents();
|
||||
|
||||
// Tour
|
||||
this.#tour = new LightTourEngine(this.querySelector('.bnb-view') as HTMLElement || this, BNB_TOUR_STEPS, 'rbnb_tour_done');
|
||||
if (!localStorage.getItem('rbnb_tour_done')) {
|
||||
setTimeout(() => this.#tour?.start(), 800);
|
||||
}
|
||||
this.querySelector('#btn-tour')?.addEventListener('click', () => this.#tour?.start());
|
||||
}
|
||||
|
||||
#wireEvents() {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
* click-to-navigate, demo mode fallback.
|
||||
*/
|
||||
|
||||
import { TourEngine } from '../../../shared/tour-engine';
|
||||
import type { TourStep } from '../../../shared/tour-engine';
|
||||
|
||||
interface TreeItem {
|
||||
docId: string;
|
||||
title: string;
|
||||
|
|
@ -118,10 +121,17 @@ const COLLECTION_ICONS: Record<string, string> = {
|
|||
pages: "📃", books: "📚", items: "🏷", channels: "📺",
|
||||
};
|
||||
|
||||
const CONTENT_TREE_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.ct-search', title: 'Search Content', message: 'Find items across all modules by name or tag.' },
|
||||
{ target: '.ct-tags', title: 'Filter', message: 'Narrow results by module or tag.' },
|
||||
{ target: '.ct-tree', title: 'Content Tree', message: 'Browse all space data hierarchically — expand modules and collections.' },
|
||||
];
|
||||
|
||||
class FolkContentTree extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "demo";
|
||||
private data: TreeData | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private search = "";
|
||||
private activeTags = new Set<string>();
|
||||
private activeModules = new Set<string>();
|
||||
|
|
@ -133,6 +143,12 @@ class FolkContentTree extends HTMLElement {
|
|||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
CONTENT_TREE_TOUR_STEPS,
|
||||
'rdata_tour_done',
|
||||
() => this.shadow.querySelector('.ct') as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -181,8 +197,13 @@ class FolkContentTree extends HTMLElement {
|
|||
this.expanded.add(`mod:${mod.id}`);
|
||||
}
|
||||
this.render();
|
||||
if (!localStorage.getItem('rdata_tour_done')) {
|
||||
setTimeout(() => this._tour.start(), 800);
|
||||
}
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private matchesSearch(text: string): boolean {
|
||||
if (!this.search) return true;
|
||||
return text.toLowerCase().includes(this.search.toLowerCase());
|
||||
|
|
@ -301,7 +322,7 @@ class FolkContentTree extends HTMLElement {
|
|||
`).join("")}
|
||||
${this.activeTags.size > 0 ? `<button class="ct-tag ct-tag--clear">Clear filters</button>` : ""}
|
||||
</div>` : ""}
|
||||
<div class="ct-summary">${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""}</div>
|
||||
<div class="ct-summary">${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""} <button style="margin-left:8px;padding:2px 8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.72rem;" id="btn-tour">Tour</button></div>
|
||||
</div>
|
||||
<div class="ct-tree">
|
||||
${totalItems === 0 ? `<div class="ct-empty">No content found${this.search || this.activeTags.size ? " matching your filters" : " in this space"}.</div>` : ""}
|
||||
|
|
@ -311,6 +332,7 @@ class FolkContentTree extends HTMLElement {
|
|||
</div>
|
||||
`;
|
||||
|
||||
this._tour.renderOverlay();
|
||||
this.attachEvents();
|
||||
}
|
||||
|
||||
|
|
@ -440,6 +462,9 @@ class FolkContentTree extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
// Tour button
|
||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
|
||||
// Navigate on leaf click
|
||||
for (const row of this.shadow.querySelectorAll<HTMLElement>(".ct-node__row--leaf[data-nav]")) {
|
||||
row.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
* space — space slug (default "demo")
|
||||
*/
|
||||
|
||||
import { TourEngine } from '../../../shared/tour-engine';
|
||||
import type { TourStep } from '../../../shared/tour-engine';
|
||||
import { CAMPAIGN_NODE_CATALOG } from '../schemas';
|
||||
import type {
|
||||
CampaignWorkflowNodeDef,
|
||||
|
|
@ -107,11 +109,18 @@ function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdg
|
|||
|
||||
// ── Component ──
|
||||
|
||||
const DASHBOARD_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign creation flow — answer a few questions and get a ready-to-run workflow.' },
|
||||
{ target: '#btn-new', title: 'New Workflow', message: 'Create a blank workflow from scratch and wire up your own nodes.' },
|
||||
{ target: '.cd-card', title: 'Workflow Cards', message: 'Click any card to open and edit its node graph.' },
|
||||
];
|
||||
|
||||
class FolkCampaignsDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = '';
|
||||
private workflows: CampaignWorkflow[] = [];
|
||||
private loading = true;
|
||||
private _tour!: TourEngine;
|
||||
|
||||
private get basePath() {
|
||||
const host = window.location.hostname;
|
||||
|
|
@ -122,6 +131,12 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
this._tour = new TourEngine(
|
||||
this.shadow,
|
||||
DASHBOARD_TOUR_STEPS,
|
||||
'rsocials_dashboard_tour_done',
|
||||
() => this.shadow.querySelector('.cd-root') as HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -142,8 +157,13 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
if (!localStorage.getItem('rsocials_dashboard_tour_done')) {
|
||||
setTimeout(() => this._tour.start(), 800);
|
||||
}
|
||||
}
|
||||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private async createWorkflow() {
|
||||
try {
|
||||
const res = await fetch(`${this.basePath}api/campaign-workflows`, {
|
||||
|
|
@ -296,6 +316,7 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
<div class="cd-header__actions">
|
||||
<button class="cd-btn cd-btn--wizard" id="btn-wizard">\uD83E\uDDD9 Campaign Wizard</button>
|
||||
${!this.loading && this.workflows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Workflow</button>' : ''}
|
||||
<button style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;" id="btn-tour">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
${loadingState}
|
||||
|
|
@ -304,6 +325,7 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
</div>
|
||||
`;
|
||||
|
||||
this._tour.renderOverlay();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
|
|
@ -329,6 +351,8 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; });
|
||||
const btnWizardEmpty = this.shadow.getElementById('btn-wizard-empty');
|
||||
if (btnWizardEmpty) btnWizardEmpty.addEventListener('click', () => { window.location.href = wizardUrl; });
|
||||
|
||||
this.shadow.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,18 @@
|
|||
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
|
||||
*/
|
||||
|
||||
import { LightTourEngine } from "../../../shared/tour-engine";
|
||||
import type { TourStep } from "../../../shared/tour-engine";
|
||||
import { makeDraggableAll } from "../../../shared/draggable";
|
||||
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
|
||||
const SPLAT_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.splat-grid', title: '3D Gallery', message: 'Browse Gaussian splat models — click any card to view in 3D.' },
|
||||
{ target: '#splat-drop', title: 'Upload / Generate', message: 'Add .ply or .splat files, or generate a 3D model from a single image using AI.' },
|
||||
{ target: '.splat-gallery__header', title: 'rSplat', message: 'Orbit, zoom, and inspect photorealistic 3D captures.' },
|
||||
];
|
||||
|
||||
interface SplatItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
@ -32,6 +40,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
private _mode: "gallery" | "viewer" = "gallery";
|
||||
private _splats: SplatItem[] = [];
|
||||
private _spaceSlug = "demo";
|
||||
private _tour: LightTourEngine | null = null;
|
||||
private _splatUrl = "";
|
||||
private _splatTitle = "";
|
||||
private _splatDesc = "";
|
||||
|
|
@ -231,7 +240,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
this.innerHTML = `
|
||||
<div class="splat-gallery">
|
||||
<div class="splat-gallery__header">
|
||||
<h1>rSplat</h1>
|
||||
<h1 style="display:flex;align-items:center;gap:0.5rem">rSplat <button style="padding:3px 8px;border-radius:6px;border:1px solid var(--rs-border,#334155);background:var(--rs-bg-surface,#1e293b);color:var(--rs-text-secondary,#94a3b8);cursor:pointer;font-size:0.72rem;font-weight:400" id="btn-tour">Tour</button></h1>
|
||||
<p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
|
||||
</div>
|
||||
${myModelsHtml}
|
||||
|
|
@ -288,6 +297,13 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
this.setupToggle();
|
||||
this.setupDemoCardHandlers();
|
||||
|
||||
// Tour
|
||||
this._tour = new LightTourEngine(this.querySelector('.splat-gallery') as HTMLElement || this, SPLAT_TOUR_STEPS, 'rsplat_tour_done');
|
||||
if (!localStorage.getItem('rsplat_tour_done')) {
|
||||
setTimeout(() => this._tour?.start(), 800);
|
||||
}
|
||||
this.querySelector('#btn-tour')?.addEventListener('click', () => this._tour?.start());
|
||||
|
||||
// Make splat cards draggable for calendar reminders
|
||||
makeDraggableAll(this, ".splat-card[data-collab-id]", (el) => {
|
||||
const title = el.querySelector(".splat-card__title")?.textContent || "";
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@
|
|||
* owner dashboard (my vehicles + incoming requests), rental request sidebar.
|
||||
*/
|
||||
|
||||
import { LightTourEngine } from '../../../shared/tour-engine';
|
||||
import type { TourStep } from '../../../shared/tour-engine';
|
||||
import './folk-vehicle-card';
|
||||
import './folk-rental-request';
|
||||
|
||||
const VNB_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' },
|
||||
{ target: '#vnb-content', title: 'Browse Vehicles', message: 'Tap a card for details and rental requests.' },
|
||||
{ target: '.vnb-view__toggle[data-view="map"]', title: 'Map View', message: 'See vehicles on an interactive map.' },
|
||||
];
|
||||
|
||||
// ── Leaflet CDN Loader ──
|
||||
|
||||
let _leafletReady = false;
|
||||
|
|
@ -61,6 +69,7 @@ class FolkVnbView extends HTMLElement {
|
|||
#economyFilter = '';
|
||||
#map: any = null;
|
||||
#mapContainer: HTMLElement | null = null;
|
||||
#tour: LightTourEngine | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.#space = this.getAttribute('space') || 'demo';
|
||||
|
|
@ -136,6 +145,7 @@ class FolkVnbView extends HTMLElement {
|
|||
<button class="vnb-view__toggle" data-view="map" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'map' ? 'rgba(16,185,129,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
|
||||
\u{1F5FA} Map
|
||||
</button>
|
||||
<button id="btn-tour" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.78rem">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -170,6 +180,13 @@ class FolkVnbView extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.#wireEvents();
|
||||
|
||||
// Tour
|
||||
this.#tour = new LightTourEngine(this.querySelector('.vnb-view') as HTMLElement || this, VNB_TOUR_STEPS, 'rvnb_tour_done');
|
||||
if (!localStorage.getItem('rvnb_tour_done')) {
|
||||
setTimeout(() => this.#tour?.start(), 800);
|
||||
}
|
||||
this.querySelector('#btn-tour')?.addEventListener('click', () => this.#tour?.start());
|
||||
}
|
||||
|
||||
#wireEvents() {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
||||
#badgeOnly = false;
|
||||
#hidden = false; // true on canvas page
|
||||
#soloMode = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -55,10 +56,13 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.#moduleId = this.getAttribute('module-id');
|
||||
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
|
||||
this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
|
||||
|
||||
// Hide on canvas page — it has its own CommunitySync + PresenceManager
|
||||
if (this.#moduleId === 'rspace') {
|
||||
this.#hidden = true;
|
||||
// Still dispatch initial solo-mode event for canvas PresenceManager
|
||||
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +76,9 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
// Resolve local identity
|
||||
this.#resolveIdentity();
|
||||
|
||||
// Render initial (empty badge)
|
||||
// Render initial (badge always visible)
|
||||
this.#render();
|
||||
this.#renderBadge();
|
||||
|
||||
// Try connecting to runtime
|
||||
this.#tryConnect();
|
||||
|
|
@ -171,6 +176,8 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
// ── Remote awareness handling ──
|
||||
|
||||
#handleRemoteAwareness(msg: AwarenessMessage) {
|
||||
if (this.#soloMode) return; // suppress incoming awareness in solo mode
|
||||
|
||||
const existing = this.#peers.get(msg.peer);
|
||||
const peer: PeerState = {
|
||||
peerId: msg.peer,
|
||||
|
|
@ -191,6 +198,8 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
// ── Local broadcasting ──
|
||||
|
||||
#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
|
||||
if (this.#soloMode) return; // suppress outgoing awareness in solo mode
|
||||
|
||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||
if (!runtime || !this.#docId) return;
|
||||
|
||||
|
|
@ -292,22 +301,45 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
#render() {
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${OVERLAY_CSS}</style>
|
||||
<div class="collab-badge" id="badge"></div>
|
||||
<div class="collab-badge visible" id="badge"></div>
|
||||
<div class="collab-cursors" id="cursors"></div>
|
||||
`;
|
||||
this.#shadow.getElementById('badge')?.addEventListener('click', () => this.#toggleSoloMode());
|
||||
}
|
||||
|
||||
#toggleSoloMode() {
|
||||
this.#soloMode = !this.#soloMode;
|
||||
localStorage.setItem('rspace_solo_mode', this.#soloMode ? '1' : '0');
|
||||
|
||||
if (this.#soloMode) {
|
||||
// Clear remote peers and their visual artifacts
|
||||
this.#peers.clear();
|
||||
if (!this.#badgeOnly) {
|
||||
this.#renderCursors();
|
||||
this.#renderFocusRings();
|
||||
}
|
||||
}
|
||||
|
||||
this.#renderBadge();
|
||||
|
||||
// Notify canvas PresenceManager and any other listeners
|
||||
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
|
||||
}
|
||||
|
||||
#renderBadge() {
|
||||
const badge = this.#shadow.getElementById('badge');
|
||||
if (!badge) return;
|
||||
|
||||
const count = this.#peers.size + 1; // +1 for self
|
||||
if (count <= 1) {
|
||||
badge.innerHTML = '';
|
||||
badge.classList.remove('visible');
|
||||
if (this.#soloMode) {
|
||||
badge.innerHTML = `<span class="count solo">\u{1F464} Solo</span>`;
|
||||
badge.classList.add('visible', 'solo');
|
||||
badge.title = 'Solo mode \u2014 your presence is hidden. Click to go collaborative.';
|
||||
return;
|
||||
}
|
||||
|
||||
badge.classList.remove('solo');
|
||||
const count = this.#peers.size + 1; // +1 for self
|
||||
|
||||
const dots = Array.from(this.#peers.values())
|
||||
.slice(0, 5) // show max 5 dots
|
||||
.map(p => `<span class="dot" style="background:${p.color}"></span>`)
|
||||
|
|
@ -316,9 +348,10 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
badge.innerHTML = `
|
||||
<span class="dot" style="background:${this.#localColor}"></span>
|
||||
${dots}
|
||||
<span class="count">${count} online</span>
|
||||
<span class="count">\u{1F465} ${count} online</span>
|
||||
`;
|
||||
badge.classList.add('visible');
|
||||
badge.title = 'Collaborative \u2014 sharing your presence. Click for solo mode.';
|
||||
}
|
||||
|
||||
#renderCursors() {
|
||||
|
|
@ -433,16 +466,29 @@ const OVERLAY_CSS = `
|
|||
font-size: 11px;
|
||||
color: var(--rs-text-secondary, #ccc);
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
z-index: 10000;
|
||||
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
|
||||
transition: opacity 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.collab-badge:hover {
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.collab-badge.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.collab-badge.solo {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.count.solo {
|
||||
color: var(--rs-text-muted, #888);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
|
|
|
|||
|
|
@ -183,6 +183,152 @@ export class TourEngine {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LightTourEngine — tour engine for non-shadow-DOM components.
|
||||
* Uses a container element instead of ShadowRoot, injects CSS into document.head.
|
||||
*/
|
||||
export class LightTourEngine {
|
||||
private container: HTMLElement;
|
||||
private steps: TourStep[];
|
||||
private storageKey: string;
|
||||
|
||||
private _active = false;
|
||||
private _step = 0;
|
||||
private _clickHandler: (() => void) | null = null;
|
||||
private _clickTarget: HTMLElement | null = null;
|
||||
|
||||
get isActive() { return this._active; }
|
||||
|
||||
constructor(container: HTMLElement, steps: TourStep[], storageKey: string) {
|
||||
this.container = container;
|
||||
this.steps = steps;
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._active = true;
|
||||
this._step = 0;
|
||||
this.renderOverlay();
|
||||
}
|
||||
|
||||
advance() {
|
||||
this._detachClickHandler();
|
||||
this._step++;
|
||||
if (this._step >= this.steps.length) {
|
||||
this.end();
|
||||
} else {
|
||||
this.renderOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
end() {
|
||||
this._detachClickHandler();
|
||||
this._active = false;
|
||||
this._step = 0;
|
||||
localStorage.setItem(this.storageKey, "1");
|
||||
this.container.querySelector("#rspace-tour-overlay")?.remove();
|
||||
}
|
||||
|
||||
renderOverlay() {
|
||||
if (!this._active) return;
|
||||
this._ensureStyles();
|
||||
|
||||
const step = this.steps[this._step];
|
||||
const targetEl = this.container.querySelector(step.target) as HTMLElement | null;
|
||||
if (!targetEl && this._step < this.steps.length - 1) {
|
||||
this._step++;
|
||||
this.renderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
let overlay = this.container.querySelector("#rspace-tour-overlay") as HTMLElement | null;
|
||||
if (!overlay) {
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "rspace-tour-overlay";
|
||||
overlay.className = "rspace-tour-overlay";
|
||||
this.container.appendChild(overlay);
|
||||
}
|
||||
|
||||
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
|
||||
if (targetEl) {
|
||||
const containerRect = this.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;
|
||||
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>
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
this._detachClickHandler();
|
||||
if (step.advanceOnClick && targetEl) {
|
||||
this._clickHandler = () => {
|
||||
this._detachClickHandler();
|
||||
setTimeout(() => this.advance(), 300);
|
||||
};
|
||||
this._clickTarget = targetEl;
|
||||
targetEl.addEventListener("click", this._clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
private _detachClickHandler() {
|
||||
if (this._clickHandler && this._clickTarget) {
|
||||
this._clickTarget.removeEventListener("click", this._clickHandler);
|
||||
}
|
||||
this._clickHandler = null;
|
||||
this._clickTarget = null;
|
||||
}
|
||||
|
||||
private _ensureStyles() {
|
||||
if (document.head.querySelector("[data-light-tour-styles]")) return;
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-light-tour-styles", "");
|
||||
style.textContent = TOUR_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
const TOUR_CSS = `
|
||||
.rspace-tour-overlay {
|
||||
position: absolute; inset: 0; z-index: 10000;
|
||||
|
|
|
|||
Loading…
Reference in New Issue