Add a drag-and-resize animation
A drawn pointer grabs the frame near its top edge, drags it across the screen, lets go, then takes hold of one corner and pulls the frame to a new size. Moves ease in and overshoot slightly before settling, and the paths bow, so the motion reads as a hand rather than a tween. The resize pins the opposite corner and keeps the aspect ratio locked — a stretched video would read as broken, not as someone resizing — and clamps the width to the room left before the corner leaves the screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
BOUNCE_SPEED,
|
||||
CORNER_INTERVAL_SECONDS,
|
||||
CORNER_MARGIN,
|
||||
DRAG_MIN_SCALE,
|
||||
DRAG_MAX_SCALE,
|
||||
AUTO_ANIMATION,
|
||||
} from '/config.js';
|
||||
|
||||
@@ -296,12 +298,394 @@ export function startCorners(context) {
|
||||
};
|
||||
}
|
||||
|
||||
// --- drag and resize ----------------------------------------------------
|
||||
|
||||
// Someone shoving a video window around a desktop: a pointer travels to the
|
||||
// frame, grabs it, drags it somewhere else, lets go for a moment, then takes
|
||||
// hold of one corner and pulls the frame to a new size. The pointer is drawn,
|
||||
// so every move on screen has a visible cause.
|
||||
|
||||
const ARROW = `<svg viewBox="0 0 24 24" width="27" height="27" aria-hidden="true">
|
||||
<path d="M5 2.5 L5 19.6 L9.5 15.4 L12.3 21.5 L15.2 20.1 L12.4 14.2 L18.6 14 Z"
|
||||
fill="#fff" stroke="#14060f" stroke-width="1.3" stroke-linejoin="round" />
|
||||
</svg>`;
|
||||
|
||||
// Where on the frame the pointer takes hold, as a fraction of its height —
|
||||
// the top strip, where a window's title bar would be.
|
||||
const GRAB_BAND = [0.06, 0.3];
|
||||
|
||||
// Seconds the pointer rests between grabs, and how far it drifts while resting.
|
||||
const PAUSE = [0.5, 1.7];
|
||||
const IDLE_DRIFT = 2.5;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function between(min, max) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
/** Smoothstep: no jerk at either end, which is what an arm does. */
|
||||
function smooth(t) {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress along a hand-driven move: a gentle launch, then a slight overshoot
|
||||
* that pulls back onto the target. `overshoot` 0 is a plain smooth ramp; the
|
||||
* bigger it gets, the further past the target the move swings first.
|
||||
*/
|
||||
export function settle(t, overshoot = 0.6) {
|
||||
const back = smooth(clamp(t, 0, 1)) - 1;
|
||||
return 1 + (overshoot + 1) * back ** 3 + overshoot * back ** 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer position at `t` (0..1) on its way from `from` to `to`. `curve` bows
|
||||
* the path sideways — a hand does not move in a straight line — and its sign
|
||||
* decides which way the arc leans.
|
||||
*/
|
||||
export function pointerAt(from, to, t, { curve = 0, overshoot = 0.6 } = {}) {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const p = settle(t, overshoot);
|
||||
const bow = Math.sin(Math.PI * clamp(t, 0, 1)) * curve;
|
||||
|
||||
return {
|
||||
x: from.x + dx * p - dy * bow,
|
||||
y: from.y + dy * p + dx * bow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rect you get by dragging `corner` (0 = top left, clockwise) to `pointer`
|
||||
* while the opposite corner stays pinned, the way a window resize works.
|
||||
*
|
||||
* The aspect ratio is locked — a squashed video reads as broken rather than as
|
||||
* someone resizing — so the axis the pointer pulled furthest wins, and the
|
||||
* width is clamped into `min`..`max`.
|
||||
*/
|
||||
export function resizeRect(rect, corner, pointer, { min, max }) {
|
||||
const anchor = anchorOf(rect, corner);
|
||||
const pulled = Math.max(
|
||||
Math.abs(pointer.x - anchor.x),
|
||||
Math.abs(pointer.y - anchor.y) / ASPECT,
|
||||
);
|
||||
|
||||
return rectFromWidth(rect, corner, clamp(pulled, min, max));
|
||||
}
|
||||
|
||||
/** The corner that stays put while `corner` is dragged. */
|
||||
function anchorOf(rect, corner) {
|
||||
const right = corner === 1 || corner === 2;
|
||||
const bottom = corner === 2 || corner === 3;
|
||||
|
||||
return {
|
||||
x: right ? rect.x : rect.x + rect.width,
|
||||
y: bottom ? rect.y : rect.y + rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Grow `rect` to `width` about its anchor, keeping the aspect ratio. */
|
||||
function rectFromWidth(rect, corner, width) {
|
||||
const anchor = anchorOf(rect, corner);
|
||||
const right = corner === 1 || corner === 2;
|
||||
const bottom = corner === 2 || corner === 3;
|
||||
const height = width * ASPECT;
|
||||
|
||||
return {
|
||||
x: right ? anchor.x : anchor.x - width,
|
||||
y: bottom ? anchor.y : anchor.y - height,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Screen position of one corner of a rect, same indexing as resizeRect. */
|
||||
function cornerOf(rect, corner) {
|
||||
const right = corner === 1 || corner === 2;
|
||||
const bottom = corner === 2 || corner === 3;
|
||||
|
||||
return {
|
||||
x: right ? rect.x + rect.width : rect.x,
|
||||
y: bottom ? rect.y + rect.height : rect.y,
|
||||
};
|
||||
}
|
||||
|
||||
function distance(a, b) {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
/** Longer trips take longer, but not proportionally — Fitts, roughly. */
|
||||
function reachTime(px) {
|
||||
return clamp(0.28 + px / 1500, 0.28, 1.2);
|
||||
}
|
||||
|
||||
/** Dragging is slower than moving an empty hand, and much slower than a flick. */
|
||||
function dragTime(px) {
|
||||
return clamp(0.55 + px / 700, 0.55, 2.4);
|
||||
}
|
||||
|
||||
export function startDragResize({ element }) {
|
||||
const pointer = document.createElement('div');
|
||||
pointer.id = 'pointer';
|
||||
pointer.innerHTML = ARROW;
|
||||
document.body.appendChild(pointer);
|
||||
|
||||
let base = frameSize(window.innerWidth, window.innerHeight);
|
||||
let rect = {
|
||||
x: (window.innerWidth - base.width) / 2,
|
||||
y: (window.innerHeight - base.height) / 2,
|
||||
width: base.width,
|
||||
height: base.height,
|
||||
};
|
||||
|
||||
// The pointer starts off screen and slides in for its first grab.
|
||||
let at = { x: -40, y: window.innerHeight * 0.75 };
|
||||
|
||||
function margin() {
|
||||
return Math.min(window.innerWidth, window.innerHeight) * CORNER_MARGIN;
|
||||
}
|
||||
|
||||
/** Widths the frame may take: never wider than the screen allows. */
|
||||
function widthLimits() {
|
||||
const fit = Math.min(
|
||||
window.innerWidth - 2 * margin(),
|
||||
(window.innerHeight - 2 * margin()) / ASPECT,
|
||||
);
|
||||
const min = Math.min(base.width * DRAG_MIN_SCALE, fit);
|
||||
return { min, max: clamp(base.width * DRAG_MAX_SCALE, min, fit) };
|
||||
}
|
||||
|
||||
function draw() {
|
||||
element.style.width = `${rect.width.toFixed(1)}px`;
|
||||
element.style.height = `${rect.height.toFixed(1)}px`;
|
||||
place(element, rect.x, rect.y);
|
||||
place(pointer, at.x, at.y);
|
||||
}
|
||||
|
||||
// --- the script ---
|
||||
//
|
||||
// One cycle is a fixed sequence of beats; each beat gets its endpoints when
|
||||
// it starts, so a viewport resize part-way through never strands the pointer
|
||||
// at coordinates that no longer exist.
|
||||
|
||||
let steps = [];
|
||||
let index = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
// Filled in by the press beats and read by the drag beats that follow.
|
||||
let held = { x: 0, y: 0 };
|
||||
let corner = 0;
|
||||
let anchored = rect;
|
||||
|
||||
function travel(to, options) {
|
||||
const from = at;
|
||||
const target = to();
|
||||
return {
|
||||
duration: options.time(distance(from, target)),
|
||||
update(p) {
|
||||
at = pointerAt(from, target, p, options);
|
||||
options.moved?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Grab: the pointer lands on the frame's title-bar strip. */
|
||||
function reachFrame() {
|
||||
return travel(
|
||||
() => ({
|
||||
x: rect.x + between(0.2, 0.8) * rect.width,
|
||||
y: rect.y + between(GRAB_BAND[0], GRAB_BAND[1]) * rect.height,
|
||||
}),
|
||||
// An empty hand shoots past the target and pulls back onto it.
|
||||
{ time: reachTime, curve: between(-0.07, 0.07), overshoot: 1.6 },
|
||||
);
|
||||
}
|
||||
|
||||
function reachCorner() {
|
||||
corner = Math.floor(Math.random() * CORNERS);
|
||||
return travel(() => cornerOf(rect, corner), {
|
||||
time: reachTime,
|
||||
curve: between(-0.05, 0.05),
|
||||
overshoot: 1.4,
|
||||
});
|
||||
}
|
||||
|
||||
function press() {
|
||||
return {
|
||||
duration: between(0.1, 0.22),
|
||||
begin() {
|
||||
pointer.classList.add('down');
|
||||
element.classList.add('dragging');
|
||||
held = { x: at.x - rect.x, y: at.y - rect.y };
|
||||
anchored = { ...rect };
|
||||
},
|
||||
update() {},
|
||||
};
|
||||
}
|
||||
|
||||
function release() {
|
||||
const from = at;
|
||||
return {
|
||||
duration: between(PAUSE[0], PAUSE[1]),
|
||||
begin() {
|
||||
pointer.classList.remove('down');
|
||||
element.classList.remove('dragging');
|
||||
},
|
||||
// A resting hand is never quite still.
|
||||
update(p) {
|
||||
at = {
|
||||
x: from.x + Math.sin(p * 7) * IDLE_DRIFT,
|
||||
y: from.y + Math.sin(p * 5 + 1) * IDLE_DRIFT * 0.7,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Drag: the frame hangs off the pointer at the offset it was grabbed by. */
|
||||
function dragFrame() {
|
||||
const gap = margin();
|
||||
return travel(
|
||||
() => {
|
||||
const limitX = Math.max(gap, window.innerWidth - rect.width - gap);
|
||||
const limitY = Math.max(gap, window.innerHeight - rect.height - gap);
|
||||
return {
|
||||
x: between(gap, limitX) + held.x,
|
||||
y: between(gap, limitY) + held.y,
|
||||
};
|
||||
},
|
||||
{
|
||||
time: dragTime,
|
||||
curve: between(-0.04, 0.04),
|
||||
// A loaded hand overshoots less than an empty one.
|
||||
overshoot: 0.8,
|
||||
moved() {
|
||||
rect.x = at.x - held.x;
|
||||
rect.y = at.y - held.y;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Resize: the held corner follows the pointer, the opposite one stays put. */
|
||||
function dragCorner() {
|
||||
const limits = widthLimits();
|
||||
const gap = margin();
|
||||
|
||||
return travel(
|
||||
() => {
|
||||
// How far the corner can travel before it leaves the screen — the same
|
||||
// wall a real hand would run the window into. The anchor never moves,
|
||||
// so the room is measured from there.
|
||||
const anchor = anchorOf(anchored, corner);
|
||||
const right = corner === 1 || corner === 2;
|
||||
const bottom = corner === 2 || corner === 3;
|
||||
const room = Math.min(
|
||||
right ? window.innerWidth - gap - anchor.x : anchor.x - gap,
|
||||
(bottom ? window.innerHeight - gap - anchor.y : anchor.y - gap) / ASPECT,
|
||||
);
|
||||
|
||||
const width = clamp(
|
||||
between(limits.min, limits.max),
|
||||
limits.min,
|
||||
Math.max(limits.min, room),
|
||||
);
|
||||
|
||||
return cornerOf(rectFromWidth(anchored, corner, width), corner);
|
||||
},
|
||||
{
|
||||
time: dragTime,
|
||||
curve: between(-0.03, 0.03),
|
||||
// Least of all when pulling against a corner: the size lands and stays.
|
||||
overshoot: 0.5,
|
||||
moved() {
|
||||
rect = resizeRect(anchored, corner, at, limits);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Beats are built lazily: each one reads the frame where the previous one
|
||||
// left it.
|
||||
const CYCLE = [reachFrame, press, dragFrame, release, reachCorner, press, dragCorner, release];
|
||||
|
||||
function step() {
|
||||
const build = CYCLE[index % CYCLE.length];
|
||||
index += 1;
|
||||
const current = build();
|
||||
current.begin?.();
|
||||
return current;
|
||||
}
|
||||
|
||||
let current = step();
|
||||
|
||||
function measure() {
|
||||
base = frameSize(window.innerWidth, window.innerHeight);
|
||||
|
||||
const gap = margin();
|
||||
const limits = widthLimits();
|
||||
rect.width = clamp(rect.width, limits.min, limits.max);
|
||||
rect.height = rect.width * ASPECT;
|
||||
rect.x = clamp(rect.x, gap, Math.max(gap, window.innerWidth - rect.width - gap));
|
||||
rect.y = clamp(rect.y, gap, Math.max(gap, window.innerHeight - rect.height - gap));
|
||||
|
||||
// Whatever the pointer was aiming at is stale now; start the next beat
|
||||
// from where things actually are.
|
||||
pointer.classList.remove('down');
|
||||
element.classList.remove('dragging');
|
||||
elapsed = 0;
|
||||
current = step();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', measure);
|
||||
|
||||
let last = performance.now();
|
||||
let handle = 0;
|
||||
let stopped = false;
|
||||
|
||||
function frame(now) {
|
||||
if (stopped) return;
|
||||
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
elapsed += dt;
|
||||
|
||||
const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1;
|
||||
current.update(p);
|
||||
draw();
|
||||
|
||||
if (p >= 1) {
|
||||
elapsed = 0;
|
||||
current = step();
|
||||
}
|
||||
|
||||
handle = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
draw();
|
||||
handle = requestAnimationFrame(frame);
|
||||
|
||||
return function stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(handle);
|
||||
window.removeEventListener('resize', measure);
|
||||
element.classList.remove('dragging');
|
||||
pointer.remove();
|
||||
// The next animation sizes the frame itself, but leaving a stretched frame
|
||||
// up for even one paint would flash.
|
||||
applyFrameSize(element);
|
||||
};
|
||||
}
|
||||
|
||||
// --- registry -----------------------------------------------------------
|
||||
|
||||
// Keyed by the names in config.js, which is what travels over the socket.
|
||||
export const ANIMATIONS = {
|
||||
bounce: startBounce,
|
||||
corners: startCorners,
|
||||
drag: startDragResize,
|
||||
};
|
||||
|
||||
const NAMES = Object.keys(ANIMATIONS);
|
||||
|
||||
Reference in New Issue
Block a user