|
460 | 460 | }); |
461 | 461 | } |
462 | 462 |
|
463 | | - function styleTransient(element, zIndex) { |
464 | | - element.setAttribute("aria-hidden", "true"); |
465 | | - setImportant(element, "position", "fixed"); |
466 | | - setImportant(element, "display", "block"); |
467 | | - setImportant(element, "margin", "0"); |
468 | | - setImportant(element, "padding", "0"); |
469 | | - setImportant(element, "pointer-events", "none"); |
470 | | - setImportant(element, "z-index", zIndex); |
471 | | - setImportant(element, "box-sizing", "border-box"); |
472 | | - } |
473 | | - |
474 | | - function removeAfterAnimation(element, animation, fallbackMs) { |
475 | | - animation.addEventListener("finish", () => element.remove(), { once: true }); |
476 | | - animation.addEventListener("cancel", () => element.remove(), { once: true }); |
477 | | - window.setTimeout(() => element.remove(), fallbackMs); |
478 | | - } |
479 | | - |
480 | | - function ripple(flash = false) { |
481 | | - if (!ensure()) { |
482 | | - return false; |
483 | | - } |
484 | | - const root = mountRoot(); |
485 | | - if (!root) { |
486 | | - return false; |
487 | | - } |
488 | | - |
489 | | - const click = CFG.click || {}; |
490 | | - const ringColor = click.color || "rgba(37, 99, 235, .9)"; |
491 | | - const endScale = Number.isFinite(Number(click.scale)) ? Number(click.scale) : 3.25; |
492 | | - |
493 | | - const ring = document.createElement("div"); |
494 | | - ring.setAttribute("data-guidebot-ripple", ""); |
495 | | - styleTransient(ring, "2147483646"); |
496 | | - setImportant(ring, "left", `${state.x - 8}px`); |
497 | | - setImportant(ring, "top", `${state.y - 8}px`); |
498 | | - setImportant(ring, "width", "16px"); |
499 | | - setImportant(ring, "height", "16px"); |
500 | | - setImportant(ring, "border", `3px solid ${ringColor}`); |
501 | | - setImportant(ring, "border-radius", "9999px"); |
502 | | - root.appendChild(ring); |
503 | | - |
504 | | - const animation = ring.animate( |
505 | | - [ |
506 | | - { opacity: 0.95, transform: "scale(.35)" }, |
507 | | - { opacity: 0, transform: `scale(${endScale})` }, |
508 | | - ], |
509 | | - { duration: 500, easing: "cubic-bezier(.16,1,.3,1)", fill: "forwards" }, |
510 | | - ); |
511 | | - removeAfterAnimation(ring, animation, 600); |
512 | | - |
513 | | - if (flash && click.flash) { |
514 | | - const disc = document.createElement("div"); |
515 | | - disc.setAttribute("data-guidebot-flash", ""); |
516 | | - styleTransient(disc, "2147483645"); |
517 | | - setImportant(disc, "left", `${state.x - 8}px`); |
518 | | - setImportant(disc, "top", `${state.y - 8}px`); |
519 | | - setImportant(disc, "width", "16px"); |
520 | | - setImportant(disc, "height", "16px"); |
521 | | - setImportant(disc, "background", ringColor); |
522 | | - setImportant(disc, "border-radius", "9999px"); |
523 | | - root.appendChild(disc); |
524 | | - |
525 | | - const flashAnimation = disc.animate( |
526 | | - [ |
527 | | - { opacity: 0.55, transform: "scale(.2)" }, |
528 | | - { opacity: 0, transform: "scale(2)" }, |
529 | | - ], |
530 | | - { duration: 420, easing: "cubic-bezier(.16,1,.3,1)", fill: "forwards" }, |
531 | | - ); |
532 | | - removeAfterAnimation(disc, flashAnimation, 520); |
533 | | - } |
534 | | - |
535 | | - return true; |
536 | | - } |
537 | | - |
538 | | - /** |
539 | | - * Lap an ellipse around a target, drawing a marker trail behind the cursor. |
540 | | - * |
541 | | - * This is what the `highlight` scenario command animates. It has nothing to do |
542 | | - * with the `highlight()` function below, which draws a one-off pulsing |
543 | | - * rectangle and survives only for API compatibility: the names collide, the |
544 | | - * features do not. |
545 | | - * |
546 | | - * The loop mirrors moveTo's on purpose: progress comes from the clock (a |
547 | | - * dropped frame must not desynchronize us from the duration Python treats as |
548 | | - * authoritative), the handle lives in `state.raf` so `cancelMove()` can preempt |
549 | | - * us, and a fallback timer settles the promise even when rAF stops firing in a |
550 | | - * backgrounded document — without it a recording would hang forever. |
551 | | - */ |
552 | | - /** |
553 | | - * Angle lookup that walks an ellipse at a CONSTANT SPEED. |
554 | | - * |
555 | | - * Stepping the angle linearly would not: on an ellipse, equal angles cover |
556 | | - * wildly unequal arcs (at rx/ry = 8 the cursor crawls around the ends and |
557 | | - * bolts along the flat runs, ~5x apart), and the trail — which is drawn by |
558 | | - * arc length via stroke-dashoffset — would come unstuck from the cursor |
559 | | - * mid-lap. Sampling cumulative chord length once and inverting it keeps both |
560 | | - * on the same clock. |
561 | | - */ |
562 | | - function arcLengthTable(rx, ry, samples = ENCIRCLE_ARC_SAMPLES) { |
563 | | - const cumulative = new Float64Array(samples + 1); |
564 | | - let previousX = rx; |
565 | | - let previousY = 0; |
566 | | - for (let i = 1; i <= samples; i++) { |
567 | | - const angle = (i / samples) * 2 * Math.PI; |
568 | | - const x = rx * Math.cos(angle); |
569 | | - const y = ry * Math.sin(angle); |
570 | | - cumulative[i] = cumulative[i - 1] + Math.hypot(x - previousX, y - previousY); |
571 | | - previousX = x; |
572 | | - previousY = y; |
573 | | - } |
574 | | - const total = cumulative[samples]; |
575 | | - return (fraction) => { |
576 | | - if (!(total > 0)) { |
577 | | - return fraction * 2 * Math.PI; |
578 | | - } |
579 | | - const wanted = fraction * total; |
580 | | - let low = 0; |
581 | | - let high = samples; |
582 | | - while (low + 1 < high) { |
583 | | - const mid = (low + high) >> 1; |
584 | | - if (cumulative[mid] <= wanted) { |
585 | | - low = mid; |
586 | | - } else { |
587 | | - high = mid; |
588 | | - } |
589 | | - } |
590 | | - const span = cumulative[high] - cumulative[low]; |
591 | | - const within = span > 0 ? (wanted - cumulative[low]) / span : 0; |
592 | | - return ((low + within) / samples) * 2 * Math.PI; |
593 | | - }; |
594 | | - } |
595 | | - |
596 | | - function encircle(cx, cy, rx, ry, options = {}) { |
597 | | - const centreX = Number(cx); |
598 | | - const centreY = Number(cy); |
599 | | - const radiusX = Number(rx); |
600 | | - const radiusY = Number(ry); |
601 | | - if ( |
602 | | - ![centreX, centreY, radiusX, radiusY].every(Number.isFinite) || |
603 | | - radiusX <= 0 || |
604 | | - radiusY <= 0 |
605 | | - ) { |
606 | | - throw new TypeError("encircle needs a finite centre and positive radii"); |
607 | | - } |
608 | | - const laps = Math.max(1, Math.round(Number(options.loops) || 1)); |
609 | | - const msPerLap = Math.max(1, Number(options.msPerLap) || 900); |
610 | | - const holdMs = Math.max(0, Number(options.holdMs) || 0); |
611 | | - const color = options.color || CURSOR_GLOW; |
612 | | - |
613 | | - cancelMove(); |
614 | | - const cursor = ensure(); |
615 | | - const root = mountRoot(); |
616 | | - // The lap starts and ends at 3 o'clock — where an SVG ellipse path also |
617 | | - // starts — so the trail unrolls exactly under the cursor. |
618 | | - const entryX = centreX + radiusX; |
619 | | - state.x = entryX; |
620 | | - state.y = centreY; |
621 | | - if (!cursor || !root) { |
622 | | - scheduleMount(); |
623 | | - return Promise.resolve(); |
624 | | - } |
625 | | - |
626 | | - const svg = document.createElementNS(SVG_NS, "svg"); |
627 | | - svg.setAttribute("aria-hidden", "true"); |
628 | | - setImportant(svg, "position", "fixed"); |
629 | | - setImportant(svg, "left", "0px"); |
630 | | - setImportant(svg, "top", "0px"); |
631 | | - setImportant(svg, "width", "100%"); |
632 | | - setImportant(svg, "height", "100%"); |
633 | | - setImportant(svg, "overflow", "visible"); |
634 | | - setImportant(svg, "pointer-events", "none"); |
635 | | - setImportant(svg, "z-index", "2147483645"); |
636 | | - |
637 | | - const trail = document.createElementNS(SVG_NS, "ellipse"); |
638 | | - trail.setAttribute("data-guidebot-encircle", ""); |
639 | | - trail.setAttribute("cx", centreX); |
640 | | - trail.setAttribute("cy", centreY); |
641 | | - trail.setAttribute("rx", radiusX); |
642 | | - trail.setAttribute("ry", radiusY); |
643 | | - trail.setAttribute("fill", "none"); |
644 | | - trail.setAttribute("stroke", color); |
645 | | - trail.setAttribute("stroke-width", ENCIRCLE_STROKE_WIDTH); |
646 | | - trail.setAttribute("stroke-linecap", "round"); |
647 | | - svg.appendChild(trail); |
648 | | - root.appendChild(svg); |
649 | | - |
650 | | - const perimeter = |
651 | | - typeof trail.getTotalLength === "function" |
652 | | - ? trail.getTotalLength() |
653 | | - : Math.PI * (radiusX + radiusY); |
654 | | - trail.style.strokeDasharray = `${perimeter}`; |
655 | | - trail.style.strokeDashoffset = `${perimeter}`; |
656 | | - |
657 | | - setImportant(cursor, "transition", "none"); |
658 | | - const spinMs = laps * msPerLap; |
659 | | - const angleAt = arcLengthTable(radiusX, radiusY); |
660 | | - |
661 | | - return new Promise((resolve) => { |
662 | | - let start = null; |
663 | | - let fallbackTimer = 0; |
664 | | - |
665 | | - const settle = () => { |
666 | | - window.clearTimeout(fallbackTimer); |
667 | | - state.raf = null; |
668 | | - state.finishMove = null; |
669 | | - const fade = svg.animate([{ opacity: 1 }, { opacity: 0 }], { |
670 | | - duration: ENCIRCLE_FADE_MS, |
671 | | - easing: "ease-out", |
672 | | - fill: "forwards", |
673 | | - }); |
674 | | - removeAfterAnimation(svg, fade, ENCIRCLE_FADE_MS + 100); |
675 | | - // Resolve only once the trail has faded, so the next step never opens on |
676 | | - // a frame that still carries this one's mark. |
677 | | - window.setTimeout(resolve, ENCIRCLE_FADE_MS); |
678 | | - }; |
679 | | - // `cancelMove` releases a superseded animation through this hook; without |
680 | | - // it a newer move would leave this promise pending forever. |
681 | | - state.finishMove = settle; |
682 | | - |
683 | | - const land = () => { |
684 | | - setImportant(cursor, "left", `${entryX}px`); |
685 | | - setImportant(cursor, "top", `${centreY}px`); |
686 | | - trail.style.strokeDashoffset = "0"; |
687 | | - settle(); |
688 | | - }; |
689 | | - |
690 | | - const step = (timestamp) => { |
691 | | - if (start === null) { |
692 | | - start = timestamp; |
693 | | - } |
694 | | - const elapsed = timestamp - start; |
695 | | - if (elapsed >= spinMs + holdMs) { |
696 | | - land(); // the final frame puts the cursor exactly back on the entry point |
697 | | - return; |
698 | | - } |
699 | | - // Only the spin phase advances the angle; during the hold the cursor |
700 | | - // rests at the entry point and the trail stands whole. |
701 | | - const spun = Math.min(elapsed, spinMs); |
702 | | - const laps = spun / msPerLap; |
703 | | - const angle = angleAt(laps - Math.floor(laps)); |
704 | | - setImportant(cursor, "left", `${centreX + radiusX * Math.cos(angle)}px`); |
705 | | - setImportant(cursor, "top", `${centreY + radiusY * Math.sin(angle)}px`); |
706 | | - // The first lap draws the trail; later laps trace over what is there. |
707 | | - trail.style.strokeDashoffset = `${perimeter * (1 - Math.min(1, spun / msPerLap))}`; |
708 | | - state.raf = window.requestAnimationFrame(step); |
709 | | - }; |
710 | | - |
711 | | - fallbackTimer = window.setTimeout(() => { |
712 | | - if (state.raf !== null) { |
713 | | - window.cancelAnimationFrame(state.raf); |
714 | | - state.raf = null; |
715 | | - } |
716 | | - land(); |
717 | | - }, spinMs + holdMs + 50); |
718 | | - state.raf = window.requestAnimationFrame(step); |
719 | | - }); |
720 | | - } |
721 | | - |
722 | | - function highlight(x, y, width, height) { |
723 | | - const values = [x, y, width, height].map(Number); |
724 | | - if (!values.every(Number.isFinite) || values[2] < 0 || values[3] < 0) { |
725 | | - throw new TypeError("highlight bounds must be finite with non-negative size"); |
726 | | - } |
727 | | - const root = mountRoot(); |
728 | | - if (!root) { |
729 | | - scheduleMount(); |
730 | | - return false; |
731 | | - } |
732 | | - |
733 | | - const box = document.createElement("div"); |
734 | | - box.setAttribute("data-guidebot-highlight", ""); |
735 | | - styleTransient(box, "2147483645"); |
736 | | - setImportant(box, "left", `${values[0]}px`); |
737 | | - setImportant(box, "top", `${values[1]}px`); |
738 | | - setImportant(box, "width", `${values[2]}px`); |
739 | | - setImportant(box, "height", `${values[3]}px`); |
740 | | - setImportant(box, "border", "3px solid rgba(37, 99, 235, .95)"); |
741 | | - setImportant(box, "border-radius", "6px"); |
742 | | - setImportant(box, "background", "rgba(59, 130, 246, .12)"); |
743 | | - setImportant(box, "box-shadow", "0 0 0 4px rgba(59, 130, 246, .16)"); |
744 | | - root.appendChild(box); |
745 | | - |
746 | | - const animation = box.animate( |
747 | | - [ |
748 | | - { opacity: 0, transform: "scale(.98)" }, |
749 | | - { opacity: 1, transform: "scale(1)", offset: 0.2 }, |
750 | | - { opacity: 0, transform: "scale(1.015)" }, |
751 | | - ], |
752 | | - { duration: 800, easing: "ease-out", fill: "forwards" }, |
753 | | - ); |
754 | | - removeAfterAnimation(box, animation, 900); |
755 | | - return true; |
756 | | - } |
757 | | - |
758 | | - function hide() { |
759 | | - hidden = true; |
760 | | - const cursor = document.querySelector(CURSOR_SELECTOR); |
761 | | - if (cursor) { |
762 | | - setImportant(cursor, "display", "none"); |
763 | | - } |
764 | | - } |
765 | | - |
766 | | - function show() { |
767 | | - hidden = false; |
768 | | - ensure(); |
769 | | - } |
770 | | - |
771 | | - const api = { |
772 | | - __guidebotVersion: API_VERSION, |
773 | | - ensure, |
774 | | - moveTo, |
775 | | - ripple, |
776 | | - highlight, |
777 | | - encircle, |
778 | | - hide, |
779 | | - show, |
780 | | - get position() { |
781 | | - return [state.x, state.y]; |
782 | | - }, |
783 | | - }; |
784 | | - |
785 | | - Object.defineProperty(window, API_KEY, { |
786 | | - configurable: true, |
787 | | - enumerable: false, |
788 | | - writable: true, |
789 | | - value: api, |
790 | | - }); |
791 | | - ensure(); |
792 | | -})(); |
0 commit comments