mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
added cursor loop feature and update readme
This commit is contained in:
1 parent
e938a4dc37
commit
bf08f8ed3f
4 files changed
+260
-11
No files matched your search
@@ -65,6 +65,8 @@ interface SettingsPanelProps {
|
||||
onConnectZoomsChange?: (enabled: boolean) => void;
|
||||
showCursor?: boolean;
|
||||
onShowCursorChange?: (enabled: boolean) => void;
|
||||
loopCursor?: boolean;
|
||||
onLoopCursorChange?: (enabled: boolean) => void;
|
||||
cursorSize?: number;
|
||||
onCursorSizeChange?: (size: number) => void;
|
||||
cursorSmoothing?: number;
|
||||
@@ -139,6 +141,8 @@ export function SettingsPanel({
|
||||
onConnectZoomsChange,
|
||||
showCursor = false,
|
||||
onShowCursorChange,
|
||||
loopCursor = false,
|
||||
onLoopCursorChange,
|
||||
cursorSize = 5,
|
||||
onCursorSizeChange,
|
||||
cursorSmoothing = 2,
|
||||
@@ -424,6 +428,16 @@ export function SettingsPanel({
|
||||
className="data-[state=checked]:bg-[#2563EB] scale-90"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-300">Loop cursor</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={loopCursor}
|
||||
onCheckedChange={onLoopCursorChange}
|
||||
className="data-[state=checked]:bg-[#2563EB] scale-90"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Background Blur</div>
|
||||
@@ -902,7 +916,7 @@ export function SettingsPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.electronAPI?.openExternalUrl('https://github.com/webadderall/bug-free-succotash/issues/new/choose');
|
||||
window.electronAPI?.openExternalUrl('https://github.com/webadderall/Recordly/issues/new/choose');
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
@@ -912,7 +926,7 @@ export function SettingsPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.electronAPI?.openExternalUrl('https://github.com/webadderall/bug-free-succotash');
|
||||
window.electronAPI?.openExternalUrl('https://github.com/webadderall/Recordly');
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
|
||||
@@ -53,6 +53,10 @@ import { getAssetPath } from "@/lib/assetPath";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
|
||||
import { buildLoopedCursorTelemetry } from "./videoPlayback/cursorRenderer";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
|
||||
const LOOP_CURSOR_END_WINDOW_MS = 670;
|
||||
|
||||
type EditorHistorySnapshot = {
|
||||
zoomRegions: ZoomRegion[];
|
||||
@@ -85,6 +89,7 @@ export default function VideoEditor() {
|
||||
const [zoomMotionBlur, setZoomMotionBlur] = useState(DEFAULT_ZOOM_MOTION_BLUR);
|
||||
const [connectZooms, setConnectZooms] = useState(true);
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
const [loopCursor, setLoopCursor] = useState(false);
|
||||
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
|
||||
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
|
||||
@@ -239,6 +244,7 @@ export default function VideoEditor() {
|
||||
setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
|
||||
setConnectZooms(normalizedEditor.connectZooms);
|
||||
setShowCursor(normalizedEditor.showCursor);
|
||||
setLoopCursor(normalizedEditor.loopCursor);
|
||||
setCursorSize(normalizedEditor.cursorSize);
|
||||
setCursorSmoothing(normalizedEditor.cursorSmoothing);
|
||||
setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
|
||||
@@ -289,6 +295,7 @@ export default function VideoEditor() {
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
@@ -317,6 +324,7 @@ export default function VideoEditor() {
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
@@ -424,6 +432,7 @@ export default function VideoEditor() {
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
@@ -477,6 +486,7 @@ export default function VideoEditor() {
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
@@ -603,6 +613,52 @@ export default function VideoEditor() {
|
||||
return normalizeCursorTelemetry(cursorTelemetry, totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER);
|
||||
}, [cursorTelemetry, duration]);
|
||||
|
||||
const effectiveCursorTelemetry = useMemo(() => {
|
||||
if (!loopCursor) {
|
||||
return normalizedCursorTelemetry;
|
||||
}
|
||||
|
||||
const totalMs = Math.max(0, Math.round(duration * 1000));
|
||||
if (normalizedCursorTelemetry.length < 2 || totalMs <= 0) {
|
||||
return normalizedCursorTelemetry;
|
||||
}
|
||||
|
||||
return buildLoopedCursorTelemetry(normalizedCursorTelemetry, totalMs);
|
||||
}, [loopCursor, normalizedCursorTelemetry, duration]);
|
||||
|
||||
const effectiveZoomRegions = useMemo(() => {
|
||||
if (!loopCursor || zoomRegions.length === 0) {
|
||||
return zoomRegions;
|
||||
}
|
||||
|
||||
const totalMs = Math.max(0, Math.round(duration * 1000));
|
||||
if (totalMs <= 0) {
|
||||
return zoomRegions;
|
||||
}
|
||||
|
||||
const dominantAtStart = findDominantRegion(zoomRegions, 0, { connectZooms }).region;
|
||||
if (!dominantAtStart) {
|
||||
return zoomRegions;
|
||||
}
|
||||
|
||||
const endWindowStartMs = Math.max(0, totalMs - LOOP_CURSOR_END_WINDOW_MS);
|
||||
const loopEndRegion: ZoomRegion = {
|
||||
id: `${dominantAtStart.id}__loop-end-sync`,
|
||||
startMs: endWindowStartMs,
|
||||
endMs: totalMs,
|
||||
depth: dominantAtStart.depth,
|
||||
focus: {
|
||||
cx: dominantAtStart.focus.cx,
|
||||
cy: dominantAtStart.focus.cy,
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
...zoomRegions.filter((region) => region.id !== loopEndRegion.id),
|
||||
loopEndRegion,
|
||||
];
|
||||
}, [loopCursor, zoomRegions, duration, connectZooms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoPath || duration <= 0 || zoomRegions.length > 0 || normalizedCursorTelemetry.length < 2) {
|
||||
return;
|
||||
@@ -1178,7 +1234,6 @@ export default function VideoEditor() {
|
||||
loop: settings.gifConfig.loop,
|
||||
sizePreset: settings.gifConfig.sizePreset,
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
@@ -1191,7 +1246,8 @@ export default function VideoEditor() {
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
cursorTelemetry: normalizedCursorTelemetry,
|
||||
zoomRegions: effectiveZoomRegions,
|
||||
cursorTelemetry: effectiveCursorTelemetry,
|
||||
showCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
@@ -1320,7 +1376,6 @@ export default function VideoEditor() {
|
||||
bitrate,
|
||||
codec: 'avc1.640033',
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
@@ -1332,7 +1387,8 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
cursorTelemetry: normalizedCursorTelemetry,
|
||||
zoomRegions: effectiveZoomRegions,
|
||||
cursorTelemetry: effectiveCursorTelemetry,
|
||||
showCursor,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
@@ -1394,7 +1450,7 @@ export default function VideoEditor() {
|
||||
setShowExportDialog(keepExportDialogOpen);
|
||||
setExportProgress(null);
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, connectZooms, showCursor, normalizedCursorTelemetry, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality, showExportSuccessToast]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, connectZooms, showCursor, effectiveCursorTelemetry, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality, showExportSuccessToast]);
|
||||
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (!videoPath) {
|
||||
@@ -1567,7 +1623,7 @@ export default function VideoEditor() {
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
zoomRegions={effectiveZoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
@@ -1587,7 +1643,7 @@ export default function VideoEditor() {
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
cursorTelemetry={normalizedCursorTelemetry}
|
||||
cursorTelemetry={effectiveCursorTelemetry}
|
||||
showCursor={showCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
@@ -1622,8 +1678,8 @@ export default function VideoEditor() {
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={normalizedCursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
cursorTelemetry={effectiveCursorTelemetry}
|
||||
zoomRegions={effectiveZoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
@@ -1676,6 +1732,8 @@ export default function VideoEditor() {
|
||||
onConnectZoomsChange={setConnectZooms}
|
||||
showCursor={showCursor}
|
||||
onShowCursorChange={setShowCursor}
|
||||
loopCursor={loopCursor}
|
||||
onLoopCursorChange={setLoopCursor}
|
||||
cursorSize={cursorSize}
|
||||
onCursorSizeChange={setCursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface ProjectEditorState {
|
||||
zoomMotionBlur: number;
|
||||
connectZooms: boolean;
|
||||
showCursor: boolean;
|
||||
loopCursor: boolean;
|
||||
cursorSize: number;
|
||||
cursorSmoothing: number;
|
||||
cursorMotionBlur: number;
|
||||
@@ -307,6 +308,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
zoomMotionBlur: normalizedZoomMotionBlur,
|
||||
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
|
||||
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
|
||||
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
|
||||
cursorSize: isFiniteNumber(editor.cursorSize) ? clamp(editor.cursorSize, 0.5, 10) : DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
|
||||
? clamp(editor.cursorSmoothing, 0, 2)
|
||||
|
||||
@@ -57,6 +57,10 @@ const CLICK_ANIMATION_MS = 140;
|
||||
const CLICK_RING_FADE_MS = 240;
|
||||
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
|
||||
const CURSOR_TIME_DISCONTINUITY_MS = 100;
|
||||
const CURSOR_STATIONARY_TARGET_EPSILON = 0.0008;
|
||||
const CURSOR_STATIONARY_SNAP_DELAY_MS = 52;
|
||||
const CURSOR_STATIONARY_SNAP_DISTANCE = 0.012;
|
||||
const CURSOR_TRAILING_MOVEMENT_EPSILON = 0.0015;
|
||||
const CURSOR_SVG_DROP_SHADOW_FILTER = 'drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))';
|
||||
const CURSOR_SHADOW_COLOR = 0x000000;
|
||||
const CURSOR_SHADOW_ALPHA = 0.35;
|
||||
@@ -64,6 +68,9 @@ const CURSOR_SHADOW_OFFSET_X = 0;
|
||||
const CURSOR_SHADOW_OFFSET_Y = 2;
|
||||
const CURSOR_SHADOW_BLUR = 3;
|
||||
const CURSOR_SHADOW_PADDING = 12;
|
||||
const LOOP_CURSOR_FREEZE_DURATION_MS = 670;
|
||||
const LOOP_CURSOR_RETURN_STEPS = 20;
|
||||
const LOOP_CURSOR_SETTLE_DURATION_MS = 120;
|
||||
|
||||
let cursorAssetsPromise: Promise<void> | null = null;
|
||||
let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {};
|
||||
@@ -164,6 +171,46 @@ function getAvailableCursorKeys(): CursorAssetKey[] {
|
||||
return loadedKeys.length > 0 ? loadedKeys : ['arrow'];
|
||||
}
|
||||
|
||||
function findFirstStableCursorType(samples: CursorTelemetryPoint[]) {
|
||||
for (const sample of samples) {
|
||||
if (!sample.cursorType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sample.interactionType === 'click'
|
||||
|| sample.interactionType === 'double-click'
|
||||
|| sample.interactionType === 'right-click'
|
||||
|| sample.interactionType === 'middle-click') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return sample.cursorType;
|
||||
}
|
||||
|
||||
return samples[0]?.cursorType ?? 'arrow';
|
||||
}
|
||||
|
||||
function easeOutQuint(progress: number) {
|
||||
return 1 - ((1 - progress) ** 5);
|
||||
}
|
||||
|
||||
function findLastMovingSampleTime(samples: CursorTelemetryPoint[]) {
|
||||
if (samples.length <= 1) {
|
||||
return samples[0]?.timeMs ?? 0;
|
||||
}
|
||||
|
||||
for (let index = samples.length - 1; index > 0; index -= 1) {
|
||||
const curr = samples[index];
|
||||
const prev = samples[index - 1];
|
||||
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
|
||||
if (distance > CURSOR_TRAILING_MOVEMENT_EPSILON) {
|
||||
return curr.timeMs;
|
||||
}
|
||||
}
|
||||
|
||||
return samples[samples.length - 1].timeMs;
|
||||
}
|
||||
|
||||
export async function preloadCursorAssets() {
|
||||
if (!cursorAssetsPromise) {
|
||||
cursorAssetsPromise = (async () => {
|
||||
@@ -290,6 +337,100 @@ export function interpolateCursorPosition(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLoopedCursorTelemetry(
|
||||
samples: CursorTelemetryPoint[],
|
||||
totalDurationMs: number,
|
||||
): CursorTelemetryPoint[] {
|
||||
if (!samples || samples.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timelineEndMs = Math.max(0, Math.round(totalDurationMs));
|
||||
if (timelineEndMs <= 0) {
|
||||
return samples;
|
||||
}
|
||||
|
||||
const firstSample = samples[0];
|
||||
const lastSample = samples[samples.length - 1];
|
||||
const maxFreezeWindowMs = timelineEndMs - firstSample.timeMs;
|
||||
if (maxFreezeWindowMs <= 1) {
|
||||
return samples;
|
||||
}
|
||||
|
||||
const freezeDurationMs = Math.min(LOOP_CURSOR_FREEZE_DURATION_MS, maxFreezeWindowMs);
|
||||
const motionEndMs = timelineEndMs - freezeDurationMs;
|
||||
const clampedSettleDurationMs = Math.min(LOOP_CURSOR_SETTLE_DURATION_MS, Math.max(0, freezeDurationMs - 1));
|
||||
const returnMotionDurationMs = Math.max(1, freezeDurationMs - clampedSettleDurationMs);
|
||||
const sourceStartMs = firstSample.timeMs;
|
||||
const sourceEndMs = Math.max(sourceStartMs, findLastMovingSampleTime(samples));
|
||||
const sourceDurationMs = Math.max(1, sourceEndMs - sourceStartMs);
|
||||
const playbackWindowMs = Math.max(1, motionEndMs);
|
||||
const startingCursorType = findFirstStableCursorType(samples);
|
||||
const loopedSamples: CursorTelemetryPoint[] = [
|
||||
{
|
||||
...firstSample,
|
||||
timeMs: 0,
|
||||
interactionType: undefined,
|
||||
cursorType: startingCursorType,
|
||||
},
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const progress = clamp((sample.timeMs - sourceStartMs) / sourceDurationMs, 0, 1);
|
||||
const mappedTimeMs = Math.round(playbackWindowMs * progress);
|
||||
|
||||
if (mappedTimeMs <= loopedSamples[loopedSamples.length - 1].timeMs) {
|
||||
loopedSamples[loopedSamples.length - 1] = {
|
||||
...sample,
|
||||
timeMs: loopedSamples[loopedSamples.length - 1].timeMs,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
loopedSamples.push({
|
||||
...sample,
|
||||
timeMs: mappedTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
const returnStartPoint = interpolateCursorPosition(loopedSamples, motionEndMs)
|
||||
?? { cx: lastSample.cx, cy: lastSample.cy };
|
||||
const returnStartCursorType = findLatestStableCursorType(loopedSamples, motionEndMs);
|
||||
const returnMotionStartMs = motionEndMs;
|
||||
|
||||
for (let step = 0; step <= LOOP_CURSOR_RETURN_STEPS; step += 1) {
|
||||
const progress = step / LOOP_CURSOR_RETURN_STEPS;
|
||||
const easedProgress = easeOutQuint(progress);
|
||||
const timeMs = Math.round(returnMotionStartMs + returnMotionDurationMs * progress);
|
||||
loopedSamples.push({
|
||||
timeMs,
|
||||
cx: returnStartPoint.cx + (firstSample.cx - returnStartPoint.cx) * easedProgress,
|
||||
cy: returnStartPoint.cy + (firstSample.cy - returnStartPoint.cy) * easedProgress,
|
||||
interactionType: progress > 0 ? 'move' : undefined,
|
||||
cursorType: progress >= 1 ? startingCursorType : progress <= 0 ? returnStartCursorType : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (clampedSettleDurationMs > 0) {
|
||||
const settleSteps = Math.max(2, Math.round((clampedSettleDurationMs / LOOP_CURSOR_FREEZE_DURATION_MS) * LOOP_CURSOR_RETURN_STEPS));
|
||||
const settleStartMs = returnMotionStartMs + returnMotionDurationMs;
|
||||
|
||||
for (let step = 1; step <= settleSteps; step += 1) {
|
||||
const progress = step / settleSteps;
|
||||
const timeMs = Math.round(settleStartMs + clampedSettleDurationMs * progress);
|
||||
loopedSamples.push({
|
||||
timeMs,
|
||||
cx: firstSample.cx,
|
||||
cy: firstSample.cy,
|
||||
interactionType: 'move',
|
||||
cursorType: startingCursorType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return loopedSamples;
|
||||
}
|
||||
|
||||
function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
if (samples.length === 0) return null;
|
||||
|
||||
@@ -399,6 +540,9 @@ export class SmoothedCursorState {
|
||||
private trailLength: number;
|
||||
private initialized = false;
|
||||
private lastTimeMs: number | null = null;
|
||||
private lastTargetX = 0.5;
|
||||
private lastTargetY = 0.5;
|
||||
private stationaryTargetSinceMs: number | null = null;
|
||||
private xSpring = createSpringState(0.5);
|
||||
private ySpring = createSpringState(0.5);
|
||||
|
||||
@@ -413,6 +557,9 @@ export class SmoothedCursorState {
|
||||
this.y = targetY;
|
||||
this.initialized = true;
|
||||
this.lastTimeMs = timeMs;
|
||||
this.lastTargetX = targetX;
|
||||
this.lastTargetY = targetY;
|
||||
this.stationaryTargetSinceMs = timeMs;
|
||||
this.xSpring.value = targetX;
|
||||
this.ySpring.value = targetY;
|
||||
this.xSpring.velocity = 0;
|
||||
@@ -428,6 +575,28 @@ export class SmoothedCursorState {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDelta = Math.hypot(targetX - this.lastTargetX, targetY - this.lastTargetY);
|
||||
if (targetDelta <= CURSOR_STATIONARY_TARGET_EPSILON) {
|
||||
if (this.stationaryTargetSinceMs === null) {
|
||||
this.stationaryTargetSinceMs = this.lastTimeMs ?? timeMs;
|
||||
}
|
||||
} else {
|
||||
this.stationaryTargetSinceMs = null;
|
||||
}
|
||||
|
||||
this.lastTargetX = targetX;
|
||||
this.lastTargetY = targetY;
|
||||
|
||||
if (this.stationaryTargetSinceMs !== null) {
|
||||
const stationaryDurationMs = Math.max(0, timeMs - this.stationaryTargetSinceMs);
|
||||
const distanceToTarget = Math.hypot(targetX - this.x, targetY - this.y);
|
||||
if (stationaryDurationMs >= CURSOR_STATIONARY_SNAP_DELAY_MS
|
||||
&& distanceToTarget <= CURSOR_STATIONARY_SNAP_DISTANCE) {
|
||||
this.snapTo(targetX, targetY, timeMs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.trail.unshift({ x: this.x, y: this.y });
|
||||
if (this.trail.length > this.trailLength) {
|
||||
this.trail.length = this.trailLength;
|
||||
@@ -450,6 +619,9 @@ export class SmoothedCursorState {
|
||||
this.y = targetY;
|
||||
this.initialized = true;
|
||||
this.lastTimeMs = timeMs;
|
||||
this.lastTargetX = targetX;
|
||||
this.lastTargetY = targetY;
|
||||
this.stationaryTargetSinceMs = timeMs;
|
||||
this.xSpring.value = targetX;
|
||||
this.ySpring.value = targetY;
|
||||
this.xSpring.velocity = 0;
|
||||
@@ -462,6 +634,9 @@ export class SmoothedCursorState {
|
||||
reset(): void {
|
||||
this.initialized = false;
|
||||
this.lastTimeMs = null;
|
||||
this.lastTargetX = this.x;
|
||||
this.lastTargetY = this.y;
|
||||
this.stationaryTargetSinceMs = null;
|
||||
this.trail = [];
|
||||
resetSpringState(this.xSpring, this.x);
|
||||
resetSpringState(this.ySpring, this.y);
|
||||
|
||||
Reference in new issue
Block a user