mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
405 lines
16 KiB
HTML
Vendored
405 lines
16 KiB
HTML
Vendored
<!doctype html>
|
|
<!--
|
|
Annotate Cursor (actor primitive), mountable sub-composition
|
|
|
|
concept: a marker pen glides to a region of the host UI, touches down and hand-draws
|
|
one annotation around it (a loose loop, an underline, or an arrow), then lifts and
|
|
leaves. The ink is revealed exactly under the nib: both read the same path length.
|
|
|
|
mount contract: loaded by a host through data-composition-src; only <template> is
|
|
cloned. #root fills the host clip (inset:0). See demo.html.
|
|
|
|
variables:
|
|
shape (circle|underline|arrow) the mark the pen draws
|
|
color marker ink
|
|
target_x, target_y, target_w, target_h (% of host box) the region being marked
|
|
|
|
envelope (default 4.2s): glide in 0-0.8s, touch down, draw 0.9-2.0s, lift,
|
|
elastic hold, exit in the last 0.55s. Shorter durations compress every beat evenly.
|
|
-->
|
|
<html
|
|
lang="en"
|
|
data-composition-id="cursor-annotate"
|
|
data-composition-duration="4.2"
|
|
data-composition-variables='[
|
|
{ "id": "shape", "type": "enum", "role": "style", "label": "Mark", "default": "circle", "options": [ { "value": "circle", "label": "Loose loop" }, { "value": "underline", "label": "Underline" }, { "value": "arrow", "label": "Arrow" } ] },
|
|
{ "id": "color", "type": "color", "role": "style", "label": "Ink", "default": "#f43f5e" },
|
|
{ "id": "target_x", "type": "number", "role": "layout", "label": "Region left", "default": 40, "min": 0, "max": 95, "step": 1, "unit": "%" },
|
|
{ "id": "target_y", "type": "number", "role": "layout", "label": "Region top", "default": 38, "min": 0, "max": 95, "step": 1, "unit": "%" },
|
|
{ "id": "target_w", "type": "number", "role": "layout", "label": "Region width", "default": 20, "min": 2, "max": 90, "step": 1, "unit": "%" },
|
|
{ "id": "target_h", "type": "number", "role": "layout", "label": "Region height", "default": 16, "min": 2, "max": 90, "step": 1, "unit": "%" }
|
|
]'
|
|
>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Annotate Cursor</title>
|
|
</head>
|
|
<body>
|
|
<template>
|
|
<style>
|
|
#root {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
isolation: isolate;
|
|
pointer-events: none;
|
|
}
|
|
.ca-ink {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: visible;
|
|
}
|
|
.ca-ink path {
|
|
fill: none;
|
|
stroke: var(--ca-ink, #f43f5e);
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
.ca-pen {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 0;
|
|
height: 0;
|
|
will-change: transform;
|
|
}
|
|
/* The nib sits at the svg origin, so the wrapper's translate places the nib. */
|
|
.ca-pen svg {
|
|
position: absolute;
|
|
left: -40px;
|
|
top: -200px;
|
|
width: 240px;
|
|
height: 240px;
|
|
overflow: visible;
|
|
}
|
|
</style>
|
|
|
|
<div id="root" data-composition-id="cursor-annotate" data-duration="4.2">
|
|
<svg class="ca-ink" id="ca-ink" aria-hidden="true"></svg>
|
|
<div class="ca-pen" id="ca-pen" aria-hidden="true">
|
|
<svg viewBox="-40 -200 240 240" aria-hidden="true">
|
|
<g id="ca-pen-body">
|
|
<g transform="rotate(-50)">
|
|
<path d="M0 0 L15 -5.5 L15 5.5 Z" fill="#1d1d22" />
|
|
<path id="ca-pen-tip" d="M3 -1.4 L15 -5.5 L15 5.5 L3 1.4 Z" />
|
|
<rect x="15" y="-7.5" width="14" height="15" rx="2" fill="#2a2a31" />
|
|
<rect
|
|
x="29"
|
|
y="-9"
|
|
width="62"
|
|
height="18"
|
|
rx="4"
|
|
fill="#f6f6f8"
|
|
stroke="#1d1d22"
|
|
stroke-width="1.5"
|
|
/>
|
|
<rect x="36" y="-9" width="8" height="18" id="ca-pen-band" />
|
|
<rect x="91" y="-9.5" width="16" height="19" rx="6" fill="#1d1d22" />
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var html = document.documentElement;
|
|
var root = document.getElementById("root");
|
|
var ink = document.getElementById("ca-ink");
|
|
var pen = document.getElementById("ca-pen");
|
|
var penBody = document.getElementById("ca-pen-body");
|
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
var DEFAULTS = {};
|
|
try {
|
|
JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(
|
|
function (variable) {
|
|
DEFAULTS[variable.id] = variable.default;
|
|
},
|
|
);
|
|
} catch (error) {
|
|
/* malformed declaration falls back to the inline defaults below */
|
|
}
|
|
var scopedVariables = {};
|
|
try {
|
|
if (window.__hyperframes && typeof window.__hyperframes.getVariables === "function") {
|
|
scopedVariables = window.__hyperframes.getVariables() || {};
|
|
}
|
|
} catch (error) {
|
|
/* no scoped variables API in this context */
|
|
}
|
|
var vars = Object.assign({}, DEFAULTS, window.__hfVariables || {}, scopedVariables);
|
|
|
|
function num(value, fallback, lo, hi) {
|
|
var n = Number(value);
|
|
if (!isFinite(n)) n = fallback;
|
|
return Math.max(lo, Math.min(hi, n));
|
|
}
|
|
|
|
// Hardcoded: the compositor rewrites the wrapper's attributes on mount.
|
|
var compositionId = "cursor-annotate";
|
|
var duration = Math.max(
|
|
0.001,
|
|
parseFloat(
|
|
root.dataset.duration || html.getAttribute("data-composition-duration") || "4.2",
|
|
),
|
|
);
|
|
var shape =
|
|
["circle", "underline", "arrow"].indexOf(vars.shape) >= 0 ? vars.shape : "circle";
|
|
var color = /^#[0-9a-f]{3,8}$/i.test(String(vars.color)) ? vars.color : "#f43f5e";
|
|
var W = root.clientWidth || 1920;
|
|
var H = root.clientHeight || 1080;
|
|
var unit = W / 1920;
|
|
var box = {
|
|
x: (num(vars.target_x, 40, 0, 100) / 100) * W,
|
|
y: (num(vars.target_y, 38, 0, 100) / 100) * H,
|
|
w: (num(vars.target_w, 20, 1, 100) / 100) * W,
|
|
h: (num(vars.target_h, 16, 1, 100) / 100) * H,
|
|
};
|
|
|
|
ink.setAttribute("viewBox", "0 0 " + W + " " + H);
|
|
root.style.setProperty("--ca-ink", color);
|
|
document.getElementById("ca-pen-tip").setAttribute("fill", color);
|
|
document.getElementById("ca-pen-band").setAttribute("fill", color);
|
|
|
|
function polyline(points) {
|
|
return points
|
|
.map(function (p, i) {
|
|
return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1);
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
// A hand loop: starts upper-left, runs a little past its start and wider on the way out.
|
|
function loopStroke() {
|
|
var cx = box.x + box.w / 2;
|
|
var cy = box.y + box.h / 2;
|
|
var rx = box.w * 0.62 + 18 * unit;
|
|
var ry = box.h * 0.66 + 16 * unit;
|
|
var tilt = (-5 * Math.PI) / 180;
|
|
var start = -2.55;
|
|
var sweep = Math.PI * 2 + 0.5;
|
|
var pts = [];
|
|
for (var i = 0; i <= 140; i++) {
|
|
var u = i / 140;
|
|
var a = start + sweep * u;
|
|
var r = (1 + 0.035 * Math.sin(3 * a + 1.1)) * (1 + 0.08 * u);
|
|
var ex = Math.cos(a) * rx * r;
|
|
var ey = Math.sin(a) * ry * r;
|
|
pts.push([
|
|
cx + ex * Math.cos(tilt) - ey * Math.sin(tilt),
|
|
cy + ex * Math.sin(tilt) + ey * Math.cos(tilt),
|
|
]);
|
|
}
|
|
return [polyline(pts)];
|
|
}
|
|
|
|
function underlineStroke() {
|
|
var y = box.y + box.h + 14 * unit;
|
|
var x0 = box.x - box.w * 0.04;
|
|
var span = box.w * 1.08;
|
|
var pts = [];
|
|
for (var i = 0; i <= 48; i++) {
|
|
var u = i / 48;
|
|
pts.push([
|
|
x0 + span * u,
|
|
y + 4 * unit * Math.sin(u * Math.PI * 1.3) - 12 * unit * u * u,
|
|
]);
|
|
}
|
|
return [polyline(pts)];
|
|
}
|
|
|
|
// Shaft from below-left, curving onto the region; the head is a second stroke.
|
|
function arrowStrokes() {
|
|
var tip = { x: box.x - 10 * unit, y: box.y + box.h + 8 * unit };
|
|
var from = {
|
|
x: tip.x - Math.max(160 * unit, box.w * 0.7),
|
|
y: tip.y + Math.max(120 * unit, box.h * 0.9),
|
|
};
|
|
var ctrl = { x: from.x + (tip.x - from.x) * 0.15, y: tip.y + (from.y - tip.y) * 0.05 };
|
|
var pts = [];
|
|
for (var i = 0; i <= 40; i++) {
|
|
var u = i / 40;
|
|
var v = 1 - u;
|
|
pts.push([
|
|
v * v * from.x + 2 * v * u * ctrl.x + u * u * tip.x,
|
|
v * v * from.y + 2 * v * u * ctrl.y + u * u * tip.y,
|
|
]);
|
|
}
|
|
var back = Math.atan2(ctrl.y - tip.y, ctrl.x - tip.x);
|
|
var len = 30 * unit;
|
|
var barb = function (spread) {
|
|
return [tip.x + Math.cos(back + spread) * len, tip.y + Math.sin(back + spread) * len];
|
|
};
|
|
return [polyline(pts), polyline([barb(0.5), [tip.x, tip.y], barb(-0.5)])];
|
|
}
|
|
|
|
var strokeDs =
|
|
shape === "underline"
|
|
? underlineStroke()
|
|
: shape === "arrow"
|
|
? arrowStrokes()
|
|
: loopStroke();
|
|
var WIDTH = 11 * unit;
|
|
var strokes = strokeDs.map(function (d) {
|
|
var thin = document.createElementNS(SVG_NS, "path");
|
|
var thick = document.createElementNS(SVG_NS, "path");
|
|
thin.setAttribute("d", d);
|
|
thick.setAttribute("d", d);
|
|
thin.setAttribute("stroke-width", (WIDTH * 0.62).toFixed(2));
|
|
thick.setAttribute("stroke-width", WIDTH.toFixed(2));
|
|
ink.appendChild(thin);
|
|
ink.appendChild(thick);
|
|
return { thin: thin, thick: thick, length: thin.getTotalLength() };
|
|
});
|
|
|
|
// Beats at the default 4.2s; a shorter composition compresses them evenly.
|
|
var squeeze = Math.min(1, duration / 3.3);
|
|
var T_LAND = 0.8 * squeeze;
|
|
var T_DRAW = 0.9 * squeeze;
|
|
var HOP = 0.2 * squeeze;
|
|
var drawTotal = (shape === "circle" ? 1.1 : shape === "arrow" ? 0.95 : 0.6) * squeeze;
|
|
var totalLength = strokes.reduce(function (sum, s) {
|
|
return sum + s.length;
|
|
}, 0);
|
|
var windows = [];
|
|
var cursorT = T_DRAW;
|
|
strokes.forEach(function (s, i) {
|
|
var span = (drawTotal - HOP * (strokes.length - 1)) * (s.length / totalLength);
|
|
windows.push({ start: cursorT, end: cursorT + span });
|
|
cursorT += span + (i < strokes.length - 1 ? HOP : 0);
|
|
});
|
|
var T_LIFT = cursorT;
|
|
var T_EXIT = Math.max(T_LIFT + 0.4 * squeeze, duration - 0.55 * squeeze);
|
|
|
|
function clamp01(u) {
|
|
return u < 0 ? 0 : u > 1 ? 1 : u;
|
|
}
|
|
var glide = gsap.parseEase("power3.out");
|
|
var stroke = gsap.parseEase("sine.inOut");
|
|
var hop = gsap.parseEase("power2.inOut");
|
|
var leave = gsap.parseEase("power2.in");
|
|
|
|
function pointOn(s, u) {
|
|
var p = s.thin.getPointAtLength(clamp01(u) * s.length);
|
|
return { x: p.x, y: p.y };
|
|
}
|
|
var first = pointOn(strokes[0], 0);
|
|
var lastStroke = strokes[strokes.length - 1];
|
|
var endPoint = pointOn(lastStroke, 1);
|
|
var START = { x: first.x + 0.3 * W, y: H * 1.25 };
|
|
var ASIDE = { x: endPoint.x + 70 * unit, y: endPoint.y + 50 * unit };
|
|
var END = { x: W * 1.15, y: H * 1.2 };
|
|
|
|
function mix(a, b, u) {
|
|
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u };
|
|
}
|
|
|
|
// Returns the nib position and how far off the paper it is (0 = touching).
|
|
function penAt(t) {
|
|
if (t <= T_LAND) return { p: mix(START, first, glide(clamp01(t / T_LAND))), lift: 1 };
|
|
if (t <= T_DRAW)
|
|
return { p: first, lift: 1 - clamp01((t - T_LAND) / (T_DRAW - T_LAND)) };
|
|
for (var i = 0; i < windows.length; i++) {
|
|
var w = windows[i];
|
|
if (t <= w.end)
|
|
return {
|
|
p: pointOn(strokes[i], stroke((t - w.start) / (w.end - w.start))),
|
|
lift: 0,
|
|
};
|
|
var next = windows[i + 1];
|
|
if (next && t < next.start) {
|
|
var u = (t - w.end) / (next.start - w.end);
|
|
return {
|
|
p: mix(pointOn(strokes[i], 1), pointOn(strokes[i + 1], 0), hop(u)),
|
|
lift: Math.sin(Math.PI * u),
|
|
};
|
|
}
|
|
}
|
|
if (t <= T_EXIT) {
|
|
var a = clamp01((t - T_LIFT) / (0.45 * squeeze));
|
|
return {
|
|
p: mix(endPoint, ASIDE, gsap.parseEase("power2.out")(a)),
|
|
lift: Math.min(1, a * 2.5),
|
|
};
|
|
}
|
|
return {
|
|
p: mix(ASIDE, END, leave(clamp01((t - T_EXIT) / (duration - T_EXIT)))),
|
|
lift: 1,
|
|
};
|
|
}
|
|
|
|
function drawn(i, t) {
|
|
var w = windows[i];
|
|
return stroke(clamp01((t - w.start) / (w.end - w.start))) * strokes[i].length;
|
|
}
|
|
|
|
function render(t) {
|
|
strokes.forEach(function (s, i) {
|
|
var len = drawn(i, t);
|
|
s.thin.style.opacity = len > 0.5 ? "1" : "0";
|
|
s.thin.setAttribute(
|
|
"stroke-dasharray",
|
|
len.toFixed(2) + " " + (s.length * 2).toFixed(2),
|
|
);
|
|
// The thick core trails the tip and never reaches either end: the stroke tapers.
|
|
var core = Math.min(len, s.length * 0.9) - s.length * 0.08;
|
|
s.thick.style.opacity = core > 0.5 ? "1" : "0";
|
|
s.thick.setAttribute(
|
|
"stroke-dasharray",
|
|
Math.max(0, core).toFixed(2) + " " + (s.length * 3).toFixed(2),
|
|
);
|
|
s.thick.setAttribute("stroke-dashoffset", (-s.length * 0.08).toFixed(2));
|
|
});
|
|
var state = penAt(t);
|
|
var lift = state.lift;
|
|
pen.style.transform =
|
|
"translate(" + state.p.x.toFixed(2) + "px," + state.p.y.toFixed(2) + "px)";
|
|
penBody.setAttribute(
|
|
"transform",
|
|
"scale(" +
|
|
((1.45 + 0.14 * lift) * unit).toFixed(4) +
|
|
") rotate(" +
|
|
(-4 * lift).toFixed(2) +
|
|
")",
|
|
);
|
|
pen.style.filter =
|
|
"drop-shadow(" +
|
|
(4 + 16 * lift).toFixed(1) +
|
|
"px " +
|
|
(6 + 20 * lift).toFixed(1) +
|
|
"px " +
|
|
(3 + 14 * lift).toFixed(1) +
|
|
"px rgba(0,0,0," +
|
|
(0.32 - 0.14 * lift).toFixed(3) +
|
|
"))";
|
|
}
|
|
|
|
var clock = { t: 0 };
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to(
|
|
clock,
|
|
{
|
|
t: duration,
|
|
duration: duration,
|
|
ease: "none",
|
|
onUpdate: function () {
|
|
render(clock.t);
|
|
},
|
|
},
|
|
0,
|
|
);
|
|
render(0);
|
|
tl.seek(0);
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines[compositionId] = tl;
|
|
})();
|
|
</script>
|
|
</template>
|
|
</body>
|
|
</html>
|