Skip to content

Selection

A text selection or a marquee driven by a MousePointer drag: it follows the pointer glyph by glyph, and lights up covered items.

Loading
00:00:00

Loading the live preview

Props
theme
set in code

Text to select: { text, match? | range? }.

set in code

A MousePointer to follow during the drag.

Drag frames (parent timeline). Default: the pointer's first drag.

Marquee corners (parent coordinates). Default: the pointer's.

Marquee: these light up once covered.

Frame (parent timeline) the selection disappears.

Usage, as set above

Install

Copies the source into your project. It's yours from then on: edit it like any other file.

npx framefields add selection

Writes one file (selection/index.ts) into your components folder. Then:

import { Selection } from "./components/selection/index.js";
Selection({ target: { text: paragraph, match: "the key line" }, pointer })

Props

Plus the props every component takes: at, duration, hold,enter, exit, x, y, width, height,theme, seed and customize.

PropTypeDefault
kind"text" | "marquee""text"
targetunknown
Text to select: { text, match? | range? }.
pointerunknown
A MousePointer to follow during the drag.
drag{ frame, end }
Drag frames (parent timeline). Default: the pointer's first drag.
from[number, number]
Marquee corners (parent coordinates). Default: the pointer's.
to[number, number]
itemsunknown[][]
Marquee: these light up once covered.
clearnumber
Frame (parent timeline) the selection disappears.
colorstring

Cues

Frames other layers can key off, on the returned node: node.cues.selected[0].frame. Change the props and everything keyed to a cue moves with it.

selecteditems

Parts

The layers customize receives by name, to restyle or animate one piece without forking the component.

line-<n>marqueeitem-<n>

Source

What framefields add selection writes for selection/index.ts.

