|
| 1 | +<script lang="ts"> |
| 2 | + import { onDestroy, onMount, tick } from 'svelte'; |
| 3 | +
|
| 4 | + interface Props { |
| 5 | + enabled?: boolean; |
| 6 | + replyText: string; |
| 7 | + onstart?: () => void; |
| 8 | + oncomplete?: () => void; |
| 9 | + oncancel?: () => void; |
| 10 | + } |
| 11 | +
|
| 12 | + let { |
| 13 | + enabled = true, |
| 14 | + replyText, |
| 15 | + onstart = () => undefined, |
| 16 | + oncomplete = () => undefined, |
| 17 | + oncancel = () => undefined, |
| 18 | + }: Props = $props(); |
| 19 | +
|
| 20 | + type Phase = 'idle' | 'reply' | 'typing' | 'send' | 'complete' | 'cancelled'; |
| 21 | +
|
| 22 | + const CODEX_CARD = '[data-thread-id="landing-demo-codex-thread"]'; |
| 23 | + const REPLY_BUTTON = `${CODEX_CARD} [data-action="reply"]`; |
| 24 | + const REPLY_COMPOSER = `${CODEX_CARD} [data-slot="review-reply-composer"]`; |
| 25 | +
|
| 26 | + let phase = $state<Phase>('idle'); |
| 27 | + let x = $state(24); |
| 28 | + let y = $state(24); |
| 29 | + let cursorVisible = $state(false); |
| 30 | + let compactPointer = $state(false); |
| 31 | + let reducedMotion = false; |
| 32 | + let outerVisible = false; |
| 33 | + let autoplayStarted = false; |
| 34 | + let userTookOver = false; |
| 35 | + let scriptOwnsInput = false; |
| 36 | + let runToken = 0; |
| 37 | + let autoTimer: ReturnType<typeof setTimeout> | undefined; |
| 38 | + let mediaQuery: MediaQueryList | undefined; |
| 39 | +
|
| 40 | + function current(token: number): boolean { |
| 41 | + return token === runToken && phase !== 'cancelled'; |
| 42 | + } |
| 43 | +
|
| 44 | + async function pause(milliseconds: number, token: number): Promise<boolean> { |
| 45 | + let remaining = milliseconds; |
| 46 | + while (remaining > 0) { |
| 47 | + if (!current(token)) return false; |
| 48 | + if (!outerVisible || document.hidden) { |
| 49 | + await new Promise<void>((resolve) => setTimeout(resolve, 60)); |
| 50 | + continue; |
| 51 | + } |
| 52 | + const slice = Math.min(remaining, 45); |
| 53 | + await new Promise<void>((resolve) => setTimeout(resolve, slice)); |
| 54 | + remaining -= slice; |
| 55 | + } |
| 56 | + return current(token); |
| 57 | + } |
| 58 | +
|
| 59 | + async function findElement<T extends Element>(selector: string, token: number): Promise<T | null> { |
| 60 | + const deadline = Date.now() + 5_000; |
| 61 | + while (Date.now() < deadline && current(token)) { |
| 62 | + const element = document.querySelector<T>(selector); |
| 63 | + if (element) return element; |
| 64 | + if (!(await pause(50, token))) return null; |
| 65 | + } |
| 66 | + return null; |
| 67 | + } |
| 68 | +
|
| 69 | + function targetPoint(element: Element): { x: number; y: number } { |
| 70 | + const rect = element.getBoundingClientRect(); |
| 71 | + return { |
| 72 | + x: Math.min(window.innerWidth - 26, Math.max(16, rect.left + Math.min(rect.width * 0.66, rect.width - 12))), |
| 73 | + y: Math.min(window.innerHeight - 26, Math.max(16, rect.top + Math.min(rect.height * 0.62, rect.height - 10))), |
| 74 | + }; |
| 75 | + } |
| 76 | +
|
| 77 | + async function pointAt(element: Element, nextPhase: Phase, token: number): Promise<boolean> { |
| 78 | + const rect = element.getBoundingClientRect(); |
| 79 | + const outside = rect.top < 74 || rect.bottom > window.innerHeight - 38; |
| 80 | + if (outside) { |
| 81 | + element.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' }); |
| 82 | + if (!(await pause(reducedMotion ? 0 : 420, token))) return false; |
| 83 | + } |
| 84 | + const point = targetPoint(element); |
| 85 | + phase = nextPhase; |
| 86 | + x = point.x; |
| 87 | + y = point.y; |
| 88 | + cursorVisible = !reducedMotion; |
| 89 | + return pause(reducedMotion ? 0 : 720, token); |
| 90 | + } |
| 91 | +
|
| 92 | + function inputValue(textarea: HTMLTextAreaElement, value: string, data: string | null): void { |
| 93 | + textarea.value = value; |
| 94 | + textarea.dispatchEvent(new InputEvent('input', { |
| 95 | + bubbles: true, |
| 96 | + data, |
| 97 | + inputType: data === null ? 'deleteContentBackward' : 'insertText', |
| 98 | + })); |
| 99 | + } |
| 100 | +
|
| 101 | + function clearScriptedDraft(): void { |
| 102 | + if (!scriptOwnsInput) return; |
| 103 | + const textarea = document.querySelector<HTMLTextAreaElement>(`${REPLY_COMPOSER} textarea`); |
| 104 | + if (textarea) inputValue(textarea, '', null); |
| 105 | + scriptOwnsInput = false; |
| 106 | + } |
| 107 | +
|
| 108 | + function cancelForVisitor(event?: Event): void { |
| 109 | + if (event && !event.isTrusted) return; |
| 110 | + userTookOver = true; |
| 111 | + runToken += 1; |
| 112 | + phase = 'cancelled'; |
| 113 | + cursorVisible = false; |
| 114 | + clearScriptedDraft(); |
| 115 | + oncancel(); |
| 116 | + } |
| 117 | +
|
| 118 | + async function runGuidedReply(manual: boolean): Promise<void> { |
| 119 | + if (!enabled || (!manual && (autoplayStarted || userTookOver))) return; |
| 120 | + autoplayStarted = true; |
| 121 | + userTookOver = false; |
| 122 | + runToken += 1; |
| 123 | + const token = runToken; |
| 124 | + phase = 'idle'; |
| 125 | + cursorVisible = false; |
| 126 | + clearScriptedDraft(); |
| 127 | + onstart(); |
| 128 | + await tick(); |
| 129 | +
|
| 130 | + const replyButton = await findElement<HTMLButtonElement>(REPLY_BUTTON, token); |
| 131 | + if (!replyButton || !(await pointAt(replyButton, 'reply', token))) return; |
| 132 | + replyButton.click(); |
| 133 | +
|
| 134 | + const textarea = await findElement<HTMLTextAreaElement>(`${REPLY_COMPOSER} textarea`, token); |
| 135 | + if (!textarea || !(await pointAt(textarea, 'typing', token))) return; |
| 136 | + scriptOwnsInput = true; |
| 137 | +
|
| 138 | + if (reducedMotion) { |
| 139 | + inputValue(textarea, replyText, replyText); |
| 140 | + } else { |
| 141 | + let draft = ''; |
| 142 | + for (const character of replyText) { |
| 143 | + if (!current(token)) return; |
| 144 | + draft += character; |
| 145 | + inputValue(textarea, draft, character); |
| 146 | + if (!(await pause(character === ' ' ? 28 : 43, token))) return; |
| 147 | + } |
| 148 | + if (!(await pause(260, token))) return; |
| 149 | + } |
| 150 | +
|
| 151 | + const sendButton = await findElement<HTMLButtonElement>(`${REPLY_COMPOSER} .rmc-btn-primary`, token); |
| 152 | + if (!sendButton || !(await pointAt(sendButton, 'send', token))) return; |
| 153 | + sendButton.click(); |
| 154 | + scriptOwnsInput = false; |
| 155 | + if (!(await pause(reducedMotion ? 0 : 580, token))) return; |
| 156 | +
|
| 157 | + phase = 'complete'; |
| 158 | + cursorVisible = false; |
| 159 | + oncomplete(); |
| 160 | + } |
| 161 | +
|
| 162 | + function scheduleAutoplay(): void { |
| 163 | + if (!enabled || autoplayStarted || userTookOver || !outerVisible) return; |
| 164 | + if (autoTimer) return; |
| 165 | + autoTimer = setTimeout(() => void runGuidedReply(false), reducedMotion ? 80 : 520); |
| 166 | + } |
| 167 | +
|
| 168 | + function handleVisibilityMessage(event: MessageEvent): void { |
| 169 | + if (event.origin !== window.location.origin) return; |
| 170 | + if (event.data?.type !== 'attn-landing-demo-visibility') return; |
| 171 | + outerVisible = event.data.visible === true; |
| 172 | + if (outerVisible) scheduleAutoplay(); |
| 173 | + } |
| 174 | +
|
| 175 | + function updatePointerMode(): void { |
| 176 | + compactPointer = mediaQuery?.matches ?? false; |
| 177 | + } |
| 178 | +
|
| 179 | + export function replay(): void { |
| 180 | + outerVisible = true; |
| 181 | + void runGuidedReply(true); |
| 182 | + } |
| 183 | +
|
| 184 | + onMount(() => { |
| 185 | + reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 186 | + mediaQuery = window.matchMedia('(max-width: 680px)'); |
| 187 | + updatePointerMode(); |
| 188 | + mediaQuery.addEventListener('change', updatePointerMode); |
| 189 | + window.addEventListener('message', handleVisibilityMessage); |
| 190 | + window.addEventListener('pointerdown', cancelForVisitor, true); |
| 191 | + window.addEventListener('keydown', cancelForVisitor, true); |
| 192 | + window.addEventListener('wheel', cancelForVisitor, { capture: true, passive: true }); |
| 193 | +
|
| 194 | + if (window.parent === window) { |
| 195 | + outerVisible = true; |
| 196 | + scheduleAutoplay(); |
| 197 | + } else { |
| 198 | + window.parent.postMessage({ type: 'attn-landing-demo-ready' }, window.location.origin); |
| 199 | + } |
| 200 | + }); |
| 201 | +
|
| 202 | + onDestroy(() => { |
| 203 | + if (autoTimer) clearTimeout(autoTimer); |
| 204 | + runToken += 1; |
| 205 | + mediaQuery?.removeEventListener('change', updatePointerMode); |
| 206 | + window.removeEventListener('message', handleVisibilityMessage); |
| 207 | + window.removeEventListener('pointerdown', cancelForVisitor, true); |
| 208 | + window.removeEventListener('keydown', cancelForVisitor, true); |
| 209 | + window.removeEventListener('wheel', cancelForVisitor, true); |
| 210 | + }); |
| 211 | +</script> |
| 212 | + |
| 213 | +<div |
| 214 | + class="guided-demo-cursor" |
| 215 | + class:is-visible={cursorVisible} |
| 216 | + data-slot="guided-demo-cursor" |
| 217 | + data-phase={phase} |
| 218 | + data-pointer={compactPointer ? 'tap' : 'cursor'} |
| 219 | + style={`--guided-x: ${x}px; --guided-y: ${y}px`} |
| 220 | + aria-hidden="true" |
| 221 | +> |
| 222 | + {#if compactPointer} |
| 223 | + <span class="tap-point"></span> |
| 224 | + {:else} |
| 225 | + <span class="cursor-point"></span> |
| 226 | + {/if} |
| 227 | + <span class="cursor-label">Demo</span> |
| 228 | +</div> |
| 229 | + |
| 230 | +<style> |
| 231 | + .guided-demo-cursor { |
| 232 | + position: fixed; |
| 233 | + z-index: 80; |
| 234 | + top: 0; |
| 235 | + left: 0; |
| 236 | + display: flex; |
| 237 | + align-items: center; |
| 238 | + gap: 0.32rem; |
| 239 | + opacity: 0; |
| 240 | + pointer-events: none; |
| 241 | + transform: translate3d(var(--guided-x), var(--guided-y), 0); |
| 242 | + transition: |
| 243 | + transform 680ms cubic-bezier(0.16, 1, 0.3, 1), |
| 244 | + opacity 160ms ease; |
| 245 | + will-change: transform, opacity; |
| 246 | + } |
| 247 | +
|
| 248 | + .guided-demo-cursor.is-visible { opacity: 1; } |
| 249 | +
|
| 250 | + .cursor-point { |
| 251 | + width: 17px; |
| 252 | + height: 22px; |
| 253 | + flex: none; |
| 254 | + background: var(--foreground); |
| 255 | + clip-path: polygon(0 0, 0 100%, 5px 15px, 10px 22px, 13px 20px, 8px 13px, 17px 13px); |
| 256 | + filter: drop-shadow(0 1px 0 var(--background)); |
| 257 | + transform: translate(-2px, -2px); |
| 258 | + } |
| 259 | +
|
| 260 | + .tap-point { |
| 261 | + width: 24px; |
| 262 | + height: 24px; |
| 263 | + flex: none; |
| 264 | + border: 2px solid var(--foreground); |
| 265 | + border-radius: 50%; |
| 266 | + background: color-mix(in oklch, var(--background) 68%, transparent); |
| 267 | + } |
| 268 | +
|
| 269 | + .guided-demo-cursor[data-phase='reply'] .cursor-point, |
| 270 | + .guided-demo-cursor[data-phase='send'] .cursor-point, |
| 271 | + .guided-demo-cursor[data-phase='reply'] .tap-point, |
| 272 | + .guided-demo-cursor[data-phase='send'] .tap-point { |
| 273 | + animation: guided-click 520ms ease-out 520ms both; |
| 274 | + } |
| 275 | +
|
| 276 | + .cursor-label { |
| 277 | + padding: 0.22rem 0.38rem; |
| 278 | + border-radius: 2px; |
| 279 | + background: var(--foreground); |
| 280 | + color: var(--background); |
| 281 | + font: 750 0.65rem/1 var(--font-sans, sans-serif); |
| 282 | + letter-spacing: 0.025em; |
| 283 | + } |
| 284 | +
|
| 285 | + @keyframes guided-click { |
| 286 | + 0%, 100% { transform: scale(1); } |
| 287 | + 42% { transform: scale(0.78); } |
| 288 | + } |
| 289 | +
|
| 290 | + @media (prefers-reduced-motion: reduce) { |
| 291 | + .guided-demo-cursor { display: none; } |
| 292 | + } |
| 293 | +</style> |
0 commit comments