diff --git a/src/ui/OnboardingTour/OnboardingTour.tsx b/src/ui/OnboardingTour/OnboardingTour.tsx new file mode 100644 index 0000000..c54b55a --- /dev/null +++ b/src/ui/OnboardingTour/OnboardingTour.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useReducer, useCallback, useState } from 'react' +import { createPortal } from 'react-dom' +import { TOUR_STEPS, TourStep } from './tourSteps' +import { TourTooltip } from './TourTooltip' + +// Storage key for persistence +const STORAGE_KEY = 'canvas_onboarding_completed' + +// State types +interface TourState { + isActive: boolean + currentStepIndex: number + hasCompletedTour: boolean +} + +type TourAction = + | { type: 'START_TOUR' } + | { type: 'NEXT_STEP' } + | { type: 'PREV_STEP' } + | { type: 'SKIP_TOUR' } + | { type: 'COMPLETE_TOUR' } + | { type: 'GO_TO_STEP'; payload: number } + +// Reducer for tour state +function tourReducer(state: TourState, action: TourAction): TourState { + switch (action.type) { + case 'START_TOUR': + return { ...state, isActive: true, currentStepIndex: 0 } + case 'NEXT_STEP': + return { ...state, currentStepIndex: state.currentStepIndex + 1 } + case 'PREV_STEP': + return { ...state, currentStepIndex: Math.max(0, state.currentStepIndex - 1) } + case 'SKIP_TOUR': + case 'COMPLETE_TOUR': + localStorage.setItem(STORAGE_KEY, 'true') + return { ...state, isActive: false, hasCompletedTour: true } + case 'GO_TO_STEP': + return { ...state, currentStepIndex: action.payload } + default: + return state + } +} + +// Hook to detect dark mode +function useDarkMode() { + const [isDark, setIsDark] = useState(() => + typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + ) + + useEffect(() => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'class') { + setIsDark(document.documentElement.classList.contains('dark')) + } + }) + }) + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }) + + return () => observer.disconnect() + }, []) + + return isDark +} + +// Global function to trigger tour from anywhere (e.g., Help menu) +export function startOnboardingTour() { + window.dispatchEvent(new CustomEvent('start-onboarding-tour')) +} + +// Main tour component +export function OnboardingTour() { + const isDark = useDarkMode() + const totalSteps = TOUR_STEPS.length + + const [state, dispatch] = useReducer(tourReducer, { + isActive: false, + currentStepIndex: 0, + hasCompletedTour: localStorage.getItem(STORAGE_KEY) === 'true' + }) + + const [targetRect, setTargetRect] = useState(null) + + const { isActive, currentStepIndex, hasCompletedTour } = state + const currentStep = TOUR_STEPS[currentStepIndex] + const isFirstStep = currentStepIndex === 0 + const isLastStep = currentStepIndex === totalSteps - 1 + + // Actions + const startTour = useCallback(() => dispatch({ type: 'START_TOUR' }), []) + + const nextStep = useCallback(() => { + if (currentStepIndex >= totalSteps - 1) { + dispatch({ type: 'COMPLETE_TOUR' }) + } else { + dispatch({ type: 'NEXT_STEP' }) + } + }, [currentStepIndex, totalSteps]) + + const prevStep = useCallback(() => dispatch({ type: 'PREV_STEP' }), []) + const skipTour = useCallback(() => dispatch({ type: 'SKIP_TOUR' }), []) + + const resetTour = useCallback(() => { + localStorage.removeItem(STORAGE_KEY) + dispatch({ type: 'START_TOUR' }) + }, []) + + // Listen for external trigger (from Help menu) + useEffect(() => { + const handleStartTour = () => resetTour() + window.addEventListener('start-onboarding-tour', handleStartTour) + return () => window.removeEventListener('start-onboarding-tour', handleStartTour) + }, [resetTour]) + + // Auto-start tour for first-time users (with delay to let UI render) + useEffect(() => { + if (!hasCompletedTour) { + const timer = setTimeout(() => startTour(), 1500) + return () => clearTimeout(timer) + } + }, [hasCompletedTour, startTour]) + + // Update target element rect when step changes + useEffect(() => { + if (!isActive || !currentStep) return + + const updateTargetRect = () => { + // Try each selector (some steps have multiple selectors separated by comma) + const selectors = currentStep.targetSelector.split(',').map(s => s.trim()) + let target: Element | null = null + + for (const selector of selectors) { + target = document.querySelector(selector) + if (target) break + } + + if (target) { + setTargetRect(target.getBoundingClientRect()) + } else if (currentStep.fallbackPosition) { + // Create a synthetic rect for fallback position + setTargetRect(new DOMRect( + currentStep.fallbackPosition.left, + currentStep.fallbackPosition.top, + 40, + 40 + )) + } else { + // Center of screen fallback + setTargetRect(new DOMRect( + window.innerWidth / 2 - 20, + window.innerHeight / 2 - 20, + 40, + 40 + )) + } + } + + // Initial update + updateTargetRect() + + // Update on resize and scroll + window.addEventListener('resize', updateTargetRect) + window.addEventListener('scroll', updateTargetRect, true) + + // Also update periodically in case elements are dynamically rendered + const interval = setInterval(updateTargetRect, 500) + + return () => { + window.removeEventListener('resize', updateTargetRect) + window.removeEventListener('scroll', updateTargetRect, true) + clearInterval(interval) + } + }, [isActive, currentStep, currentStepIndex]) + + // Keyboard navigation + useEffect(() => { + if (!isActive) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Don't capture if user is typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + + switch (e.key) { + case 'Escape': + e.preventDefault() + e.stopPropagation() + skipTour() + break + case 'ArrowRight': + case 'Enter': + e.preventDefault() + e.stopPropagation() + nextStep() + break + case 'ArrowLeft': + e.preventDefault() + e.stopPropagation() + if (!isFirstStep) prevStep() + break + } + } + + // Use capture phase to intercept before tldraw + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [isActive, nextStep, prevStep, skipTour, isFirstStep]) + + // Don't render if not active + if (!isActive || !currentStep) return null + + return createPortal( + , + document.body + ) +} diff --git a/src/ui/OnboardingTour/TourTooltip.tsx b/src/ui/OnboardingTour/TourTooltip.tsx new file mode 100644 index 0000000..d56bd30 --- /dev/null +++ b/src/ui/OnboardingTour/TourTooltip.tsx @@ -0,0 +1,349 @@ +import React from 'react' +import { TourStep } from './tourSteps' + +interface TourTooltipProps { + step: TourStep + stepNumber: number + totalSteps: number + targetRect: DOMRect | null + isDark: boolean + onNext: () => void + onPrev: () => void + onSkip: () => void + isFirstStep: boolean + isLastStep: boolean +} + +// Calculate tooltip position relative to target +function calculateTooltipPosition( + targetRect: DOMRect, + placement: TourStep['placement'], + tooltipWidth: number, + tooltipHeight: number, + gap: number +): { top: number; left: number } { + const { top, left, right, bottom, width, height } = targetRect + const centerX = left + width / 2 + const centerY = top + height / 2 + + switch (placement) { + case 'top': + return { top: top - tooltipHeight - gap, left: centerX - tooltipWidth / 2 } + case 'bottom': + return { top: bottom + gap, left: centerX - tooltipWidth / 2 } + case 'left': + return { top: centerY - tooltipHeight / 2, left: left - tooltipWidth - gap } + case 'right': + return { top: centerY - tooltipHeight / 2, left: right + gap } + case 'top-left': + return { top: top - tooltipHeight - gap, left: left } + case 'top-right': + return { top: top - tooltipHeight - gap, left: right - tooltipWidth } + case 'bottom-left': + return { top: bottom + gap, left: left } + case 'bottom-right': + return { top: bottom + gap, left: right - tooltipWidth } + default: + return { top: bottom + gap, left: centerX - tooltipWidth / 2 } + } +} + +// Clamp position to viewport bounds +function clampToViewport( + position: { top: number; left: number }, + tooltipWidth: number, + tooltipHeight: number, + padding: number = 16 +): { top: number; left: number } { + return { + top: Math.max(padding, Math.min(window.innerHeight - tooltipHeight - padding, position.top)), + left: Math.max(padding, Math.min(window.innerWidth - tooltipWidth - padding, position.left)) + } +} + +export function TourTooltip({ + step, + stepNumber, + totalSteps, + targetRect, + isDark, + onNext, + onPrev, + onSkip, + isFirstStep, + isLastStep +}: TourTooltipProps) { + const tooltipWidth = 320 + const tooltipHeight = 200 // Approximate, will auto-size + const highlightPadding = step.highlightPadding || 8 + const gap = 12 + + // Theme colors + const colors = isDark ? { + background: 'rgba(30, 30, 30, 0.98)', + border: 'rgba(70, 70, 70, 0.8)', + text: '#e4e4e4', + textMuted: '#a1a1aa', + accent: '#10b981', + accentHover: '#059669', + buttonBg: 'rgba(50, 50, 50, 0.8)', + buttonBorder: 'rgba(70, 70, 70, 0.6)', + buttonHover: 'rgba(70, 70, 70, 1)', + overlay: 'rgba(0, 0, 0, 0.7)' + } : { + background: 'rgba(255, 255, 255, 0.98)', + border: 'rgba(229, 231, 235, 0.8)', + text: '#18181b', + textMuted: '#71717a', + accent: '#10b981', + accentHover: '#059669', + buttonBg: 'rgba(244, 244, 245, 0.8)', + buttonBorder: 'rgba(212, 212, 216, 0.8)', + buttonHover: 'rgba(228, 228, 231, 1)', + overlay: 'rgba(0, 0, 0, 0.5)' + } + + // Calculate tooltip position + let tooltipPosition = { top: 100, left: 100 } + if (targetRect) { + const calculated = calculateTooltipPosition( + targetRect, + step.placement, + tooltipWidth, + tooltipHeight, + gap + ) + tooltipPosition = clampToViewport(calculated, tooltipWidth, tooltipHeight) + } + + return ( + <> + {/* Spotlight overlay with cutout */} + {targetRect && ( +
+ )} + + {/* Tooltip */} +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {/* Header with icon and step counter */} +
+
+ {step.icon && ( + + {step.icon} + + )} + + Step {stepNumber} of {totalSteps} + +
+ +
+ + {/* Content */} +
+

+ {step.title} +

+

+ {step.content} +

+ {step.actionHint && ( +

+ {step.actionHint} +

+ )} +
+ + {/* Navigation */} +
+ {/* Progress dots */} +
+ {Array.from({ length: totalSteps }).map((_, i) => ( +
+ ))} +
+ + {/* Buttons */} +
+ {!isFirstStep && ( + + )} + +
+
+
+ + {/* CSS Animation */} + + + ) +} diff --git a/src/ui/OnboardingTour/index.ts b/src/ui/OnboardingTour/index.ts new file mode 100644 index 0000000..f05582b --- /dev/null +++ b/src/ui/OnboardingTour/index.ts @@ -0,0 +1,2 @@ +export { OnboardingTour, startOnboardingTour } from './OnboardingTour' +export type { TourStep } from './tourSteps' diff --git a/src/ui/OnboardingTour/tourSteps.ts b/src/ui/OnboardingTour/tourSteps.ts new file mode 100644 index 0000000..6d94386 --- /dev/null +++ b/src/ui/OnboardingTour/tourSteps.ts @@ -0,0 +1,86 @@ +// Tour step definitions for the onboarding walkthrough + +export interface TourStep { + id: string + title: string + content: string + // Target element selector - CSS selector string + targetSelector: string + // Fallback positioning if element not found + fallbackPosition?: { top: number; left: number } + // Tooltip placement relative to target + placement: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' + // Optional highlight padding around target element + highlightPadding?: number + // Optional action hint (e.g., "Click to continue" vs "Press Enter") + actionHint?: string + // Optional icon for visual interest + icon?: string +} + +export const TOUR_STEPS: TourStep[] = [ + { + id: 'local-first', + title: 'Your Data, Your Device', + content: 'This canvas stores everything locally in your browser first. Your work is saved automatically and works offline - no account required to start creating.', + targetSelector: '.tlui-menu__button', + placement: 'bottom-right', + highlightPadding: 8, + icon: '🏠', + actionHint: 'Your data never leaves your device unless you choose to sync' + }, + { + id: 'cryptid-login', + title: 'Encrypted Identity', + content: 'Sign in with CryptID for end-to-end encrypted sync across devices. Your password never leaves your browser - we use cryptographic keys instead.', + targetSelector: '.cryptid-dropdown-trigger', + fallbackPosition: { top: 60, left: window.innerWidth - 200 }, + placement: 'bottom-left', + highlightPadding: 8, + icon: '🔐', + actionHint: 'Create an account or sign in to sync' + }, + { + id: 'share-collaborate', + title: 'Share & Collaborate', + content: 'Invite others to view or edit this board in real-time. See live cursors and collaborate together on the same canvas.', + targetSelector: '.share-board-button', + fallbackPosition: { top: 60, left: window.innerWidth - 150 }, + placement: 'bottom-left', + highlightPadding: 8, + icon: '👥', + actionHint: 'Share via link or QR code' + }, + { + id: 'toolbar-tools', + title: 'Your Creative Toolkit', + content: 'Draw, write, add shapes, embed media, generate AI images, and more. Everything you need to think visually is here.', + targetSelector: '.tlui-toolbar', + placement: 'right', + highlightPadding: 12, + icon: '🎨', + actionHint: 'Select a tool to get started' + }, + { + id: 'mycelial-ai', + title: 'Mycelial Intelligence', + content: 'Ask questions about your canvas. The AI understands all your shapes, notes, and connections - like a second brain for your visual thinking.', + targetSelector: '.mycelial-intelligence-bar', + fallbackPosition: { top: window.innerHeight - 100, left: window.innerWidth / 2 - 150 }, + placement: 'top', + highlightPadding: 8, + icon: '🍄', + actionHint: 'Type a question to get started' + }, + { + id: 'keyboard-shortcuts', + title: 'Keyboard Shortcuts', + content: 'Press ? anytime to see all keyboard shortcuts. Power users can navigate, zoom, and create without touching the mouse.', + targetSelector: '.help-button, [title*="Keyboard shortcuts"]', + fallbackPosition: { top: 60, left: window.innerWidth - 50 }, + placement: 'bottom-left', + highlightPadding: 8, + icon: '⌨️', + actionHint: 'Press ? to open shortcuts anytime' + } +] diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 3ac90c0..eb70f4f 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -27,6 +27,7 @@ import { useDialogs, } from "tldraw" import { SlidesPanel } from "@/slides/SlidesPanel" +import { OnboardingTour, startOnboardingTour } from "./OnboardingTour" // AI tool model configurations const AI_TOOLS = [ @@ -636,6 +637,31 @@ function CustomSharePanel() { API Keys + + {/* Show Tutorial */} + )} @@ -836,14 +862,14 @@ function CustomSharePanel() { boxShadow: '0 2px 8px rgba(0,0,0,0.1)', }}> {/* CryptID dropdown - leftmost */} -
+
{/* Share board button */} -
+
@@ -1332,6 +1358,46 @@ function CustomSharePanel() {
+ {/* Show Tutorial Button */} +
+ +
+ +
+ {/* AI Models Accordion */}