Show 333 lines
selection/index.ts
/**
* Selection: a text selection or a marquee, the way an editor draws them,
* driven by a `MousePointer` drag. Text selections follow the pointer glyph
* by glyph; a marquee follows it corner by corner and lights up the items it
* covers.
*
* Coordinates are the frame's: the root covers the video (default
* the composition `canvas`, or the given width / height) from 0, 0.
*/
import {
type KeyframeTuple,
LayerAnimation,
type LayoutNode,
} from "framefields";
import {
type Animatable,
bakeSamples,
bindComponent,
boundsOf,
defineComponent,
evaluateEase,
lineShift,
rangeRects,
type Rect,
resolveTarget,
round as roundTo,
scaleFrames,
type Target,
type TargetNode,
targetSchema,
textLayout,
type Vec2,
withAlpha,
z,
} from "framefields/components";
export interface SelectionCues {
[name: string]: ReadonlyArray<{ frame: number }>;
/** The selection is complete (the drag ends). */
selected: ReadonlyArray<{ frame: number }>;
/** Marquee items, as each is first covered. */
items: ReadonlyArray<{ frame: number; index: number }>;
}
/** What Selection reads from a pointer: `MousePointer` satisfies it. */
export interface SelectionPointer {
positionAt(frame: number): Vec2;
cues?: { drags?: ReadonlyArray<{ frame: number; end: number }> };
}
export const selection = /* @__PURE__ */ defineComponent({
name: "Selection",
props: () => ({
kind: z.enum(["text", "marquee"]).default("text"),
/** Text to select: `{ text, match? | range? }`. */
target: z
.custom<{
text: object;
match?: string | RegExp;
range?: [number, number];
nth?: number;
}>((v) => typeof v === "object" && v !== null && "text" in v, {
message: "Expected a text target `{ text, match? | range? }`",
})
.optional(),
/** A MousePointer to follow during the drag. */
pointer: z
.custom<SelectionPointer>(
(v) =>
typeof v === "object" &&
v !== null &&
typeof (v as SelectionPointer).positionAt === "function",
{ message: "Expected a MousePointer (anything with positionAt)" },
)
.optional(),
/** Drag frames (parent timeline). Default: the pointer's first drag. */
drag: z
.object({ frame: z.number().int().min(0), end: z.number().int().min(0) })
.passthrough()
.optional(),
/** Marquee corners (parent coordinates). Default: the pointer's. */
from: z.tuple([z.number(), z.number()]).optional(),
to: z.tuple([z.number(), z.number()]).optional(),
/** Marquee: these light up once covered. */
items: z.array(targetSchema()).default([]),
/** Frame (parent timeline) the selection disappears. */
clear: z.number().int().min(0).optional(),
color: z.string().optional(),
}),
defaults: { enter: false, exit: false },
build(ctx, props) {
const { host, theme, fps } = ctx;
const s = theme.scale;
const W = ctx.requested.width ?? ctx.canvas.width;
const H = ctx.requested.height ?? ctx.canvas.height;
const color = props.color ?? theme.color.accent;
const at0 = ctx.requested.at;
const drag = props.drag ?? props.pointer?.cues?.drags?.[0];
if (!drag)
throw new Error(
"Selection: give `drag: { frame, end }`, or a `pointer` that drags.",
);
// Local frames: the component's own timeline.
const start = drag.frame - at0;
const end = Math.max(start + 1, drag.end - at0);
const progress = (f: number) =>
evaluateEase(
"power2.inOut",
Math.max(0, Math.min(1, (f - start) / (end - start))),
);
const pointerAt = (f: number) => props.pointer?.positionAt(f + at0);
const fade = scaleFrames(8, fps);
const clear = props.clear !== undefined ? props.clear - at0 : undefined;
const parts: Record<string, Animatable<LayoutNode>> = {};
const children: LayoutNode[] = [];
const cues: SelectionCues = {
selected: [{ frame: end }],
items: [] as Array<{ frame: number; index: number }>,
};
if (props.kind === "text") {
if (!props.target)
throw new Error("Selection: a text selection needs a `target`.");
const t = textLayout(
props.target as Extract<Target, { text: TargetNode }>,
{ measure: host.measure, fps },
);
const [r0, r1] = t.range;
const glyphs = t.metrics.glyphs.filter(
(g) => g.index >= r0 && g.index < r1,
);
// The selection's end (a string index) on frame f.
const endIndex = (f: number): number => {
if (f <= start) return r0;
const p = pointerAt(Math.min(f, end));
if (!p) return r0 + Math.round(progress(f) * (r1 - r0));
// The line under the pointer, then the glyph boundary nearest it.
const lh = t.metrics.lineHeight;
const lines = [...new Set(glyphs.map((g) => g.line))];
const want = Math.floor((p[1] - t.y) / lh);
const line = Math.max(
lines[0] as number,
Math.min(lines[lines.length - 1] as number, want),
);
const shift = lineShift(t.metrics, line, t.options);
let best = r0;
let bestD = Number.POSITIVE_INFINITY;
for (const g of glyphs) {
if (g.line !== line) continue;
for (const [edge, idx] of [
[g.x, g.index],
[g.x + g.width, g.index + 1],
] as const) {
const d = Math.abs(t.x + shift + edge - p[0]);
if (d < bestD) {
bestD = d;
best = idx;
}
}
}
// Below the range's lines selects to the end; above, to the start.
if (want > (lines[lines.length - 1] as number)) return r1;
if (want < (lines[0] as number)) return r0;
return Math.max(r0, Math.min(r1, best));
};
const full = rangeRects(t.metrics, r0, r1, t.options);
const lineOf = (rect: Rect) => Math.round(rect.y / t.metrics.lineHeight);
full.forEach((rect, i) => {
const line = lineOf(rect);
const widthAt = (f: number) => {
const part = rangeRects(t.metrics, r0, endIndex(f), t.options).find(
(r) => lineOf(r) === line,
);
return part ? round(part.x + part.width - rect.x) : 0;
};
const keys: KeyframeTuple[] = [[0, 0]];
let last = 0;
for (let f = start; f <= end; f++) {
const w = widthAt(f);
if (w !== last) {
keys.push([f, w, "hold"]);
last = w;
}
}
const pad = Math.max(1, 2 * s);
const box = host.box({
id: ctx.partId(`line-${i}`),
position: "absolute",
x: t.x + rect.x,
y: t.y + rect.y + pad,
width: 0,
height: rect.height - pad * 2,
background: withAlpha(color, 0.32),
borderRadius: Math.max(1, 3 * s),
});
const anim = LayerAnimation.create().keys("width", keys);
if (clear !== undefined)
anim.keys("opacity", [
[clear, 1],
[clear + fade, 0, "power2.out"],
]);
box.animate(anim);
parts[`line-${i}`] = box;
children.push(box);
});
} else {
const from: Vec2 = props.from ?? pointerAt(start) ?? [W * 0.3, H * 0.3];
const to: Vec2 = props.to ?? pointerAt(end) ?? [W * 0.7, H * 0.7];
const corner = (f: number): Vec2 => {
const p = props.to ? undefined : pointerAt(Math.min(f, end));
if (p) return p;
const k = progress(f);
return [
from[0] + (to[0] - from[0]) * k,
from[1] + (to[1] - from[1]) * k,
];
};
const rectAt = (f: number): Rect => {
const c = corner(f);
return {
x: Math.min(from[0], c[0]),
y: Math.min(from[1], c[1]),
width: Math.abs(c[0] - from[0]),
height: Math.abs(c[1] - from[1]),
};
};
const frames = Array.from({ length: end - start + 1 }, (_, k) =>
rectAt(start + k),
);
const track = (k: keyof Rect) =>
bakeSamples(
frames.map((r) => r[k]),
start,
{ tolerance: 0.3 },
);
const box = host.box({
id: ctx.partId("marquee"),
position: "absolute",
x: from[0],
y: from[1],
width: 0,
height: 0,
background: withAlpha(color, 0.16),
borderColor: color,
borderWidth: Math.max(1, 1.5 * s),
opacity: 0,
});
box.animate(
LayerAnimation.create()
.keys("x", track("x"))
.keys("y", track("y"))
.keys("width", track("width"))
.keys("height", track("height"))
// The marquee goes on release; what it selected stays.
.keys("opacity", [
[start, 1, "hold"],
[end, 1],
[end + fade, 0, "power2.out"],
]),
);
parts.marquee = box;
props.items.forEach((item, index) => {
const r = boundsOf(
(() => {
const res = resolveTarget(item, { measure: host.measure, fps });
return res.kind === "static" ? res.rects : [res.at(end)];
})(),
);
const hits = (m: Rect) =>
m.x < r.x + r.width &&
m.x + m.width > r.x &&
m.y < r.y + r.height &&
m.y + m.height > r.y;
const keys: KeyframeTuple[] = [[0, 0]];
let on = false;
frames.forEach((m, k) => {
const now = m.width > 0 && m.height > 0 && hits(m);
if (now !== on) {
keys.push([start + k, now ? 1 : 0, "hold"]);
if (now && !cues.items.some((c) => c.index === index))
(cues.items as Array<{ frame: number; index: number }>).push({
frame: start + k,
index,
});
on = now;
}
});
if (on && clear !== undefined)
keys.push([clear, 1], [clear + fade, 0, "power2.out"]);
const pad = 4 * s;
const glow = host.box({
id: ctx.partId(`item-${index}`),
position: "absolute",
x: r.x - pad,
y: r.y - pad,
width: r.width + pad * 2,
height: r.height + pad * 2,
background: withAlpha(color, 0.18),
borderColor: color,
borderWidth: Math.max(1.5, 2 * s),
borderRadius: theme.radius.sm + pad,
opacity: 0,
});
glow.animate(LayerAnimation.keys("opacity", keys));
parts[`item-${index}`] = glow;
children.push(glow);
});
children.push(box);
}
const settle = props.kind === "marquee" ? end + fade : end;
return {
children,
width: W,
height: H,
duration: clear !== undefined ? clear + fade : settle,
openEnded: clear === undefined,
origin: [0, 0],
cues,
parts,
};
},
});
const round = (v: number) => roundTo(v, 2);
/**
* A text selection or a marquee driven by a MousePointer drag: it follows the
* pointer glyph by glyph, and lights up covered items.
*/
export const Selection = /* @__PURE__ */ bindComponent(selection);