Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | 1x 1x 1x 1x 1x 1x 1x 7x 7x 7x 7x 7x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 10x 10x 6x 10x 10x 31x 31x 9x 9x 9x 9x 9x 9x 9x 8x 9x 9x 9x 31x 31x 4x 4x 4x 1x 1x 4x 4x 4x 4x 4x 4x 31x 31x 31x 20x 20x 20x 20x 7x 31x 31x 31x 20x 20x 31x 31x 31x 31x 31x 31x 20x 20x 31x 31x 31x 31x 14x 31x 31x 31x 31x 31x 31x 14x 31x 14x 31x 14x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 14x 14x 14x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x | 'use client'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {usePathname, useRouter} from 'next/navigation'
import {stepsFor, type TourFeatures, type TourStep} from './steps'
import {DEMO} from '@/lib/demo'
/** Start the tour from anywhere (the menu, the help page). */
export function startTour() {
window.dispatchEvent(new Event('ms:tour'))
}
const KEY = 'ms:tour-step'
// The public demo can't save that someone took the tour: their browser
// remembers instead, so it opens once per visitor, not on every page load
const DEMO_DONE = 'ms:tour-done'
function doneHere() {
try {
return DEMO && localStorage.getItem(DEMO_DONE) === '1'
} catch {
return false
}
}
/**
* A walk through the app on the real screens: the screen dims except the
* thing being explained, with a card pointing at it. Shown once, the first
* time someone signs in; "Take the tour" (menu, Help) or ?tour on any page
* starts it again.
*/
export default function Tour({
auto,
features = {scheduling: true},
}: {
auto: boolean
/** The band's switchable tools: steps about ones it has off are left out */
features?: TourFeatures
}) {
const STEPS = useMemo(() => stepsFor(features), [features])
const router = useRouter()
const pathname = usePathname() ?? '/'
const [step, setStep] = useState<number | null>(null)
const [rect, setRect] = useState<DOMRect | null>(null)
const [missing, setMissing] = useState(false)
// Survives the page changes the tour itself makes
const go = useCallback((n: number | null) => {
try {
if (n === null) sessionStorage.removeItem(KEY)
else sessionStorage.setItem(KEY, String(n))
} catch {
// private mode: the tour still runs on this page
}
setStep(n)
}, [])
useEffect(() => {
let saved: number | null = null
try {
const v = sessionStorage.getItem(KEY)
saved = v === null ? null : Number(v)
} catch {}
const asked = new URLSearchParams(window.location.search).has('tour')
if (saved !== null && !Number.isNaN(saved)) setStep(saved)
else if (asked || (auto && pathname === '/songs' && !doneHere())) go(0)
const start = () => {
if (window.location.pathname !== '/songs') router.push('/songs')
go(0)
}
window.addEventListener('ms:tour', start)
return () => window.removeEventListener('ms:tour', start)
// Once per mount: later page changes are the tour's own
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const finish = useCallback(() => {
go(null)
if (DEMO)
try {
localStorage.setItem(DEMO_DONE, '1')
} catch {}
// Back where it started: the band's own songs (not a sample page, or
// wherever the last steps were)
if (window.location.pathname !== '/songs') router.push('/songs')
fetch('/api/account/settings', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tourDone: true}),
}).catch(() => {})
}, [go, router])
const current: TourStep | null = step === null ? null : (STEPS[step] ?? null)
// Find what the step points at (it may still be rendering), bring it into
// view, and follow it as the page moves
useLayoutEffect(() => {
setRect(null)
setMissing(false)
if (!current) return
if (current.path && !current.path.test(pathname)) return
if (!current.target) return
let tries = 0
let el: Element | null = null
let follow: ReturnType<typeof setInterval> | undefined
let opening: ReturnType<typeof setTimeout> | undefined
const find = () => visible(current.target!)
// The thing, and what it opened (a chord's diagram), lit up together
const measure = () => {
if (!el) return
const r = el.getBoundingClientRect()
const extra = current.also ? visible(current.also) : null
setRect((old) => {
const next = extra ? union(r, extra.getBoundingClientRect()) : r
return old && same(old, next) ? old : next
})
}
const timer = setInterval(() => {
el = find()
if (el) {
clearInterval(timer)
// Taller than most of the screen (the editor): from its top, clear
// of the sticky header
const r = el.getBoundingClientRect()
if (r.height > window.innerHeight * 0.6)
window.scrollBy({top: r.top - 88, behavior: 'instant'})
else el.scrollIntoView({block: 'center', behavior: 'instant'})
measure()
// Show it working: open it once the page has stopped moving (a
// scroll puts a chord's diagram away)
if (current.open)
opening = setTimeout(() => {
const o = visible(current.open!)
if (o instanceof HTMLElement) o.click()
}, 200)
// What opens grows as it draws
follow = setInterval(measure, 150)
} else if (++tries > 25) {
clearInterval(timer)
setMissing(true)
}
}, 120)
window.addEventListener('resize', measure)
window.addEventListener('scroll', measure, true)
return () => {
clearInterval(timer)
clearInterval(follow)
clearTimeout(opening)
window.removeEventListener('resize', measure)
window.removeEventListener('scroll', measure, true)
}
}, [current, pathname])
// A step whose thing isn't on this page (a song with no notation): skip
// it, the way you were going
const heading = useRef<1 | -1>(1)
useEffect(() => {
if (!missing || step === null) return
const n = step + heading.current
go(n >= 0 && n < STEPS.length ? n : null)
}, [missing, step, go, STEPS.length])
const next = () => {
if (step === null) return
heading.current = 1
let n = step + 1
// Steps on this page whose thing isn't here (no notation in this song)
while (
n < STEPS.length &&
STEPS[n].target &&
!STEPS[n].from &&
!STEPS[n].href &&
(!STEPS[n].path || STEPS[n].path!.test(pathname)) &&
!visible(STEPS[n].target!)
)
n++
if (n >= STEPS.length) return finish()
const to = STEPS[n]
// The next step is on another page: follow the link it names
if (to.path && !to.path.test(pathname)) {
const href =
to.href ?? (to.from && visible(to.from)?.getAttribute('href'))
if (href) router.push(href)
}
go(n)
}
const back = () => {
if (!step) return
heading.current = -1
let n = step - 1
// Back past steps on this page whose thing isn't here (no notation)
while (
n > 0 &&
STEPS[n].target &&
(!STEPS[n].path || STEPS[n].path!.test(pathname)) &&
!visible(STEPS[n].target!)
)
n--
const to = STEPS[n]
if (to.path && !to.path.test(pathname)) router.back()
go(n)
}
// The card's real height, to tell whether it fits beside what it points at
const card = useRef<HTMLDivElement>(null)
const [cardH, setCardH] = useState(240)
useLayoutEffect(() => {
const h = card.current?.offsetHeight
if (h && h !== cardH) setCardH(h)
}, [step, rect, cardH])
if (!current || step === null) return null
if (current.path && !current.path.test(pathname)) return null
if (current.target && !rect) return null
const pad = 8
const hole = rect && {
x: rect.left - pad,
y: rect.top - pad,
w: rect.width + pad * 2,
h: rect.height + pad * 2,
}
// The card goes below what it points at, or above it; when neither has
// room (a tall editor), over its lower part, so Next stays on screen
const vw = typeof window === 'undefined' ? 400 : window.innerWidth
const vh = typeof window === 'undefined' ? 800 : window.innerHeight
const width = Math.min(340, vw - 24)
const gap = 14
const place = !hole
? 'center'
: hole.y + hole.h + gap + cardH <= vh - 12
? 'below'
: hole.y - gap - cardH >= 12
? 'above'
: 'over'
const left = hole
? Math.min(Math.max(12, hole.x + hole.w / 2 - width / 2), vw - width - 12)
: (vw - width) / 2
const arrowX = hole
? Math.min(Math.max(20, hole.x + hole.w / 2 - left), width - 20)
: 0
return (
<div className="fixed inset-0 z-[70]" role="dialog" aria-label="Tour">
<svg className="absolute inset-0 h-full w-full" aria-hidden>
<defs>
<mask id="tour-hole">
<rect width="100%" height="100%" fill="white" />
{hole && (
<rect
x={hole.x}
y={hole.y}
width={hole.w}
height={hole.h}
rx={12}
fill="black"
/>
)}
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="rgba(10,10,12,0.72)"
mask="url(#tour-hole)"
/>
{hole && (
<rect
x={hole.x}
y={hole.y}
width={hole.w}
height={hole.h}
rx={12}
fill="none"
stroke="var(--color-amber)"
strokeWidth={3}
/>
)}
</svg>
<div
ref={card}
className="absolute rounded-2xl border border-line-2 bg-panel p-4 text-text shadow-2xl"
style={{
width,
left,
...(place === 'below'
? {top: hole!.y + hole!.h + gap}
: place === 'above'
? {bottom: vh - hole!.y + gap}
: place === 'over'
? {bottom: 16}
: {top: '50%', transform: 'translateY(-50%)'}),
}}
>
{(place === 'below' || place === 'above') && (
<span
aria-hidden
className="absolute h-3 w-3 rotate-45 border-line-2 bg-panel"
style={{
left: arrowX - 6,
...(place === 'below'
? {top: -7, borderLeftWidth: 1, borderTopWidth: 1}
: {bottom: -7, borderRightWidth: 1, borderBottomWidth: 1}),
}}
/>
)}
<p className="text-[11px] font-bold uppercase tracking-[0.15em] text-sky">
{step + 1} of {STEPS.length}
</p>
<h2 className="mt-1 text-lg font-extrabold">{current.title}</h2>
<p className="mt-1.5 text-[15px] leading-snug text-muted">
{current.body}
</p>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
onClick={finish}
className="min-h-11 px-2 text-sm text-muted underline"
>
{step + 1 === STEPS.length ? 'Close' : 'Skip tour'}
</button>
<span className="flex-1" />
{step > 0 && (
<button
type="button"
onClick={back}
className="min-h-11 rounded-lg border border-line-2 px-4"
>
Back
</button>
)}
<button
type="button"
onClick={next}
className="min-h-11 rounded-lg bg-accent px-5 font-bold text-on-accent"
>
{step + 1 === STEPS.length ? 'Done' : 'Next'}
</button>
</div>
</div>
</div>
)
}
function union(a: DOMRect, b: DOMRect) {
const left = Math.min(a.left, b.left)
const top = Math.min(a.top, b.top)
return new DOMRect(
left,
top,
Math.max(a.right, b.right) - left,
Math.max(a.bottom, b.bottom) - top,
)
}
function same(a: DOMRect, b: DOMRect) {
return (
a.left === b.left &&
a.top === b.top &&
a.width === b.width &&
a.height === b.height
)
}
/**
* The first match that's actually on screen (the header has two navs);
* several selectors are tried in order.
*/
function visible(selectors: string | string[]) {
for (const selector of Array.isArray(selectors) ? selectors : [selectors])
for (const el of Array.from(document.querySelectorAll(selector))) {
const r = el.getBoundingClientRect()
if (r.width > 0 && r.height > 0) return el
}
return null
}
|