feat: add onboarding tooltip tour for new users
- Create 6-step spotlight tour covering key features:
- Local-first storage
- Encrypted identity (CryptID)
- Share & collaborate
- Creative toolkit
- Mycelial Intelligence
- Keyboard shortcuts
- Features:
- Auto-starts for first-time users (1.5s delay)
- Spotlight effect with darkened backdrop
- Keyboard navigation (Escape, arrows, Enter)
- "Show Tutorial" button in settings (desktop + mobile)
- Dark mode support
- Progress dots indicator
- New files: src/ui/OnboardingTour/{index,OnboardingTour,TourTooltip,tourSteps}.ts(x)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
09eb17605e
commit
6a85381a6c
|
|
@ -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<DOMRect | null>(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(
|
||||||
|
<TourTooltip
|
||||||
|
step={currentStep}
|
||||||
|
stepNumber={currentStepIndex + 1}
|
||||||
|
totalSteps={totalSteps}
|
||||||
|
targetRect={targetRect}
|
||||||
|
isDark={isDark}
|
||||||
|
onNext={nextStep}
|
||||||
|
onPrev={prevStep}
|
||||||
|
onSkip={skipTour}
|
||||||
|
isFirstStep={isFirstStep}
|
||||||
|
isLastStep={isLastStep}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: targetRect.y - highlightPadding,
|
||||||
|
left: targetRect.x - highlightPadding,
|
||||||
|
width: targetRect.width + highlightPadding * 2,
|
||||||
|
height: targetRect.height + highlightPadding * 2,
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: `0 0 0 9999px ${colors.overlay}`,
|
||||||
|
zIndex: 99998,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'all 0.3s ease-out'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="tour-step-title"
|
||||||
|
aria-describedby="tour-step-content"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: tooltipPosition.top,
|
||||||
|
left: tooltipPosition.left,
|
||||||
|
width: tooltipWidth,
|
||||||
|
zIndex: 99999,
|
||||||
|
background: colors.background,
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 20px 50px rgba(0, 0, 0, 0.5), 0 10px 20px rgba(0, 0, 0, 0.3)'
|
||||||
|
: '0 20px 50px rgba(0, 0, 0, 0.15), 0 10px 20px rgba(0, 0, 0, 0.1)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
animation: 'tourTooltipFadeIn 0.25s ease-out',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header with icon and step counter */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 18px 0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
{step.icon && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
filter: isDark ? 'none' : 'saturate(0.9)'
|
||||||
|
}}>
|
||||||
|
{step.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: colors.accent,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px'
|
||||||
|
}}>
|
||||||
|
Step {stepNumber} of {totalSteps}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: colors.textMuted,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
opacity: 0.6,
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '1'
|
||||||
|
e.currentTarget.style.background = colors.buttonBg
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.opacity = '0.6'
|
||||||
|
e.currentTarget.style.background = 'none'
|
||||||
|
}}
|
||||||
|
title="Skip tour (Esc)"
|
||||||
|
aria-label="Skip tour"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ padding: '14px 18px 16px' }}>
|
||||||
|
<h3
|
||||||
|
id="tour-step-title"
|
||||||
|
style={{
|
||||||
|
margin: '0 0 10px',
|
||||||
|
fontSize: '17px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: colors.text,
|
||||||
|
lineHeight: 1.3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
id="tour-step-content"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: colors.textMuted
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.content}
|
||||||
|
</p>
|
||||||
|
{step.actionHint && (
|
||||||
|
<p style={{
|
||||||
|
margin: '12px 0 0',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: colors.accent,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
opacity: 0.9
|
||||||
|
}}>
|
||||||
|
{step.actionHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 18px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTop: `1px solid ${colors.border}`
|
||||||
|
}}>
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: i === stepNumber - 1 ? '16px' : '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
background: i === stepNumber - 1 ? colors.accent : colors.buttonBg,
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{!isFirstStep && (
|
||||||
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${colors.buttonBorder}`,
|
||||||
|
background: colors.buttonBg,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = colors.buttonHover
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = colors.buttonBg
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: colors.accent,
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = colors.accentHover
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = colors.accent
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLastStep ? 'Get Started' : 'Next'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS Animation */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes tourTooltipFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { OnboardingTour, startOnboardingTour } from './OnboardingTour'
|
||||||
|
export type { TourStep } from './tourSteps'
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
useDialogs,
|
useDialogs,
|
||||||
} from "tldraw"
|
} from "tldraw"
|
||||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||||
|
import { OnboardingTour, startOnboardingTour } from "./OnboardingTour"
|
||||||
|
|
||||||
// AI tool model configurations
|
// AI tool model configurations
|
||||||
const AI_TOOLS = [
|
const AI_TOOLS = [
|
||||||
|
|
@ -636,6 +637,31 @@ function CustomSharePanel() {
|
||||||
<span>API Keys</span>
|
<span>API Keys</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Show Tutorial */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowMobileMenu(false)
|
||||||
|
startOnboardingTour()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{ fontSize: '18px' }}>🎓</span>
|
||||||
|
<span>Show Tutorial</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -836,14 +862,14 @@ function CustomSharePanel() {
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
}}>
|
}}>
|
||||||
{/* CryptID dropdown - leftmost */}
|
{/* CryptID dropdown - leftmost */}
|
||||||
<div style={{ padding: '0 4px' }}>
|
<div className="cryptid-dropdown-trigger" style={{ padding: '0 4px' }}>
|
||||||
<CryptIDDropdown isDarkMode={isDarkMode} />
|
<CryptIDDropdown isDarkMode={isDarkMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Share board button */}
|
{/* Share board button */}
|
||||||
<div style={{ padding: '0 2px' }}>
|
<div className="share-board-button" style={{ padding: '0 2px' }}>
|
||||||
<ShareBoardButton className="share-panel-btn" />
|
<ShareBoardButton className="share-panel-btn" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1332,6 +1358,46 @@ function CustomSharePanel() {
|
||||||
|
|
||||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
|
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
|
||||||
|
|
||||||
|
{/* Show Tutorial Button */}
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSettingsDropdown(false)
|
||||||
|
startOnboardingTour()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
background: 'none',
|
||||||
|
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'
|
||||||
|
e.currentTarget.style.borderColor = '#10b981'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'none'
|
||||||
|
e.currentTarget.style.borderColor = isDarkMode ? '#404040' : '#e5e7eb'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>🎓</span>
|
||||||
|
<span>Show Tutorial</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
|
||||||
|
|
||||||
{/* AI Models Accordion */}
|
{/* AI Models Accordion */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1530,7 +1596,7 @@ function CustomSharePanel() {
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Help/Keyboard shortcuts button - rightmost - opens Command Palette */}
|
{/* Help/Keyboard shortcuts button - rightmost - opens Command Palette */}
|
||||||
<div style={{ padding: '0 4px' }}>
|
<div className="help-button" style={{ padding: '0 4px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => openCommandPalette()}
|
onClick={() => openCommandPalette()}
|
||||||
className="share-panel-btn"
|
className="share-panel-btn"
|
||||||
|
|
@ -1595,6 +1661,7 @@ function CustomInFrontOfCanvas() {
|
||||||
<FocusLockIndicator />
|
<FocusLockIndicator />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<NetworkGraphPanel />
|
<NetworkGraphPanel />
|
||||||
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue