Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions electron/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -2619,11 +2619,11 @@ const api: ElectronAPI = {
...(isDemoMode
? {
demo: {
moveTo: (x: number, y: number, durationMs: number) =>
moveTo: (x: number, y: number, durationMs?: number) =>
_unwrappingInvoke(CHANNELS.DEMO_MOVE_TO, { x, y, durationMs }),
moveToSelector: (
selector: string,
durationMs: number,
durationMs?: number,
offsetX?: number,
offsetY?: number
) =>
Expand Down
4 changes: 2 additions & 2 deletions shared/types/ipc/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,10 +1219,10 @@ export interface ElectronAPI {
getFolderPath(): Promise<string | null>;
};
demo?: {
moveTo(x: number, y: number, durationMs: number): Promise<void>;
moveTo(x: number, y: number, durationMs?: number): Promise<void>;
moveToSelector(
selector: string,
durationMs: number,
durationMs?: number,
offsetX?: number,
offsetY?: number
): Promise<void>;
Expand Down
4 changes: 2 additions & 2 deletions shared/types/ipc/demo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export interface DemoMoveToPayload {
x: number;
y: number;
durationMs: number;
durationMs?: number;
}

export interface DemoTypePayload {
Expand All @@ -26,7 +26,7 @@ export interface DemoSleepPayload {

export interface DemoMoveToSelectorPayload {
selector: string;
durationMs: number;
durationMs?: number;
offsetX?: number;
offsetY?: number;
}
Expand Down
182 changes: 158 additions & 24 deletions src/components/Demo/DemoCursor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,57 @@ import type {

const CURSOR_SVG_PATH = "M2.5 1L17.5 13.5H9.5L14 22L11 23.5L6.5 15L2.5 19.5V1Z";

const FITTS_A = 100;
const FITTS_B = 200;
const FITTS_DEFAULT_W = 40;
const TWO_PHASE_THRESHOLD = 300;

function getDemoApi() {
return window.electron.demo!;
}

function noise1D(x: number): number {
const h = (n: number) => Math.abs(Math.sin(n) * 1e4) % 1;
const i = Math.floor(x);
const f = x - i;
const s = f * f * (3 - 2 * f);
return h(i) * (1 - s) + h(i + 1) * s;
}

function gaussianRandom(mean: number, stdev: number): number {
const u = 1 - Math.random();
const v = Math.random();
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
return Math.max(20, z * stdev + mean);
}

function fittsDuration(dist: number, targetWidth?: number): number {
const w = targetWidth ?? FITTS_DEFAULT_W;
const t = FITTS_A + FITTS_B * Math.log2(1 + dist / w);
return Math.min(3000, Math.max(200, t));
}

function movementSteps(dist: number): number {
return Math.round(Math.min(60, Math.max(10, dist / 20)));
}

function getTypingDelay(char: string, prevChar: string, baseMean: number): number {
if (Math.random() < 0.01) return gaussianRandom(1200, 400);
let mean = baseMean;
let stdev = baseMean * 0.18;
if (/[.!?,;:]/.test(prevChar)) {
mean = baseMean * 3.5;
stdev = baseMean * 1.2;
} else if (prevChar === " ") {
mean = baseMean * 2.2;
stdev = baseMean * 0.6;
} else if (/[a-zA-Z]/.test(char) && /[a-zA-Z]/.test(prevChar)) {
mean = baseMean * 0.7;
stdev = baseMean * 0.12;
}
return gaussianRandom(mean, stdev);
}

function cubicBezier(t: number, p0: number, p1: number, p2: number, p3: number): number {
const u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
Expand All @@ -26,33 +73,38 @@ function computeBezierKeyframes(
fromY: number,
toX: number,
toY: number,
steps = 30
steps: number,
seed: number
): Array<{ transform: string }> {
const dx = toX - fromX;
const dy = toY - fromY;
const dist = Math.sqrt(dx * dx + dy * dy);

// Perpendicular vector (normalized)
const perpX = dist > 0 ? -dy / dist : 0;
const perpY = dist > 0 ? dx / dist : 0;

// Random offset 5-30% of distance, both control points on the same side
const offset = dist * (0.05 + Math.random() * 0.25);
const sign = Math.random() > 0.5 ? 1 : -1;

// P1: ~33% along the vector, offset perpendicular
const p1x = fromX + dx * 0.33 + perpX * offset * sign;
const p1y = fromY + dy * 0.33 + perpY * offset * sign;

// P2: ~80% along the vector, smaller perpendicular offset, slight overshoot past target
const p2x = fromX + dx * 0.8 + perpX * offset * 0.3 * sign + dx * 0.05;
const p2y = fromY + dy * 0.8 + perpY * offset * 0.3 * sign + dy * 0.05;

const jitterAmplitude = Math.min(2, dist * 0.003);
const frames: Array<{ transform: string }> = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = cubicBezier(t, fromX, p1x, p2x, toX) - fromX;
const y = cubicBezier(t, fromY, p1y, p2y, toY) - fromY;
let x = cubicBezier(t, fromX, p1x, p2x, toX) - fromX;
let y = cubicBezier(t, fromY, p1y, p2y, toY) - fromY;

if (i > 0 && i < steps && jitterAmplitude > 0) {
const noiseVal = (noise1D(i * 0.15 + seed) * 2 - 1) * jitterAmplitude;
x += perpX * noiseVal;
y += perpY * noiseVal;
}

frames.push({ transform: `translate(${x}px, ${y}px)` });
}
return frames;
Expand Down Expand Up @@ -107,25 +159,78 @@ export function DemoCursor() {
async function animateCursor(
targetX: number,
targetY: number,
durationMs: number
durationMs?: number,
targetWidth?: number
): Promise<void> {
const el = cursorRef.current;
if (!el) return;

const fromX = posRef.current.x;
const fromY = posRef.current.y;
const keyframes = computeBezierKeyframes(fromX, fromY, targetX, targetY, 30);
const dx = targetX - fromX;
const dy = targetY - fromY;
const dist = Math.sqrt(dx * dx + dy * dy);

const totalDuration = durationMs ?? fittsDuration(dist, targetWidth);
const steps = movementSteps(dist);
const seed = Math.random() * 1000;

if (dist > TWO_PHASE_THRESHOLD) {
const splitIndex = Math.round(steps * 0.8);
const allKeyframes = computeBezierKeyframes(fromX, fromY, targetX, targetY, steps, seed);

const ballisticKeyframes = allKeyframes.slice(0, splitIndex + 1);
const ballisticDuration = totalDuration * 0.75;
const ballisticAnim = el.animate(ballisticKeyframes, {
duration: ballisticDuration,
easing: "cubic-bezier(0.32, 0, 0.67, 0)",
fill: "forwards",
});
await ballisticAnim.finished;

const lastBallistic = allKeyframes[splitIndex].transform;
const match = lastBallistic.match(/translate\(([^p]+)px,\s*([^p]+)px\)/);
const midX = fromX + (match ? parseFloat(match[1]) : dx * 0.8);
const midY = fromY + (match ? parseFloat(match[2]) : dy * 0.8);
el.style.left = `${midX}px`;
el.style.top = `${midY}px`;
el.style.transform = "";
ballisticAnim.cancel();

const acquisitionKeyframes = allKeyframes.slice(splitIndex).map((kf, i) => {
if (i === 0) return { transform: "translate(0px, 0px)" };
const m = kf.transform.match(/translate\(([^p]+)px,\s*([^p]+)px\)/);
if (!m) return kf;
const origX = parseFloat(m[1]) + fromX;
const origY = parseFloat(m[2]) + fromY;
return { transform: `translate(${origX - midX}px, ${origY - midY}px)` };
});

const acquisitionDuration = totalDuration * 0.25;
const acquisitionAnim = el.animate(acquisitionKeyframes, {
duration: acquisitionDuration,
easing: "cubic-bezier(0.33, 1, 0.68, 1)",
fill: "forwards",
});
await acquisitionAnim.finished;
el.style.left = `${targetX}px`;
el.style.top = `${targetY}px`;
el.style.transform = "";
acquisitionAnim.cancel();
} else {
const keyframes = computeBezierKeyframes(fromX, fromY, targetX, targetY, steps, seed);
const anim = el.animate(keyframes, {
duration: totalDuration,
easing: "ease-out",
fill: "forwards",
});
await anim.finished;
el.style.left = `${targetX}px`;
el.style.top = `${targetY}px`;
el.style.transform = "";
anim.cancel();
}

const anim = el.animate(keyframes, {
duration: durationMs,
easing: "linear",
fill: "forwards",
});
await anim.finished;
el.style.left = `${targetX}px`;
el.style.top = `${targetY}px`;
el.style.transform = "";
anim.cancel();
posRef.current = { x: targetX, y: targetY };
}

Expand Down Expand Up @@ -170,7 +275,12 @@ export function DemoCursor() {
const targetX = rect.left + rect.width / 2 + (payload.offsetX ?? 0);
const targetY = rect.top + rect.height / 2 + (payload.offsetY ?? 0);

await animateCursor(targetX, targetY, payload.durationMs);
await animateCursor(
targetX,
targetY,
payload.durationMs,
Math.min(rect.width, rect.height)
);
sendDone(payload.requestId);
} catch (err) {
sendDone(payload.requestId, String(err));
Expand Down Expand Up @@ -218,6 +328,26 @@ export function DemoCursor() {
releaseAnim.cancel();
wrapper.style.transform = "scale(1)";

const cursor = cursorRef.current;
if (cursor) {
const settleX = (Math.random() * 2 - 1) * 1.5;
const settleY = (Math.random() * 2 - 1) * 1.5;
const settleAnim = cursor.animate(
[
{ transform: "translate(0px, 0px)" },
{ transform: `translate(${settleX}px, ${settleY}px)` },
],
{ duration: 150, easing: "ease-out", fill: "forwards" }
);
await settleAnim.finished;
posRef.current.x += settleX;
posRef.current.y += settleY;
cursor.style.left = `${posRef.current.x}px`;
cursor.style.top = `${posRef.current.y}px`;
cursor.style.transform = "";
settleAnim.cancel();
}

const { x: cx, y: cy } = posRef.current;
const clickTarget = document.elementFromPoint(cx, cy);
if (clickTarget) {
Expand Down Expand Up @@ -250,12 +380,13 @@ export function DemoCursor() {
return;
}

const cps = payload.cps ?? 12;
const delay = 1000 / cps;
const cps = Math.max(1, payload.cps ?? 12);
const baseMean = 1000 / cps;

const cmView = EditorView.findFromDOM(target);
if (cmView) {
cmView.focus();
let prevChar = "";
for (const char of payload.text) {
await waitIfPaused();
const pos = cmView.state.selection.main.head;
Expand All @@ -264,18 +395,21 @@ export function DemoCursor() {
selection: { anchor: pos + char.length },
annotations: Transaction.userEvent.of("input"),
});
await pauseAwareDelay(delay);
await pauseAwareDelay(getTypingDelay(char, prevChar, baseMean));
prevChar = char;
}
} else {
const inputTarget = target as HTMLInputElement | HTMLTextAreaElement;
inputTarget.focus();
let prevChar = "";
for (const char of payload.text) {
await waitIfPaused();
inputTarget.value += char;
inputTarget.dispatchEvent(
new InputEvent("input", { inputType: "insertText", data: char, bubbles: true })
);
await pauseAwareDelay(delay);
await pauseAwareDelay(getTypingDelay(char, prevChar, baseMean));
prevChar = char;
}
}
sendDone(payload.requestId);
Expand Down
Loading