diff --git a/website/app/globals.css b/website/app/globals.css index c133907..f1c5344 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -361,7 +361,7 @@ button:disabled { width: 100%; } -.laser-strokes.fading { +.laser-stroke-group.fading { animation: laser-fade 900ms ease-out forwards; } @@ -373,30 +373,35 @@ button:disabled { .laser-stroke.outer { stroke: var(--laser-outer); - stroke-width: 22; + stroke-width: 14; } .laser-stroke.main { stroke: var(--laser-main); - stroke-width: 11; + stroke-width: 6; } .laser-stroke.middle { stroke: var(--laser-middle); - stroke-width: 7; + stroke-width: 4; } .laser-stroke.inner { stroke: var(--laser-inner); - stroke-width: 3.4; + stroke-width: 2; } .laser-cursor { background: color-mix(in srgb, var(--laser-main), transparent 82%); border-radius: 999px; - height: 42px; + height: 32px; transform: translate(-50%, -50%); - width: 42px; + transition: opacity 420ms ease-out; + width: 32px; +} + +.laser-cursor.fading { + opacity: 0; } .laser-cursor::before, @@ -410,20 +415,20 @@ button:disabled { } .laser-cursor::before { - background: radial-gradient( - circle, - var(--laser-inner) 0 33%, - var(--laser-middle) 34% 48%, - var(--laser-main) 50% 100% - ); - height: 22px; - width: 22px; + background: var(--laser-main); + height: 16px; + width: 16px; } .laser-cursor::after { - background: var(--laser-inner); - height: 14px; - width: 14px; + background: radial-gradient( + circle, + var(--laser-inner) 0 38%, + var(--laser-middle) 40% 68%, + var(--laser-main) 70% 100% + ); + height: 13px; + width: 13px; } .pulse { diff --git a/website/app/page.tsx b/website/app/page.tsx index 0674604..011f20f 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,10 +2,24 @@ import { PointerEvent, useEffect, useMemo, useRef, useState } from "react"; -type ClickKind = "press" | "release" | "right" | "rightRelease" | "middle" | "middleRelease" | "drag"; +type ClickKind = + | "press" + | "release" + | "right" + | "rightRelease" + | "middle" + | "middleRelease" + | "drag"; type ThemeName = "blue" | "amber" | "red"; type ProfileName = "Default" | "Tutorial" | "Presentation"; -type ToggleKey = "press" | "release" | "right" | "middle" | "drag" | "laser" | "keys"; +type ToggleKey = + | "press" + | "release" + | "right" + | "middle" + | "drag" + | "laser" + | "keys"; type DemoSettings = Record & { pulseDuration: number; @@ -37,6 +51,15 @@ type TrailPoint = { y: number; }; +type Stroke = { + id: number; + points: TrailPoint[]; +}; + +const LASER_CURSOR_FADE_MS = 420; +const LASER_STROKE_FADE_MS = 900; +const LASER_MIN_POINT_DISTANCE = 2.5; + const profiles: Record = { Default: { press: true, @@ -110,17 +133,19 @@ const themePalettes: Record = { }; let nextAnimationId = 0; -const installCommand = "brew install --cask aurorascharff/clicklight/clicklight"; +const installCommand = + "brew install --cask aurorascharff/clicklight/clicklight"; export default function Home() { const [settings, setSettings] = useState(profiles.Default); const [profile, setProfile] = useState("Default"); const [pulses, setPulses] = useState([]); - const [trail, setTrail] = useState([]); + const [activeStroke, setActiveStroke] = useState(null); + const [fadingStrokes, setFadingStrokes] = useState([]); const [laserCursor, setLaserCursor] = useState(null); + const [laserCursorFading, setLaserCursorFading] = useState(false); const [shortcut, setShortcut] = useState(null); const [copiedInstall, setCopiedInstall] = useState(false); - const [isPointerActive, setIsPointerActive] = useState(false); const surfaceRef = useRef(null); const pointerDownRef = useRef(false); const downPointRef = useRef(null); @@ -128,8 +153,13 @@ export default function Home() { const hasDraggedRef = useRef(false); const pressedKindRef = useRef("press"); const shortcutTimeoutRef = useRef(null); + const cursorFadeTimeoutRef = useRef(null); + const cursorRemoveTimeoutRef = useRef(null); - const palette = useMemo(() => themePalettes[settings.theme], [settings.theme]); + const palette = useMemo( + () => themePalettes[settings.theme], + [settings.theme], + ); useEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -140,18 +170,27 @@ export default function Home() { if (event.ctrlKey) parts.push("⌃"); if (event.altKey) parts.push("⌥"); if (event.shiftKey) parts.push("⇧"); - const key = event.key.length === 1 ? event.key.toUpperCase() : event.key.replace("Arrow", ""); - if (!["Meta", "Control", "Alt", "Shift"].includes(event.key)) parts.push(key); + const key = + event.key.length === 1 + ? event.key.toUpperCase() + : event.key.replace("Arrow", ""); + if (!["Meta", "Control", "Alt", "Shift"].includes(event.key)) + parts.push(key); if (parts.length === 0) return; setShortcut(parts.join(" ")); - if (shortcutTimeoutRef.current) window.clearTimeout(shortcutTimeoutRef.current); - shortcutTimeoutRef.current = window.setTimeout(() => setShortcut(null), 900); + if (shortcutTimeoutRef.current) + window.clearTimeout(shortcutTimeoutRef.current); + shortcutTimeoutRef.current = window.setTimeout( + () => setShortcut(null), + 900, + ); } window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); - if (shortcutTimeoutRef.current) window.clearTimeout(shortcutTimeoutRef.current); + if (shortcutTimeoutRef.current) + window.clearTimeout(shortcutTimeoutRef.current); }; }, [settings.keys]); @@ -177,9 +216,10 @@ export default function Home() { function handlePointerDown(event: PointerEvent) { event.currentTarget.setPointerCapture(event.pointerId); pointerDownRef.current = true; - setIsPointerActive(true); const downPoint = pointFromEvent(event); - downPointRef.current = downPoint ? { id: nextAnimationId++, ...downPoint } : null; + downPointRef.current = downPoint + ? { id: nextAnimationId++, ...downPoint } + : null; lastDragPointRef.current = downPointRef.current; hasDraggedRef.current = false; @@ -199,6 +239,38 @@ export default function Home() { if (settings.press) addPulse(event, "press"); } + function clearCursorTimers() { + if (cursorFadeTimeoutRef.current) { + window.clearTimeout(cursorFadeTimeoutRef.current); + cursorFadeTimeoutRef.current = null; + } + if (cursorRemoveTimeoutRef.current) { + window.clearTimeout(cursorRemoveTimeoutRef.current); + cursorRemoveTimeoutRef.current = null; + } + } + + function bumpLaserCursor(point: TrailPoint) { + clearCursorTimers(); + setLaserCursor(point); + setLaserCursorFading(false); + cursorFadeTimeoutRef.current = window.setTimeout(() => { + setLaserCursorFading(true); + cursorRemoveTimeoutRef.current = window.setTimeout(() => { + setLaserCursor(null); + setLaserCursorFading(false); + }, LASER_CURSOR_FADE_MS); + }, 16); + } + + function clearLaserVisuals() { + clearCursorTimers(); + setLaserCursor(null); + setLaserCursorFading(false); + setActiveStroke(null); + setFadingStrokes([]); + } + function handlePointerMove(event: PointerEvent) { const point = pointFromEvent(event); if (!point) return; @@ -209,8 +281,16 @@ export default function Home() { if (distance > 4) hasDraggedRef.current = true; } const lastDragPoint = lastDragPointRef.current; - if (pointerDownRef.current && hasDraggedRef.current && settings.drag && lastDragPoint) { - const dragDistance = Math.hypot(point.x - lastDragPoint.x, point.y - lastDragPoint.y); + if ( + pointerDownRef.current && + hasDraggedRef.current && + settings.drag && + lastDragPoint + ) { + const dragDistance = Math.hypot( + point.x - lastDragPoint.x, + point.y - lastDragPoint.y, + ); if (!settings.laser && dragDistance > 18) { addPulse(event, "drag"); lastDragPointRef.current = nextPoint; @@ -219,9 +299,21 @@ export default function Home() { } } if (!settings.laser) return; - setLaserCursor(nextPoint); + bumpLaserCursor(nextPoint); if (pointerDownRef.current) { - setTrail((current) => [...current.slice(-34), nextPoint]); + setActiveStroke((current) => { + if (!current) { + return { id: nextAnimationId++, points: [nextPoint, nextPoint] }; + } + const last = current.points[current.points.length - 1]; + if ( + Math.hypot(last.x - nextPoint.x, last.y - nextPoint.y) < + LASER_MIN_POINT_DISTANCE + ) { + return current; + } + return { ...current, points: [...current.points, nextPoint] }; + }); } } @@ -229,16 +321,27 @@ export default function Home() { if (hasDraggedRef.current && settings.drag) addPulse(event, "drag"); if (settings.release) { if (pressedKindRef.current === "right") addPulse(event, "rightRelease"); - else if (pressedKindRef.current === "middle") addPulse(event, "middleRelease"); + else if (pressedKindRef.current === "middle") + addPulse(event, "middleRelease"); else addPulse(event, "release"); } + + const stroke = activeStroke; + setActiveStroke(null); + if (stroke && stroke.points.length >= 2) { + setFadingStrokes((strokes) => [...strokes, stroke]); + window.setTimeout(() => { + setFadingStrokes((strokes) => + strokes.filter((item) => item.id !== stroke.id), + ); + }, LASER_STROKE_FADE_MS); + } + resetPointerState(); - window.setTimeout(() => setTrail([]), 900); } function resetPointerState() { pointerDownRef.current = false; - setIsPointerActive(false); downPointRef.current = null; lastDragPointRef.current = null; hasDraggedRef.current = false; @@ -247,13 +350,28 @@ export default function Home() { function updateProfile(nextProfile: ProfileName) { setProfile(nextProfile); setSettings(profiles[nextProfile]); - setTrail([]); - setLaserCursor(null); + clearLaserVisuals(); setShortcut(null); } function toggle(key: ToggleKey) { - setSettings((current) => ({ ...current, [key]: !current[key] })); + setSettings((current) => { + const next = { ...current, [key]: !current[key] }; + if (key === "laser" && !next.laser) { + clearLaserVisuals(); + } + return next; + }); + } + + function handlePointerLeave() { + if (pointerDownRef.current) return; + clearCursorTimers(); + setLaserCursorFading(true); + cursorRemoveTimeoutRef.current = window.setTimeout(() => { + setLaserCursor(null); + setLaserCursorFading(false); + }, LASER_CURSOR_FADE_MS); } async function copyInstallCommand() { @@ -291,10 +409,15 @@ export default function Home() { onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={resetPointerState} + onPointerLeave={handlePointerLeave} >

- A tiny macOS menu bar app that highlights your clicks during demos, screen sharing, - UX reviews, and anywhere people need to follow what you are doing. + A tiny macOS menu bar app that highlights your clicks during demos, + screen sharing, UX reviews, and anywhere people need to follow what + you are doing.

{installCommand} @@ -322,7 +446,11 @@ export default function Home() { className={copiedInstall ? "copied" : ""} type="button" onClick={copyInstallCommand} - aria-label={copiedInstall ? "Copied install command" : "Copy install command"} + aria-label={ + copiedInstall + ? "Copied install command" + : "Copy install command" + } > {copiedInstall ? (
-