mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
* docs(catalog): put each item page's title, description and Docs text in the server HTML Mintlify renders snippet components in the browser only, so everything inside CatalogDetail (title, standfirst, the Docs tab) was missing from the HTML search engines read: 448 catalog pages served Googlebot no body text. The generator now writes the H1 and standfirst as page markup above the component and the Docs tab as page markup after it, shown with that tab (docs/custom.css). The component no longer draws its own title. Pages patched structurally only; registry drift on 14 pages is left for a regeneration of its own. * docs(catalog): keep the title's top spacing the component gave it * fix(docs): catalog titles and descriptions keep markdown markers literal MDX reads text inside the static <h1> and <p> as inline markdown, so @_blue_sweater_ rendered as italic blue_sweater. mdxText now writes backslash, backtick, * _ [ ] ~ as character references too, with a unit test; the four affected pages are re-escaped.
527 lines
20 KiB
Plaintext
527 lines
20 KiB
Plaintext
---
|
|
title: "Text Select Cursor"
|
|
description: "An arrow turns into an I-beam over a document, drags a selection across a phrase character by character, raises a format toolbar, turns back into an arrow and clicks Bold."
|
|
mode: "frame"
|
|
---
|
|
|
|
import { CatalogDetail, CatalogSlot } from "/snippets/catalog-detail.jsx";
|
|
import { InstallCommand } from "/snippets/install-command.jsx";
|
|
|
|
<div className="hf-ve-static-head not-prose">
|
|
<h1>Text Select Cursor</h1>
|
|
<p>An arrow turns into an I-beam over a document, drags a selection across a phrase character by character, raises a format toolbar, turns back into an arrow and clicks Bold.</p>
|
|
</div>
|
|
|
|
<CatalogDetail
|
|
previewSrc="/public/catalog/components/cursor-text-select.json"
|
|
compositionId="cursor-text-select"
|
|
compositionSrc="compositions/components/cursor-text-select.html"
|
|
title="Text Select Cursor"
|
|
variables={[{"id":"title","type":"string","role":"content","label":"Heading","default":"Spring release notes"},{"id":"text","type":"string","role":"content","label":"Paragraph","default":"Our spring release ships on Thursday. Teams can now share a live review link, collect time-stamped comments, and approve the final cut without leaving the editor. Early customers cut their review rounds in half."},{"id":"phrase","type":"string","role":"content","label":"Phrase to select","default":"approve the final cut"},{"id":"accent","type":"color","role":"style","label":"Accent","default":"#0a84ff"}]}
|
|
meta={{"category":"Cursors","badge":"Stable","codeLines":440}}
|
|
attribution={{"path":"registry/components/cursor-text-select","tags":["cursor","motion-primitive","text","selection","editor","toolbar"]}}
|
|
hasCode
|
|
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/cursor-text-select/cursor-text-select.html"
|
|
>
|
|
|
|
<CatalogSlot slot="code">
|
|
|
|
```html cursor-text-select.html
|
|
<!doctype html>
|
|
<!--
|
|
Text Select Cursor (actor primitive), mountable sub-composition
|
|
|
|
concept: an arrow glides onto a document and turns into an I-beam as its tip enters
|
|
the text, presses before a phrase and drags the selection across it character by
|
|
character, releases (the selection snaps to the word end), a format toolbar fades
|
|
up centred above the selection, the pointer turns back into an arrow, clicks Bold, and the
|
|
phrase turns bold.
|
|
|
|
mount contract: loaded by a host through data-composition-src; only <template> is
|
|
cloned. #root fills the host clip (inset:0) and draws its own document card.
|
|
|
|
variables:
|
|
title (string) the document heading
|
|
text (string) the paragraph
|
|
phrase (string) the words to select; must appear in text, else the middle words are used
|
|
accent (color) selection tint and the active Bold button
|
|
|
|
envelope (default 4.2s): glide 0-0.5s, drag 0.65-1.6s, release 1.65s, toolbar 1.75s,
|
|
click Bold 2.7s, cursor drifts aside by 3.4s, hold. Shorter durations compress every beat evenly.
|
|
-->
|
|
<html
|
|
lang="en"
|
|
data-composition-id="cursor-text-select"
|
|
data-composition-duration="4.2"
|
|
data-composition-variables='[
|
|
{ "id": "title", "type": "string", "role": "content", "label": "Heading", "default": "Spring release notes" },
|
|
{ "id": "text", "type": "string", "role": "content", "label": "Paragraph", "default": "Our spring release ships on Thursday. Teams can now share a live review link, collect time-stamped comments, and approve the final cut without leaving the editor. Early customers cut their review rounds in half." },
|
|
{ "id": "phrase", "type": "string", "role": "content", "label": "Phrase to select", "default": "approve the final cut" },
|
|
{ "id": "accent", "type": "color", "role": "style", "label": "Accent", "default": "#0a84ff" }
|
|
]'
|
|
>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Text Select Cursor</title>
|
|
</head>
|
|
<body>
|
|
<template>
|
|
<style>
|
|
#root {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
container-type: size;
|
|
font-family: var(--font-body, Inter, system-ui, sans-serif);
|
|
--ts-accent: #0a84ff;
|
|
}
|
|
.ts-stage {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.ts-card {
|
|
position: relative;
|
|
width: 70cqw;
|
|
padding: 4cqw 4.6cqw 4.4cqw;
|
|
border-radius: 1.4cqw;
|
|
background: #ffffff;
|
|
box-shadow:
|
|
0 2.4cqw 6cqw rgba(8, 12, 28, 0.35),
|
|
0 0 0 1px rgba(15, 23, 42, 0.06);
|
|
}
|
|
.ts-meta {
|
|
font-size: 1.1cqw;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: #8a90a2;
|
|
}
|
|
.ts-title {
|
|
margin: 0.9cqw 0 1.8cqw;
|
|
font-size: 3.1cqw;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
color: #0f1222;
|
|
}
|
|
.ts-para {
|
|
position: relative;
|
|
margin: 0;
|
|
font-size: 2.05cqw;
|
|
line-height: 1.6;
|
|
color: #30354a;
|
|
}
|
|
.ts-ch.is-sel {
|
|
background: color-mix(in srgb, var(--ts-accent) 26%, transparent);
|
|
}
|
|
.ts-ch.is-bold {
|
|
font-weight: 700;
|
|
color: #0f1222;
|
|
}
|
|
.ts-toolbar {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
display: flex;
|
|
gap: 0.3cqw;
|
|
padding: 0.4cqw;
|
|
border-radius: 0.9cqw;
|
|
background: #15182a;
|
|
box-shadow: 0 1cqw 2.4cqw rgba(8, 12, 28, 0.35);
|
|
transform-origin: 50% 100%;
|
|
opacity: 0;
|
|
will-change: transform, opacity;
|
|
}
|
|
.ts-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 3.5cqw;
|
|
height: 3.5cqw;
|
|
border-radius: 0.6cqw;
|
|
color: #e7e9f2;
|
|
}
|
|
.ts-btn svg {
|
|
width: 1.8cqw;
|
|
height: 1.8cqw;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 2.2;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
.ts-btn.is-on {
|
|
background: var(--ts-accent);
|
|
color: #ffffff;
|
|
}
|
|
.ts-cursor {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 0;
|
|
height: 0;
|
|
transform-origin: 0 0;
|
|
will-change: transform;
|
|
}
|
|
.ts-arrow,
|
|
.ts-ibeam {
|
|
position: absolute;
|
|
overflow: visible;
|
|
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.3));
|
|
}
|
|
.ts-arrow path {
|
|
fill: #ffffff;
|
|
stroke: #141414;
|
|
stroke-width: 1.4;
|
|
stroke-linejoin: round;
|
|
}
|
|
.ts-ibeam path {
|
|
fill: none;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
</style>
|
|
|
|
<div id="root" data-composition-id="cursor-text-select" data-duration="4.2">
|
|
<div class="ts-stage">
|
|
<div class="ts-card">
|
|
<div class="ts-meta">Draft · edited just now</div>
|
|
<h2 class="ts-title" id="ts-title"></h2>
|
|
<p class="ts-para" id="ts-para"></p>
|
|
</div>
|
|
</div>
|
|
<div class="ts-toolbar" id="ts-toolbar">
|
|
<div class="ts-btn" id="ts-bold">
|
|
<svg viewBox="0 0 24 24">
|
|
<path d="M7 4h6.5a4 4 0 0 1 0 8H7zM7 12h7.5a4 4 0 0 1 0 8H7z" />
|
|
</svg>
|
|
</div>
|
|
<div class="ts-btn">
|
|
<svg viewBox="0 0 24 24"><path d="M14 4h-4M14 20h-4M13.5 4 10.5 20" /></svg>
|
|
</div>
|
|
<div class="ts-btn">
|
|
<svg viewBox="0 0 24 24">
|
|
<path
|
|
d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="ts-btn">
|
|
<svg viewBox="0 0 24 24"><path d="M5 5h14v10H10l-5 4z" /></svg>
|
|
</div>
|
|
</div>
|
|
<div class="ts-cursor" id="ts-cursor" aria-hidden="true">
|
|
<svg class="ts-arrow" id="ts-arrow" viewBox="0 0 24 24">
|
|
<path d="M5 3 L5 19 L9 15 L12 22 L15 20.5 L11.5 14 L18 14 Z" />
|
|
</svg>
|
|
<svg class="ts-ibeam" id="ts-ibeam" viewBox="0 0 12 32">
|
|
<path
|
|
d="M2.5 2Q6 2 6 4.5Q6 2 9.5 2M2.5 30Q6 30 6 27.5Q6 30 9.5 30M6 4.5V27.5"
|
|
stroke="#ffffff"
|
|
stroke-width="3.4"
|
|
/>
|
|
<path
|
|
d="M2.5 2Q6 2 6 4.5Q6 2 9.5 2M2.5 30Q6 30 6 27.5Q6 30 9.5 30M6 4.5V27.5"
|
|
stroke="#141414"
|
|
stroke-width="1.3"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var html = document.documentElement;
|
|
var root = document.getElementById("root");
|
|
var para = document.getElementById("ts-para");
|
|
var toolbar = document.getElementById("ts-toolbar");
|
|
var boldBtn = document.getElementById("ts-bold");
|
|
var cursor = document.getElementById("ts-cursor");
|
|
var arrow = document.getElementById("ts-arrow");
|
|
var ibeam = document.getElementById("ts-ibeam");
|
|
|
|
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);
|
|
|
|
// Hardcoded: the compositor rewrites the wrapper's attributes on mount.
|
|
var compositionId = "cursor-text-select";
|
|
var duration = Math.max(
|
|
0.001,
|
|
parseFloat(
|
|
root.dataset.duration || html.getAttribute("data-composition-duration") || "4.2",
|
|
),
|
|
);
|
|
var text = String(vars.text || DEFAULTS.text || "");
|
|
var phrase = String(vars.phrase || "");
|
|
if (vars.accent) root.style.setProperty("--ts-accent", String(vars.accent));
|
|
document.getElementById("ts-title").textContent = String(vars.title || "");
|
|
|
|
// The phrase must be in the text; otherwise select the middle three words.
|
|
var at = phrase ? text.indexOf(phrase) : -1;
|
|
if (at < 0) {
|
|
var words = text.split(" ");
|
|
var from = Math.max(0, Math.floor(words.length / 2) - 1);
|
|
phrase = words.slice(from, from + 3).join(" ");
|
|
at = words.slice(0, from).join(" ").length + (from > 0 ? 1 : 0);
|
|
}
|
|
para.appendChild(document.createTextNode(text.slice(0, at)));
|
|
var chars = [];
|
|
for (var i = 0; i < phrase.length; i++) {
|
|
var span = document.createElement("span");
|
|
span.className = "ts-ch";
|
|
span.textContent = phrase[i];
|
|
para.appendChild(span);
|
|
chars.push(span);
|
|
}
|
|
para.appendChild(document.createTextNode(text.slice(at + phrase.length)));
|
|
|
|
var W = root.clientWidth || 1920;
|
|
var H = root.clientHeight || 1080;
|
|
var u = W / 1920;
|
|
var ARROW = 64 * u;
|
|
arrow.style.width = arrow.style.height = ARROW + "px";
|
|
arrow.style.left = (-5 / 24) * ARROW + "px";
|
|
arrow.style.top = (-3 / 24) * ARROW + "px";
|
|
// macOS proportion: the I-beam's drawn height (28 of 32 units) is 1.2x the arrow's (19 of 24).
|
|
var BEAM = (1.2 * ARROW * (19 / 24) * 32) / 28;
|
|
ibeam.style.height = BEAM + "px";
|
|
ibeam.style.width = BEAM * 0.375 + "px";
|
|
ibeam.style.left = -BEAM * 0.1875 + "px";
|
|
ibeam.style.top = -BEAM / 2 + "px";
|
|
|
|
// Layout-space offsets, which ignore any transform a host puts on the player.
|
|
function offsetIn(el) {
|
|
var x = 0;
|
|
var y = 0;
|
|
while (el && el !== root) {
|
|
x += el.offsetLeft;
|
|
y += el.offsetTop;
|
|
el = el.offsetParent;
|
|
}
|
|
return { x: x, y: y };
|
|
}
|
|
function charBox(k) {
|
|
var o = offsetIn(chars[k]);
|
|
return { l: o.x, r: o.x + chars[k].offsetWidth, t: o.y, h: chars[k].offsetHeight };
|
|
}
|
|
// Tip point for a fractional character count f: between the edges of character floor(f).
|
|
function boundary(f) {
|
|
var n = chars.length;
|
|
var k = Math.min(n - 1, Math.max(0, Math.floor(f)));
|
|
var box = charBox(k);
|
|
var frac = Math.min(1, Math.max(0, f - k));
|
|
return { x: box.l + (box.r - box.l) * frac, y: box.t + box.h / 2 };
|
|
}
|
|
|
|
var squeeze = Math.min(1, duration / 4.2);
|
|
var T = {
|
|
inEnd: 0.5,
|
|
press: 0.6,
|
|
drag0: 0.65,
|
|
drag1: 1.6,
|
|
release: 1.65,
|
|
bar: 1.75,
|
|
move0: 2.0,
|
|
move1: 2.5,
|
|
click: 2.7,
|
|
bold: 2.77,
|
|
away0: 2.95,
|
|
away1: 3.4,
|
|
};
|
|
for (var key in T) T[key] *= squeeze;
|
|
var DRAG_REACH = 0.9;
|
|
|
|
var easeOut = gsap.parseEase("power3.out");
|
|
var easeInOut = gsap.parseEase("power2.inOut");
|
|
function seg(t, a, b) {
|
|
return Math.max(0, Math.min(1, (t - a) / (b - a)));
|
|
}
|
|
function lerp(a, b, k) {
|
|
return { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k };
|
|
}
|
|
function dip(t, at) {
|
|
var d = 0.07 * squeeze;
|
|
if (t < at || t > at + d * 3.3) return 1;
|
|
return t < at + d
|
|
? 1 - 0.12 * seg(t, at, at + d)
|
|
: 0.88 + 0.12 * easeOut(seg(t, at + d, at + d * 3.3));
|
|
}
|
|
|
|
function render(t) {
|
|
var n = chars.length;
|
|
var bold = t >= T.bold;
|
|
var f = t < T.drag0 ? 0 : n * DRAG_REACH * easeInOut(seg(t, T.drag0, T.drag1));
|
|
var selected = t >= T.release ? n : Math.round(f);
|
|
for (var i = 0; i < n; i++) {
|
|
chars[i].classList.toggle("is-sel", i < selected);
|
|
chars[i].classList.remove("is-bold");
|
|
}
|
|
|
|
// Anchor on the regular-weight layout so the toolbar does not shift when Bold lands.
|
|
var first = charBox(0);
|
|
var lineR = first.r;
|
|
for (var j = 1; j < n && charBox(j).t === first.t; j++) lineR = charBox(j).r;
|
|
var card = offsetIn(para.parentNode);
|
|
var cardRight = card.x + para.parentNode.offsetWidth;
|
|
var barW = toolbar.offsetWidth;
|
|
var barX = Math.max(
|
|
card.x + 16 * u,
|
|
Math.min((first.l + lineR) / 2 - barW / 2, cardRight - 16 * u - barW),
|
|
);
|
|
var barY = first.t - 12 * u - toolbar.offsetHeight;
|
|
for (i = 0; i < n; i++) chars[i].classList.toggle("is-bold", bold);
|
|
var barK = easeOut(seg(t, T.bar, T.bar + 0.18 * squeeze));
|
|
toolbar.style.opacity = String(barK);
|
|
toolbar.style.transform =
|
|
"translate(" +
|
|
barX +
|
|
"px," +
|
|
(barY + 4 * u * (1 - barK)) +
|
|
"px) scale(" +
|
|
(0.96 + 0.04 * barK) +
|
|
")";
|
|
var pressed = t >= T.click && t < T.click + 0.12 * squeeze;
|
|
boldBtn.style.transform = pressed ? "scale(0.92)" : "";
|
|
boldBtn.classList.toggle("is-on", bold);
|
|
|
|
var start = { x: W * 1.08, y: H * 0.34 };
|
|
var press = boundary(0);
|
|
var release = boundary(n * DRAG_REACH);
|
|
var target = {
|
|
x: barX + boldBtn.offsetLeft + boldBtn.offsetWidth * 0.55,
|
|
y: barY + boldBtn.offsetTop + boldBtn.offsetHeight * 0.6,
|
|
};
|
|
var aside = { x: cardRight + 80 * u, y: target.y + 140 * u };
|
|
var p;
|
|
if (t < T.inEnd) p = lerp(start, press, easeOut(seg(t, 0, T.inEnd)));
|
|
else if (t < T.drag0) p = press;
|
|
else if (t < T.move0) p = boundary(f);
|
|
else if (t < T.move1) p = lerp(release, target, easeOut(seg(t, T.move0, T.move1)));
|
|
else if (t < T.away0) p = target;
|
|
else p = lerp(target, aside, easeInOut(seg(t, T.away0, T.away1)));
|
|
|
|
// The I-beam takes over while the tip is inside the paragraph box.
|
|
var box = offsetIn(para);
|
|
var inside = Math.min(
|
|
p.x - box.x,
|
|
box.x + para.offsetWidth - p.x,
|
|
p.y - box.y,
|
|
box.y + para.offsetHeight - p.y,
|
|
);
|
|
var m = seg(inside, -24 * u, 0);
|
|
var overBar =
|
|
barK > 0 &&
|
|
p.x > barX - 10 * u &&
|
|
p.x < barX + toolbar.offsetWidth + 10 * u &&
|
|
p.y > barY - 10 * u &&
|
|
p.y < barY + toolbar.offsetHeight + 10 * u;
|
|
if (overBar) m = 0;
|
|
arrow.style.opacity = String(1 - m);
|
|
ibeam.style.opacity = String(m);
|
|
|
|
var s = dip(t, T.press) * dip(t, T.click);
|
|
cursor.style.transform = "translate(" + p.x + "px," + p.y + "px) scale(" + s + ")";
|
|
}
|
|
|
|
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>
|
|
```
|
|
|
|
</CatalogSlot>
|
|
|
|
<CatalogSlot slot="install">
|
|
|
|
<InstallCommand command="npx hyperframes add cursor-text-select" item="cursor-text-select" />
|
|
|
|
That writes one file: `compositions/components/cursor-text-select.html`.
|
|
|
|
</CatalogSlot>
|
|
|
|
</CatalogDetail>
|
|
|
|
<div data-static-slot="docs" className="prose prose-gray dark:prose-invert">
|
|
|
|
## Paste it into your composition
|
|
|
|
Open `compositions/components/cursor-text-select.html` and copy what is inside into your own composition.
|
|
|
|
A component has no size or duration of its own. It takes both from the composition
|
|
you paste it into.
|
|
|
|
## Variables
|
|
|
|
Every one of these has a default, so the piece works untouched. Set the ones you
|
|
want to change on the element:
|
|
|
|
| Variable | Default | Accepts | What it does |
|
|
| --- | --- | --- | --- |
|
|
| `title` | `Spring release notes` | string | |
|
|
| `text` | `Our spring release ships on Thursday. Teams can now share a live review link, collect time-stamped comments, and approve the final cut without leaving the editor. Early customers cut their review rounds in half.` | string | |
|
|
| `phrase` | `approve the final cut` | string | |
|
|
| `accent` | `#0a84ff` | color | |
|
|
|
|
Set them with `data-variable-values` on the element that mounts it. These are the
|
|
defaults, so this behaves exactly like the preview above until you change one:
|
|
|
|
```html wrap
|
|
<div
|
|
data-composition-id="cursor-text-select"
|
|
data-composition-src="compositions/components/cursor-text-select.html"
|
|
data-variable-values='{"title":"Spring release notes","text":"Our spring release ships on Thursday. Teams can now share a live review link, collect time-stamped comments, and approve the final cut without leaving the editor. Early customers cut their review rounds in half.","phrase":"approve the final cut","accent":"#0a84ff"}'
|
|
></div>
|
|
```
|
|
|
|
{/* hf:generated-footer */}
|
|
|
|
Tagged `cursor` `motion-primitive` `text` `selection` `editor` `toolbar`.
|
|
|
|
## Related topics
|
|
|
|
- [Browse the complete Catalog](/catalog)
|
|
- [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks)
|
|
- [Put blocks together into one video](/concepts/compositions)
|
|
|
|
</div>
|