|
| 1 | +/** |
| 2 | + * Pure helpers for committing a keyframe-diamond drag: pick the tween the |
| 3 | + * dragged keyframe belongs to, and compute the GSAP mutations (tween |
| 4 | + * position/duration and/or keyframe add/remove) for the move. Kept free of |
| 5 | + * React/store so the timeline drag handler stays a thin orchestrator. |
| 6 | + */ |
| 7 | + |
| 8 | +interface TweenLike { |
| 9 | + id: string; |
| 10 | + targetSelector: string; |
| 11 | + position: number | string; |
| 12 | + duration?: number; |
| 13 | + resolvedStart?: number; |
| 14 | + propertyGroup?: string; |
| 15 | + keyframes?: { keyframes: { percentage: number; properties: Record<string, number | string> }[] }; |
| 16 | +} |
| 17 | + |
| 18 | +interface ElementWindow { |
| 19 | + start: number; |
| 20 | + duration: number; |
| 21 | + domId?: string; |
| 22 | + selector?: string; |
| 23 | +} |
| 24 | + |
| 25 | +export interface KeyframeMovePlan { |
| 26 | + /** Tween timing change (start/end point drags). */ |
| 27 | + meta?: { position: number; duration: number }; |
| 28 | + /** Keyframe percentages to remove, then re-add (intermediate move / remap). */ |
| 29 | + removes: number[]; |
| 30 | + adds: { pct: number; properties: Record<string, number | string> }[]; |
| 31 | +} |
| 32 | + |
| 33 | +const round3 = (n: number) => Math.round(n * 1000) / 1000; |
| 34 | +const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100)); |
| 35 | +const MIN_DUR = 0.05; |
| 36 | + |
| 37 | +function tweenWindow(a: TweenLike): { start: number; dur: number } { |
| 38 | + return { |
| 39 | + start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0), |
| 40 | + dur: a.duration ?? 0, |
| 41 | + }; |
| 42 | +} |
| 43 | + |
| 44 | +type Kf = { percentage: number; properties: Record<string, number | string> }; |
| 45 | + |
| 46 | +/** |
| 47 | + * Remap every keyframe except `keepIdx` from the old tween window to the new one |
| 48 | + * so their absolute times stay fixed after a start/end resize. Returns the |
| 49 | + * remove/add ops (empty for flat tweens, which have no intermediates). |
| 50 | + */ |
| 51 | +function remapKeyframes( |
| 52 | + kfs: Kf[], |
| 53 | + keepIdx: number, |
| 54 | + oldStart: number, |
| 55 | + oldDur: number, |
| 56 | + newStart: number, |
| 57 | + newDur: number, |
| 58 | +): Pick<KeyframeMovePlan, "removes" | "adds"> { |
| 59 | + const removes: number[] = []; |
| 60 | + const adds: KeyframeMovePlan["adds"] = []; |
| 61 | + if (newDur <= 0) return { removes, adds }; |
| 62 | + for (let i = 0; i < kfs.length; i++) { |
| 63 | + if (i === keepIdx) continue; |
| 64 | + const k = kfs[i]!; |
| 65 | + const absT = oldStart + (k.percentage / 100) * oldDur; |
| 66 | + const remapped = clampPct(((absT - newStart) / newDur) * 100); |
| 67 | + if (Math.abs(remapped - k.percentage) < 0.05) continue; |
| 68 | + removes.push(k.percentage); |
| 69 | + adds.push({ pct: remapped, properties: k.properties }); |
| 70 | + } |
| 71 | + return { removes, adds }; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Pick the tween the dragged keyframe belongs to: restrict to the element's |
| 76 | + * selector and (if known) the keyframe's property group, then choose the one |
| 77 | + * whose time window contains — or is nearest — the keyframe's original time. |
| 78 | + * An element can have several tweens in one group (e.g. fade-in + fade-out). |
| 79 | + */ |
| 80 | +export function pickKeyframeTween<T extends TweenLike>( |
| 81 | + anims: T[], |
| 82 | + el: ElementWindow, |
| 83 | + origAbsTime: number, |
| 84 | + group: string | undefined, |
| 85 | +): T | undefined { |
| 86 | + const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean); |
| 87 | + const forEl = anims.filter((a) => selectors.includes(a.targetSelector)); |
| 88 | + const pool = forEl.length > 0 ? forEl : anims; |
| 89 | + const groupPool = group ? pool.filter((a) => a.propertyGroup === group) : []; |
| 90 | + const candidates = groupPool.length > 0 ? groupPool : pool; |
| 91 | + if (candidates.length === 0) return undefined; |
| 92 | + const dist = (a: T): number => { |
| 93 | + const { start, dur } = tweenWindow(a); |
| 94 | + if (origAbsTime >= start && origAbsTime <= start + dur) return 0; |
| 95 | + return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur))); |
| 96 | + }; |
| 97 | + return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!); |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Compute the mutations for moving a keyframe to `newPct` (clip-relative): |
| 102 | + * - start point → trim front (position moves, end fixed), |
| 103 | + * - end point → resize (duration changes, start fixed), |
| 104 | + * - intermediate → move only that keyframe; start/end moves remap the other |
| 105 | + * keyframes so their absolute times stay put. |
| 106 | + */ |
| 107 | +// fallow-ignore-next-line complexity |
| 108 | +export function computeKeyframeMovePlan( |
| 109 | + anim: TweenLike, |
| 110 | + tweenOldPct: number, |
| 111 | + el: ElementWindow, |
| 112 | + newPct: number, |
| 113 | +): KeyframeMovePlan { |
| 114 | + const newAbsTime = el.start + (newPct / 100) * el.duration; |
| 115 | + const tweenStart = tweenWindow(anim).start; |
| 116 | + const tweenDur = anim.duration ?? el.duration; |
| 117 | + const kfs = anim.keyframes |
| 118 | + ? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage) |
| 119 | + : null; |
| 120 | + const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1; |
| 121 | + |
| 122 | + if (kfs && idx > 0 && idx < kfs.length - 1) { |
| 123 | + const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0; |
| 124 | + return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] }; |
| 125 | + } |
| 126 | + |
| 127 | + const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50; |
| 128 | + let newStart = tweenStart; |
| 129 | + let newDur = tweenDur; |
| 130 | + if (isStartPoint) { |
| 131 | + const end = tweenStart + tweenDur; |
| 132 | + newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR)); |
| 133 | + newDur = end - newStart; |
| 134 | + } else { |
| 135 | + newDur = Math.max(MIN_DUR, newAbsTime - tweenStart); |
| 136 | + } |
| 137 | + |
| 138 | + const windowChanged = newStart !== tweenStart || newDur !== tweenDur; |
| 139 | + const remap = |
| 140 | + kfs && windowChanged |
| 141 | + ? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur) |
| 142 | + : { removes: [], adds: [] }; |
| 143 | + return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap }; |
| 144 | +} |
0 commit comments