transitions-refine 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/refine-live/SKILL.md +96 -12
- package/README.md +12 -2
- package/demo.html +515 -264
- package/package.json +1 -1
- package/server/inject.mjs +3 -5
- package/server/relay.mjs +69 -7
package/demo.html
CHANGED
|
@@ -249,50 +249,6 @@
|
|
|
249
249
|
@media (prefers-reduced-motion: reduce) {
|
|
250
250
|
.tl-refine-btn:hover:not(:disabled) .tl-refine-sparks i { animation: none; } }
|
|
251
251
|
|
|
252
|
-
/* ── transport row ── */
|
|
253
|
-
.tl-transport { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--c-line); }
|
|
254
|
-
.tl-transport-left { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
255
|
-
.tl-transport-center { flex: 0 0 auto; display: flex; justify-content: center; }
|
|
256
|
-
.tl-transport-right { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
|
|
257
|
-
.tl-transport .tl-ghost-btn,
|
|
258
|
-
.tl-transport .tl-icon-btn,
|
|
259
|
-
.tl-transport .tl-play-btn { border-radius: 60px; }
|
|
260
|
-
.tl-timecode { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 500; color: var(--c-text); min-width: 70px; }
|
|
261
|
-
.tl-speed { font-variant-numeric: tabular-nums; }
|
|
262
|
-
.tl-zoom { position: relative; width: 72px; height: 15px; flex: none; }
|
|
263
|
-
/* slider — Logram design system (node 13064:2539): 15px frame, track at y=5, 15px knob at y=0 */
|
|
264
|
-
.tl-zoom-track { position: absolute; left: 0; right: 0; top: 5px; height: 5px; pointer-events: none;
|
|
265
|
-
background: rgba(115,115,115,0.1); border: 1px solid rgba(0,0,0,0.08); border-radius: 7px; box-sizing: border-box; }
|
|
266
|
-
.tl-zoom input[type="range"] { position: relative; z-index: 1; width: 100%; height: 15px; margin: 0;
|
|
267
|
-
-webkit-appearance: none; appearance: none; background: transparent; outline: none; cursor: pointer; }
|
|
268
|
-
.tl-zoom input[type="range"]::-webkit-slider-runnable-track { height: 15px; background: transparent; border: none; }
|
|
269
|
-
.tl-zoom input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
|
|
270
|
-
width: 15px; height: 15px; margin-top: 0; border-radius: 50%; background: #fff; cursor: pointer;
|
|
271
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
|
|
272
|
-
.tl-zoom input[type="range"]::-moz-range-track { height: 15px; background: transparent; border: none; }
|
|
273
|
-
.tl-zoom input[type="range"]::-moz-range-thumb { width: 15px; height: 15px; border: none; border-radius: 50%;
|
|
274
|
-
background: #fff; cursor: pointer;
|
|
275
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
|
|
276
|
-
/* play/pause circle — Figma: drop shadow + inset ring overlay */
|
|
277
|
-
.tl-play-btn { position: relative; isolation: isolate; overflow: hidden;
|
|
278
|
-
display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
|
279
|
-
border: none; border-radius: 50%; background: var(--c-circle); color: #17181c;
|
|
280
|
-
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04); cursor: pointer;
|
|
281
|
-
transition: background 0.12s ease, scale 0.12s ease; }
|
|
282
|
-
.tl-play-btn::after { content: ""; position: absolute; inset: 0; border-radius: inherit;
|
|
283
|
-
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06), inset 0 -1px 0 0 rgba(0, 0, 0, 0.06),
|
|
284
|
-
inset 0 0 0 1px rgba(196, 196, 196, 0.10); pointer-events: none; }
|
|
285
|
-
.tl-play-btn > * { position: relative; z-index: 1; }
|
|
286
|
-
.tl-play-btn:hover:not(:disabled) { background: var(--c-circle-h); }
|
|
287
|
-
.tl-play-btn:active:not(:disabled) { background: var(--c-circle-a); scale: 0.96; }
|
|
288
|
-
.tl-play-btn:disabled { opacity: 0.5; cursor: default; }
|
|
289
|
-
/* center both icon layers in the swap cell so the differently-sized play (10×12)
|
|
290
|
-
and pause (16²) svgs share one center; nudge ONLY the play triangle right for
|
|
291
|
-
optical centering (skill: play-button triangles). left/position (not transform)
|
|
292
|
-
so it doesn't clash with the icon-swap scale animation; pause stays dead-center. */
|
|
293
|
-
.tl-play-btn .t-icon { place-self: center; }
|
|
294
|
-
.tl-play-btn .t-icon[data-icon="a"] { position: relative; left: 1px; }
|
|
295
|
-
|
|
296
252
|
/* ── body / floating cards row ── */
|
|
297
253
|
.tl-body { display: flex; flex: 1; min-height: 0; gap: 0; }
|
|
298
254
|
.tl-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
@@ -323,9 +279,16 @@
|
|
|
323
279
|
position: relative; z-index: 5; cursor: grabbing; }
|
|
324
280
|
.tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
|
|
325
281
|
.tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
|
|
326
|
-
text-overflow: ellipsis; text-transform: capitalize; }
|
|
282
|
+
text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
327
283
|
.tl-prop-row.selected .tl-prop-label { color: #171717; }
|
|
328
|
-
|
|
284
|
+
.tl-prop-member { flex: none; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
|
|
285
|
+
background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; max-width: 96px;
|
|
286
|
+
overflow: hidden; text-overflow: ellipsis; }
|
|
287
|
+
.tl-prop-row.selected .tl-prop-member { color: var(--c-text-mut2); }
|
|
288
|
+
.tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
289
|
+
.tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
|
|
290
|
+
background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
|
|
291
|
+
/* 16px left inset of the plot area (kept in sync with the ruler) */
|
|
329
292
|
.tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
|
|
330
293
|
/* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
|
|
331
294
|
.tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
|
|
@@ -345,13 +308,16 @@
|
|
|
345
308
|
.tl-bar-grip { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; z-index: 2; }
|
|
346
309
|
.tl-bar-grip.left { left: -5px; } .tl-bar-grip.right { right: -5px; }
|
|
347
310
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
.tl-
|
|
352
|
-
|
|
353
|
-
.tl-
|
|
354
|
-
|
|
311
|
+
/* draggable divider between the label column and the bars column. The hit
|
|
312
|
+
area is wide for easy grabbing; the visible line is thin and only accents
|
|
313
|
+
on hover / while dragging (matches the panel's 0.12s hover house style). */
|
|
314
|
+
.tl-col-resizer { position: absolute; top: 0; bottom: 0; width: 11px; transform: translateX(-50%);
|
|
315
|
+
cursor: col-resize; z-index: 6; display: flex; justify-content: center; }
|
|
316
|
+
.tl-col-resizer-line { width: 1px; height: 100%; background: transparent;
|
|
317
|
+
transition: background 0.12s ease, width 0.12s ease; }
|
|
318
|
+
.tl-col-resizer:hover .tl-col-resizer-line,
|
|
319
|
+
.tl-col-resizer.dragging .tl-col-resizer-line { width: 2px; background: #1A7AFF; }
|
|
320
|
+
@media (prefers-reduced-motion: reduce) { .tl-col-resizer-line { transition: none; } }
|
|
355
321
|
|
|
356
322
|
/* ── value field (slider + input) — exact Figma "Value slider and input" ── */
|
|
357
323
|
.tl-field-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
@@ -548,6 +514,9 @@
|
|
|
548
514
|
.tl-menu-help svg { display: block; }
|
|
549
515
|
.tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
|
|
550
516
|
.tl-menu-empty { padding: 10px; color: var(--c-disabled); font-size: 13px; }
|
|
517
|
+
.tl-menu-section { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; line-height: 14px;
|
|
518
|
+
letter-spacing: 0.02em; text-transform: uppercase; color: var(--c-text-faint); }
|
|
519
|
+
.tl-menu-section:not(:first-child) { margin-top: 2px; border-top: 1px solid var(--c-border, rgba(0,0,0,0.06)); padding-top: 8px; }
|
|
551
520
|
|
|
552
521
|
/* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
|
|
553
522
|
.t-dropdown {
|
|
@@ -1009,6 +978,13 @@
|
|
|
1009
978
|
if(cur)parts.push(cur); return parts;
|
|
1010
979
|
}
|
|
1011
980
|
function transitionSignature(props,dur,del,ease) { return [...props].sort().join(",")+"|"+dur+"|"+del+"|"+ease; }
|
|
981
|
+
// Serialise a set of effective lanes back into a CSS `transition` shorthand.
|
|
982
|
+
function transitionLanesToCss(lanes){ return (lanes||[]).map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", "); }
|
|
983
|
+
// Elements we've live-applied edited timings to. Maps element → its ORIGINAL
|
|
984
|
+
// source lanes ({z}), so the DomScanner keeps the entry pinned to the source
|
|
985
|
+
// signature/timings even while our inline `transition` overrides the DOM —
|
|
986
|
+
// otherwise the timing-based id would churn and orphan the user's edits.
|
|
987
|
+
const _TX_LIVE_BASE = new WeakMap();
|
|
1012
988
|
|
|
1013
989
|
const EASING_PRESETS = [
|
|
1014
990
|
{ keyword:"linear", cubic:[0,0,1,1] }, { keyword:"ease", cubic:[0.25,0.1,0.25,1] },
|
|
@@ -1115,36 +1091,104 @@
|
|
|
1115
1091
|
return out;
|
|
1116
1092
|
}
|
|
1117
1093
|
|
|
1118
|
-
// ── registry (per-
|
|
1094
|
+
// ── registry (per-lane overrides) ──
|
|
1095
|
+
// A "lane" is one animated property of one element. For flat DOM transitions
|
|
1096
|
+
// a lane's id is just the property; for agent-grouped phases it is
|
|
1097
|
+
// `memberId::property`, so two members can each animate e.g. `opacity`.
|
|
1098
|
+
// Selectable items are either flat DOM entries (kind "flat") or one phase of
|
|
1099
|
+
// an agent group (kind "phase"); a phase fans out into per-member lanes.
|
|
1119
1100
|
class TransitionRegistry {
|
|
1120
|
-
entries=new Map();
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1101
|
+
entries=new Map(); // flat DOM entries by id
|
|
1102
|
+
groups=[]; // agent groups: [{id,label,component,phases:[{id,phase,label,members:[...]}]}]
|
|
1103
|
+
claimedEntryIds=new Set();// flat entries covered by a group member (hidden from "Ungrouped")
|
|
1104
|
+
listeners=new Set(); laneOverrides=new Map(); snapshot=[]; effectiveCache=new Map();
|
|
1105
|
+
register(e){ this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
|
|
1106
|
+
unregister(id){ this.entries.delete(id); this.laneOverrides.delete(id); this._recomputeClaimed(); this._notify(); }
|
|
1107
|
+
replaceAll(list){ this.entries.clear(); for(const e of list) this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
|
|
1108
|
+
setGroups(groups){ this.groups = Array.isArray(groups)?groups:[]; this._recomputeClaimed(); this._notify(); }
|
|
1109
|
+
clearGroups(){ this.groups=[]; this.claimedEntryIds.clear(); this._notify(); }
|
|
1124
1110
|
get(id){ return this.entries.get(id); }
|
|
1125
1111
|
getAll(){ return this.snapshot; }
|
|
1126
1112
|
getEffective(id){ return this.effectiveCache.get(id); }
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
m
|
|
1130
|
-
|
|
1113
|
+
// overrides are keyed by selectable-item id, then by laneId
|
|
1114
|
+
setPropOverride(id, laneId, o){
|
|
1115
|
+
const m = this.laneOverrides.get(id) || {};
|
|
1116
|
+
m[laneId] = { ...m[laneId], ...o };
|
|
1117
|
+
this.laneOverrides.set(id, m);
|
|
1131
1118
|
this._notify();
|
|
1132
1119
|
}
|
|
1133
|
-
clearOverride(id){ this.
|
|
1134
|
-
getPropOverrides(id){ return this.
|
|
1120
|
+
clearOverride(id){ this.laneOverrides.delete(id); this._notify(); }
|
|
1121
|
+
getPropOverrides(id){ return this.laneOverrides.get(id); }
|
|
1135
1122
|
subscribe(fn){ this.listeners.add(fn); return ()=>this.listeners.delete(fn); }
|
|
1123
|
+
// A flat entry is "claimed" when every live element it is bound to is also
|
|
1124
|
+
// matched by some group member's selector, so we don't list it twice.
|
|
1125
|
+
_recomputeClaimed(){
|
|
1126
|
+
this.claimedEntryIds.clear();
|
|
1127
|
+
if(!this.groups.length||typeof document==="undefined")return;
|
|
1128
|
+
const memberEls=new Set();
|
|
1129
|
+
for(const g of this.groups)for(const ph of (g.phases||[]))for(const m of (ph.members||[])){
|
|
1130
|
+
if(!m.selector)continue;
|
|
1131
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
1132
|
+
for(const el of els)memberEls.add(el);
|
|
1133
|
+
}
|
|
1134
|
+
if(!memberEls.size)return;
|
|
1135
|
+
for(const[id,entry]of this.entries){
|
|
1136
|
+
const els=((entry.bindings&&entry.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
|
|
1137
|
+
if(els.length&&els.every(el=>memberEls.has(el)))this.claimedEntryIds.add(id);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
_baseLanesFlat(entry){
|
|
1141
|
+
const timings=(entry.propertyTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing})));
|
|
1142
|
+
return timings.map(t=>({laneId:t.property,property:t.property,memberId:null,member:null,selector:entry.bindings&&entry.bindings.selector,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,spring:t.spring}));
|
|
1143
|
+
}
|
|
1144
|
+
_baseLanesPhase(phase){
|
|
1145
|
+
const out=[];
|
|
1146
|
+
for(const m of (phase.members||[])){
|
|
1147
|
+
for(const t of (m.propertyTimings||[])){
|
|
1148
|
+
out.push({laneId:m.id+"::"+t.property,property:t.property,memberId:m.id,member:m.label||m.id,selector:m.selector,toState:m.toState,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,spring:t.spring});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return out;
|
|
1152
|
+
}
|
|
1153
|
+
_applyOverrides(baseLanes,po){
|
|
1154
|
+
return baseLanes.map(t=>{const o=po[t.laneId];return {...t,durationMs:o?.durationMs??t.durationMs,delayMs:o?.delayMs??t.delayMs,easing:o?.easing??t.easing,spring:o?.spring??t.spring};});
|
|
1155
|
+
}
|
|
1136
1156
|
_notify(){
|
|
1137
|
-
this.snapshot=Array.from(this.entries.values());
|
|
1138
1157
|
this.effectiveCache.clear();
|
|
1158
|
+
const items=[];
|
|
1159
|
+
// 1) agent groups → one selectable item per phase
|
|
1160
|
+
for(const g of this.groups){
|
|
1161
|
+
for(const ph of (g.phases||[])){
|
|
1162
|
+
const id=ph.id||(g.id+":"+(ph.phase||ph.label||"phase"));
|
|
1163
|
+
const po=this.laneOverrides.get(id)||{};
|
|
1164
|
+
const baseLanes=this._baseLanesPhase(ph);
|
|
1165
|
+
const effTimings=this._applyOverrides(baseLanes,po);
|
|
1166
|
+
const repDur=effTimings.reduce((mx,t)=>Math.max(mx,(t.durationMs||0)+(t.delayMs||0)),0);
|
|
1167
|
+
const members=(ph.members||[]).map(m=>{
|
|
1168
|
+
const mb=baseLanes.filter(l=>l.memberId===m.id);
|
|
1169
|
+
return {memberId:m.id,label:m.label||m.id,selector:m.selector,toState:m.toState,
|
|
1170
|
+
lanes:this._applyOverrides(mb,po),baseLanes:mb};
|
|
1171
|
+
});
|
|
1172
|
+
const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
|
|
1173
|
+
phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
|
|
1174
|
+
stateTarget:ph.stateTarget||null,fromState:(ph.fromState??null),toState:(ph.toState??null),
|
|
1175
|
+
label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
|
|
1176
|
+
members,effectiveTimings:effTimings,baseLanes};
|
|
1177
|
+
this.effectiveCache.set(id,item);
|
|
1178
|
+
items.push(item);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// 2) flat DOM entries not claimed by a group → "Ungrouped"
|
|
1139
1182
|
for(const[id,entry]of this.entries){
|
|
1140
|
-
|
|
1141
|
-
const
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1183
|
+
if(this.claimedEntryIds.has(id))continue;
|
|
1184
|
+
const po=this.laneOverrides.get(id)||{};
|
|
1185
|
+
const baseLanes=this._baseLanesFlat(entry);
|
|
1186
|
+
const effTimings=this._applyOverrides(baseLanes,po);
|
|
1187
|
+
const item={...entry,kind:"flat",groupId:null,groupLabel:null,effectiveTimings:effTimings,baseLanes};
|
|
1188
|
+
this.effectiveCache.set(id,item);
|
|
1189
|
+
items.push(item);
|
|
1147
1190
|
}
|
|
1191
|
+
this.snapshot=items;
|
|
1148
1192
|
for(const fn of this.listeners) fn(this.snapshot);
|
|
1149
1193
|
}
|
|
1150
1194
|
}
|
|
@@ -1259,6 +1303,53 @@
|
|
|
1259
1303
|
for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
|
|
1260
1304
|
return {from,to};
|
|
1261
1305
|
}
|
|
1306
|
+
// Toggle a phase's state class/attribute (e.g. ".is-open" or "[data-open]")
|
|
1307
|
+
// and read the resulting computed values, so a phase plays its real end-state
|
|
1308
|
+
// even when the source uses a toggled class the DOM isn't currently in.
|
|
1309
|
+
function _txToState(el,et,toState){
|
|
1310
|
+
if(typeof document==="undefined"||!el.matches||!toState)return null;
|
|
1311
|
+
const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
|
|
1312
|
+
if(!props.length)return null;
|
|
1313
|
+
const s=String(toState).trim();
|
|
1314
|
+
let applied=null; // {undo}
|
|
1315
|
+
try{
|
|
1316
|
+
if(s[0]==="."){const c=s.slice(1);if(c&&!el.classList.contains(c)){el.classList.add(c);applied=()=>el.classList.remove(c);}}
|
|
1317
|
+
else if(s[0]==="["){
|
|
1318
|
+
const m=s.match(/^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*"?([^"\]]*)"?\s*)?\]$/);
|
|
1319
|
+
if(m){const name=m[1],val=m[3]!=null?m[3]:"";if(el.getAttribute(name)!==val){el.setAttribute(name,val);applied=()=>el.removeAttribute(name);}}
|
|
1320
|
+
}
|
|
1321
|
+
}catch{}
|
|
1322
|
+
if(!applied)return null; // already in that state, or unparseable
|
|
1323
|
+
const cs=getComputedStyle(el);const to={};
|
|
1324
|
+
for(const p of props)to[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
|
|
1325
|
+
try{applied();}catch{}
|
|
1326
|
+
const cs0=getComputedStyle(el);const from={};
|
|
1327
|
+
for(const p of props)from[p]=cs0.getPropertyValue(p)||cs0[_txCamel(p)]||"";
|
|
1328
|
+
const keys=Object.keys(to).filter(p=>to[p]!=null&&to[p]!==""&&to[p]!==from[p]);
|
|
1329
|
+
if(!keys.length)return null;
|
|
1330
|
+
const f={},t={};for(const p of keys){f[p]=from[p];t[p]=to[p];}
|
|
1331
|
+
return {from:f,to:t};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Add/remove a single state token (".class" or "[attr=val]"/"[attr]") on an
|
|
1335
|
+
// element. Returns false for tokens we can't toggle (e.g. pseudo ":hover").
|
|
1336
|
+
function _applyStateToken(el,token,add){
|
|
1337
|
+
const s=String(token||"").trim();
|
|
1338
|
+
if(!s)return true;
|
|
1339
|
+
if(s[0]==="."){const c=s.slice(1);if(!c)return false;if(add)el.classList.add(c);else el.classList.remove(c);return true;}
|
|
1340
|
+
if(s[0]==="["){
|
|
1341
|
+
const m=s.match(/^\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*"?([^"\]]*)"?\s*)?\]$/);
|
|
1342
|
+
if(!m)return false;
|
|
1343
|
+
const name=m[1],val=m[2]!=null?m[2]:"";
|
|
1344
|
+
if(add)el.setAttribute(name,val);else el.removeAttribute(name);
|
|
1345
|
+
return true;
|
|
1346
|
+
}
|
|
1347
|
+
return false; // pseudo (:hover/:focus) or unrecognised → not class-toggleable
|
|
1348
|
+
}
|
|
1349
|
+
function _tokenToggleable(token){
|
|
1350
|
+
const s=String(token||"").trim();
|
|
1351
|
+
return !s || s[0]==="."||s[0]==="[";
|
|
1352
|
+
}
|
|
1262
1353
|
|
|
1263
1354
|
class PreviewController {
|
|
1264
1355
|
state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
|
|
@@ -1269,7 +1360,25 @@
|
|
|
1269
1360
|
subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
|
|
1270
1361
|
onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
|
|
1271
1362
|
play(entry){this.stop();this._gen++;this._current=entry;
|
|
1272
|
-
if(this.scanner)this.scanner.pause();
|
|
1363
|
+
if(this.scanner)this.scanner.pause();this._playCss(entry,this._gen);this._setState("playing");}
|
|
1364
|
+
// Resolve a selectable item to a flat list of {el, et, toState} arm targets.
|
|
1365
|
+
// Phase items fan out to their members (selector may match several elements,
|
|
1366
|
+
// e.g. staggered items); flat items use their bound DOM elements.
|
|
1367
|
+
_targets(entry){
|
|
1368
|
+
const out=[];
|
|
1369
|
+
if(entry.kind==="phase"){
|
|
1370
|
+
for(const m of (entry.members||[])){
|
|
1371
|
+
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
1372
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
1373
|
+
for(const el of els)out.push({el,et:m.lanes,toState:m.toState});
|
|
1374
|
+
}
|
|
1375
|
+
return out;
|
|
1376
|
+
}
|
|
1377
|
+
if(!entry.bindings||entry.bindings.type!=="css")return out;
|
|
1378
|
+
const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
|
|
1379
|
+
for(const wr of entry.bindings.elements){const el=wr.deref&&wr.deref();if(el)out.push({el,et,toState:null});}
|
|
1380
|
+
return out;
|
|
1381
|
+
}
|
|
1273
1382
|
pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
|
|
1274
1383
|
resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
|
|
1275
1384
|
stop(){this._stopPL();for(const a of this.animations){try{a.cancel();}catch{}}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];this._ep(0);if(this.scanner)this.scanner.unpause();this._setState("idle");}
|
|
@@ -1278,20 +1387,81 @@
|
|
|
1278
1387
|
this.stop();this._gen++;const gen=this._gen;
|
|
1279
1388
|
this._pendingSeek=seekMs;
|
|
1280
1389
|
if(this.scanner)this.scanner.pause();
|
|
1281
|
-
|
|
1390
|
+
const armed=this._armAll(entry);
|
|
1391
|
+
const els=armed.els;
|
|
1392
|
+
if(!els.length){this._setState("paused");if(seekMs!=null)this._ep(seekMs);return;}
|
|
1393
|
+
this.cleanups.push(armed.restore);
|
|
1282
1394
|
this.animations=[];
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
|
|
1290
|
-
});
|
|
1291
|
-
this.cleanups.push(restore);}
|
|
1395
|
+
requestAnimationFrame(()=>{if(this._gen!==gen)return;
|
|
1396
|
+
const running=[];for(const el of els){for(const a of el.getAnimations()){a.pause();a.playbackRate=this.rate;running.push(a);}}
|
|
1397
|
+
this.animations=running;
|
|
1398
|
+
const t=this._pendingSeek??seekMs;
|
|
1399
|
+
if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
|
|
1400
|
+
});
|
|
1292
1401
|
this._setState("paused");
|
|
1293
1402
|
if(seekMs!=null)this._ep(seekMs);
|
|
1294
1403
|
}
|
|
1404
|
+
// Snapshot/restore the toggled state on a root element.
|
|
1405
|
+
_snapshotState(el,toggleTokens){
|
|
1406
|
+
const attrs={},classes={};
|
|
1407
|
+
for(const t of toggleTokens){const s=String(t).trim();
|
|
1408
|
+
if(s[0]===".")classes[s.slice(1)]=el.classList.contains(s.slice(1));
|
|
1409
|
+
else if(s[0]==="["){const m=s.match(/^\[\s*([\w-]+)/);if(m)attrs[m[1]]=el.getAttribute(m[1]);}}
|
|
1410
|
+
return {attrs,classes};
|
|
1411
|
+
}
|
|
1412
|
+
_restoreState(el,snap){
|
|
1413
|
+
for(const c in snap.classes){if(snap.classes[c])el.classList.add(c);else el.classList.remove(c);}
|
|
1414
|
+
for(const n in snap.attrs){const v=snap.attrs[n];if(v==null)el.removeAttribute(n);else el.setAttribute(n,v);}
|
|
1415
|
+
}
|
|
1416
|
+
_setRootState(el,target,toggleTokens){
|
|
1417
|
+
for(const t of toggleTokens)_applyStateToken(el,t,false);
|
|
1418
|
+
if(target)_applyStateToken(el,target,true);
|
|
1419
|
+
}
|
|
1420
|
+
// Arm a whole phase by toggling the REAL driving state on its stateTarget,
|
|
1421
|
+
// so the actual CSS animates every member in the correct direction (open
|
|
1422
|
+
// base→state, close state→base). Edited timings are applied inline on the
|
|
1423
|
+
// member elements so they override the source. Returns {els, restore} or
|
|
1424
|
+
// null to fall back to per-member arming.
|
|
1425
|
+
_armPhase(entry,root){
|
|
1426
|
+
const fromState=entry.fromState??null, toState=entry.toState??null;
|
|
1427
|
+
if(![fromState,toState].every(_tokenToggleable))return null; // pseudo states → legacy
|
|
1428
|
+
const toggleTokens=[fromState,toState].filter(Boolean);
|
|
1429
|
+
if(!toggleTokens.length)return null;
|
|
1430
|
+
const memberEls=[];
|
|
1431
|
+
for(const m of (entry.members||[])){
|
|
1432
|
+
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
1433
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
1434
|
+
for(const el of els)memberEls.push({el,lanes:m.lanes});
|
|
1435
|
+
}
|
|
1436
|
+
if(!memberEls.length)return null;
|
|
1437
|
+
const savedRoot=this._snapshotState(root,toggleTokens);
|
|
1438
|
+
const savedStyles=memberEls.map(({el})=>el.style.cssText);
|
|
1439
|
+
// 1. suppress transitions and commit the FROM state
|
|
1440
|
+
for(const {el} of memberEls)el.style.transition="none";
|
|
1441
|
+
this._setRootState(root,fromState,toggleTokens);
|
|
1442
|
+
void root.offsetWidth;for(const {el} of memberEls)void el.offsetWidth;
|
|
1443
|
+
// 2. apply the (possibly edited) per-member timings inline
|
|
1444
|
+
for(const {el,lanes} of memberEls){
|
|
1445
|
+
el.style.transition=lanes.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
|
|
1446
|
+
}
|
|
1447
|
+
// 3. toggle to the TO state → real CSS transitions fire on every member
|
|
1448
|
+
this._setRootState(root,toState,toggleTokens);
|
|
1449
|
+
const restore=()=>{try{this._restoreState(root,savedRoot);}catch{}
|
|
1450
|
+
memberEls.forEach(({el},i)=>{try{el.style.cssText=savedStyles[i];}catch{}});};
|
|
1451
|
+
return {els:memberEls.map(m=>m.el),restore};
|
|
1452
|
+
}
|
|
1453
|
+
// Resolve an entry to armed element(s) + a restore fn. Phase items with a
|
|
1454
|
+
// toggleable stateTarget use _armPhase; everything else uses per-target arm.
|
|
1455
|
+
_armAll(entry){
|
|
1456
|
+
if(entry&&entry.kind==="phase"&&entry.stateTarget&&typeof document!=="undefined"){
|
|
1457
|
+
let root=null;try{root=document.querySelector(entry.stateTarget);}catch{}
|
|
1458
|
+
if(root){const r=this._armPhase(entry,root);if(r)return r;}
|
|
1459
|
+
}
|
|
1460
|
+
const targets=this._targets(entry);
|
|
1461
|
+
const els=[],restores=[];
|
|
1462
|
+
for(const {el,et,toState} of targets){restores.push(this._arm(el,et,toState));els.push(el);}
|
|
1463
|
+
return {els,restore:()=>{for(const c of restores){try{c();}catch{}}}};
|
|
1464
|
+
}
|
|
1295
1465
|
seek(timeMs){
|
|
1296
1466
|
if(this.state==="idle")return;
|
|
1297
1467
|
if(this.state==="playing"){for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
|
|
@@ -1307,10 +1477,12 @@
|
|
|
1307
1477
|
// 3. a synthetic opacity/transform pulse to preview the timing,
|
|
1308
1478
|
// 4. click the element (last resort, may have side effects).
|
|
1309
1479
|
// Returns a restore fn.
|
|
1310
|
-
_arm(el,et){
|
|
1480
|
+
_arm(el,et,toState){
|
|
1311
1481
|
const saved=el.style.cssText;
|
|
1312
1482
|
const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
|
|
1313
|
-
|
|
1483
|
+
// toState (a phase's driving class/attr) is the most reliable source of
|
|
1484
|
+
// the real end-state; fall back to a captured run, then CSSOM discovery.
|
|
1485
|
+
const states=(toState&&_txToState(el,et,toState))||_txCaptured(el,et)||_txDiscover(el,et);
|
|
1314
1486
|
if(states){
|
|
1315
1487
|
const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
|
|
1316
1488
|
if(props.length){
|
|
@@ -1335,13 +1507,18 @@
|
|
|
1335
1507
|
el.style.transition=tv;el.click();
|
|
1336
1508
|
return ()=>{try{el.style.cssText=saved;}catch{}};
|
|
1337
1509
|
}
|
|
1338
|
-
_playCss(entry,gen){
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1510
|
+
_playCss(entry,gen){
|
|
1511
|
+
const armed=this._armAll(entry);
|
|
1512
|
+
const els=armed.els;
|
|
1513
|
+
this.animations=[];
|
|
1514
|
+
if(!els.length){this._finish();return;}
|
|
1515
|
+
this.cleanups.push(armed.restore);
|
|
1516
|
+
requestAnimationFrame(()=>{if(this._gen!==gen)return;
|
|
1517
|
+
const running=[];for(const el of els){for(const a of el.getAnimations()){a.playbackRate=this.rate;running.push(a);}}
|
|
1518
|
+
this.animations=running;this._startPL();
|
|
1519
|
+
const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
|
|
1520
|
+
if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}
|
|
1521
|
+
});}
|
|
1345
1522
|
_startPL(){this._stopPL();const tick=()=>{if(this.animations.length>0){let cur=0;for(const a of this.animations){const c=Number(a.currentTime)||0;if(c>cur)cur=c;}this._ep(cur);}this._rafId=requestAnimationFrame(tick);};this._rafId=requestAnimationFrame(tick);}
|
|
1346
1523
|
_stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
|
|
1347
1524
|
_ep(p){for(const fn of this.progressListeners)fn(p);}
|
|
@@ -1353,7 +1530,7 @@
|
|
|
1353
1530
|
observer=null;rafId=null;running=false;paused=false;
|
|
1354
1531
|
constructor(root,reg){this.root=root;this.registry=reg;}
|
|
1355
1532
|
pause(){this.paused=true;} unpause(){this.paused=false;this._sched();}
|
|
1356
|
-
start(){if(this.running)return;this.running=true;
|
|
1533
|
+
start(){if(this.running)return;this.running=true;this.scan();this.observer=new MutationObserver(()=>this._sched());this.observer.observe(this.root,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style"]});}
|
|
1357
1534
|
stop(){this.running=false;this.observer?.disconnect();this.observer=null;if(this.rafId!==null){cancelAnimationFrame(this.rafId);this.rafId=null;}}
|
|
1358
1535
|
_sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
|
|
1359
1536
|
scan(){const seen=new Map();const w=document.createTreeWalker(this.root,NodeFilter.SHOW_ELEMENT);let n=w.currentNode;while(n){if(n instanceof HTMLElement)this._proc(n,seen);n=w.nextNode();}this.registry.replaceAll(Array.from(seen.values()));}
|
|
@@ -1365,7 +1542,11 @@
|
|
|
1365
1542
|
const durs=(s.transitionDuration||"0s").split(",");
|
|
1366
1543
|
const dels=(s.transitionDelay||"0s").split(",");
|
|
1367
1544
|
const eass=splitCssValues(s.transitionTimingFunction||"ease");
|
|
1368
|
-
|
|
1545
|
+
let z=zipTransitionLists(props,durs,dels,eass);
|
|
1546
|
+
// If we've live-applied edited timings here, keep the entry pinned to the
|
|
1547
|
+
// ORIGINAL source lanes so its id/overrides stay stable across rescans.
|
|
1548
|
+
const _lb=_TX_LIVE_BASE.get(el);
|
|
1549
|
+
if(_lb){ if(_lb.z&&_lb.z.length) z=_lb.z; else return; }
|
|
1369
1550
|
if(!z.length||z.every(x=>x.durationMs===0&&x.delayMs===0))return;
|
|
1370
1551
|
const pDur=z[0].durationMs,pDel=z[0].delayMs,pEase=z[0].easing;
|
|
1371
1552
|
const allP=z.map(x=>x.property);
|
|
@@ -1383,19 +1564,11 @@
|
|
|
1383
1564
|
const TimelineCtx = createContext(null);
|
|
1384
1565
|
function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
|
|
1385
1566
|
function useActive(){const{activeId,setActiveId,registry}=useContext(TimelineCtx);const active=useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]));return{active,setActiveId};}
|
|
1386
|
-
function usePlayback(){const{preview,registry,activeId}=useContext(TimelineCtx);const state=useSyncExternalStore(useCallback(cb=>preview.subscribe(cb),[preview]),useCallback(()=>preview.getState(),[preview]),useCallback(()=>preview.getState(),[preview]));
|
|
1387
|
-
return{state,play:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.play(e);},[preview,registry,activeId]),pause:useCallback(()=>preview.pause(),[preview]),resume:useCallback(()=>preview.resume(),[preview]),restart:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.restart(e);},[preview,registry,activeId]),stop:useCallback(()=>preview.stop(),[preview])};}
|
|
1388
1567
|
function usePropOverride(){const{registry,activeId}=useContext(TimelineCtx);return{setPropOverride:useCallback((prop,o)=>{if(activeId)registry.setPropOverride(activeId,prop,o);},[registry,activeId])};}
|
|
1389
1568
|
|
|
1390
1569
|
// ── components ──
|
|
1391
|
-
const BASE_SCALE_MS = 5000;
|
|
1392
|
-
const ZOOM_MIN = 25;
|
|
1393
|
-
const ZOOM_MAX = 400;
|
|
1394
|
-
const ZOOM_DEFAULT = 100;
|
|
1395
|
-
const scaleFromZoom = zoom => Math.round(BASE_SCALE_MS * 100 / zoom);
|
|
1396
1570
|
const LABEL_W = 150;
|
|
1397
1571
|
const CLOSE_MS = 150;
|
|
1398
|
-
const SPEEDS = [0.25, 0.5, 1, 2];
|
|
1399
1572
|
const DURATION_TOKENS = [
|
|
1400
1573
|
{label:"Duration-fast", ms:150, usage:"Quick state changes — hovers, toggles, button presses, dropdown & modal close, text swaps."},
|
|
1401
1574
|
{label:"Duration-medium", ms:250, usage:"Standard UI motion — icon swaps, dropdown & modal open, sliding tabs, page slides."},
|
|
@@ -1409,8 +1582,6 @@
|
|
|
1409
1582
|
{label:"Long", ms:300, usage:"Pronounced wait — use sparingly to draw attention."},
|
|
1410
1583
|
];
|
|
1411
1584
|
function cx(...a){ return a.filter(Boolean).join(" "); }
|
|
1412
|
-
function fmtTimecode(ms){ const m=Math.floor(ms/60000); const s=Math.floor((ms%60000)/1000); const c=Math.floor((ms%1000)/10); const p=n=>String(n).padStart(2,"0"); return p(m)+":"+p(s)+":"+p(c); }
|
|
1413
|
-
function fmtSpeed(s){ return s+"x"; }
|
|
1414
1585
|
|
|
1415
1586
|
// ── icons ──
|
|
1416
1587
|
// Exact SVGs exported from the Logram ❖ Design System Figma file (no recreations).
|
|
@@ -1445,13 +1616,6 @@
|
|
|
1445
1616
|
style:{display:"block"},dangerouslySetInnerHTML:{__html:ic.svg}});
|
|
1446
1617
|
}
|
|
1447
1618
|
|
|
1448
|
-
function usePreviewTime(){
|
|
1449
|
-
const{preview}=useContext(TimelineCtx);
|
|
1450
|
-
const[ms,setMs]=useState(0);
|
|
1451
|
-
useEffect(()=>preview.onProgress(setMs),[preview]);
|
|
1452
|
-
return ms;
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
1619
|
// portaled, origin-aware dropdown surface (transitions.dev menu-dropdown)
|
|
1456
1620
|
function Dropdown({open,onClose,triggerRef,width,align,children}){
|
|
1457
1621
|
const ref=useRef(null);
|
|
@@ -1748,17 +1912,19 @@
|
|
|
1748
1912
|
h("div",{className:"tl-refine-foot"},foot)));
|
|
1749
1913
|
}
|
|
1750
1914
|
|
|
1751
|
-
// Diff the active
|
|
1915
|
+
// Diff the active item's effective (edited/refined) lanes against their
|
|
1752
1916
|
// originally-scanned values — the set of changes Accept writes to source.
|
|
1917
|
+
// Each change carries member/selector/phase context so the agent can target
|
|
1918
|
+
// the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
|
|
1753
1919
|
function computeChanges(active){
|
|
1754
1920
|
if(!active)return[];
|
|
1755
|
-
const base=active.
|
|
1756
|
-
const bmap=new Map(base.map(t=>[t.
|
|
1921
|
+
const base=active.baseLanes||[];
|
|
1922
|
+
const bmap=new Map(base.map(t=>[t.laneId,t]));
|
|
1757
1923
|
const out=[];
|
|
1758
1924
|
for(const t of (active.effectiveTimings||[])){
|
|
1759
|
-
const b=bmap.get(t.
|
|
1925
|
+
const b=bmap.get(t.laneId);if(!b)continue;
|
|
1760
1926
|
if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
|
|
1761
|
-
out.push({property:t.property,
|
|
1927
|
+
out.push({property:t.property,member:t.member||null,selector:t.selector||null,
|
|
1762
1928
|
from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
|
|
1763
1929
|
to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
|
|
1764
1930
|
}
|
|
@@ -1766,10 +1932,26 @@
|
|
|
1766
1932
|
return out;
|
|
1767
1933
|
}
|
|
1768
1934
|
|
|
1769
|
-
function Header({entries,active,onSelect,onReset,onCopy,copied,
|
|
1935
|
+
function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
|
|
1770
1936
|
const[pick,setPick]=useState(false);
|
|
1771
1937
|
const[setg,setSetg]=useState(false);
|
|
1772
1938
|
const pickRef=useRef(null), gearRef=useRef(null);
|
|
1939
|
+
// Split selectable items into agent groups (component → phases) and the
|
|
1940
|
+
// flat, ungrouped DOM transitions, preserving order.
|
|
1941
|
+
const groupList=[]; const groupMap=new Map(); const flatItems=[];
|
|
1942
|
+
for(const e of entries){
|
|
1943
|
+
if(e.kind==="phase"){
|
|
1944
|
+
let g=groupMap.get(e.groupId);
|
|
1945
|
+
if(!g){g={id:e.groupId,label:e.groupLabel,phases:[]};groupMap.set(e.groupId,g);groupList.push(g);}
|
|
1946
|
+
g.phases.push(e);
|
|
1947
|
+
}else flatItems.push(e);
|
|
1948
|
+
}
|
|
1949
|
+
const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
|
|
1950
|
+
right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
|
|
1951
|
+
h("span",null,e.phaseLabel,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
|
|
1952
|
+
const flatItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
|
|
1953
|
+
right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
|
|
1954
|
+
h("span",null,e.label,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
|
|
1773
1955
|
return h("div",{className:"tl-header"},
|
|
1774
1956
|
h("span",{className:"tl-header-label"},"Selected"),
|
|
1775
1957
|
h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
|
|
@@ -1779,13 +1961,16 @@
|
|
|
1779
1961
|
h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
|
|
1780
1962
|
entries.length===0
|
|
1781
1963
|
? h("div",{className:"tl-menu-empty"},"No transitions found")
|
|
1782
|
-
:
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1964
|
+
: h(React.Fragment,null,
|
|
1965
|
+
...groupList.map(g=>h(React.Fragment,{key:g.id},
|
|
1966
|
+
h("div",{className:"tl-menu-section"},g.label),
|
|
1967
|
+
...g.phases.map(phaseItem))),
|
|
1968
|
+
flatItems.length>0&&groupList.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
|
|
1969
|
+
...flatItems.map(flatItem))),
|
|
1970
|
+
h("span",{className:"tl-header-count"},
|
|
1971
|
+
scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
|
|
1786
1972
|
h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
|
|
1787
1973
|
h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
|
|
1788
|
-
h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
|
|
1789
1974
|
h(MenuItem,{onClick:()=>setSnap(v=>!v),right:snap&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Snap to grid")),
|
|
1790
1975
|
h("span",{className:"t-tt-wrap"},
|
|
1791
1976
|
h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
|
|
@@ -1826,7 +2011,7 @@
|
|
|
1826
2011
|
);
|
|
1827
2012
|
}
|
|
1828
2013
|
|
|
1829
|
-
function PropTrack({property, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
|
|
2014
|
+
function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
|
|
1830
2015
|
const trackRef=useRef(null);
|
|
1831
2016
|
const grid = snap ? 25 : 1;
|
|
1832
2017
|
const pxToMs=useCallback(px=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;return Math.round((px/w)*scaleMs/grid)*grid;},[grid,scaleMs]);
|
|
@@ -1854,9 +2039,11 @@
|
|
|
1854
2039
|
|
|
1855
2040
|
const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
|
|
1856
2041
|
return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
|
|
1857
|
-
h("div",{className:"tl-prop-head"},
|
|
2042
|
+
h("div",{className:"tl-prop-head",style:labelW!=null?{flexBasis:labelW+"px"}:undefined},
|
|
1858
2043
|
h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
|
|
1859
|
-
h("span",{className:"tl-prop-label"},
|
|
2044
|
+
h("span",{className:"tl-prop-label"},
|
|
2045
|
+
member&&h("span",{className:"tl-prop-member"},member),
|
|
2046
|
+
h("span",{className:"tl-prop-prop"},property))),
|
|
1860
2047
|
h("div",{className:"tl-prop-track",ref:trackRef},
|
|
1861
2048
|
h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
|
|
1862
2049
|
title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
|
|
@@ -2235,82 +2422,63 @@
|
|
|
2235
2422
|
);
|
|
2236
2423
|
}
|
|
2237
2424
|
|
|
2238
|
-
function
|
|
2239
|
-
const { preview, registry, activeId } = useContext(TimelineCtx);
|
|
2240
|
-
const areaRef = useRef(null);
|
|
2241
|
-
const startedRef = useRef(false);
|
|
2242
|
-
|
|
2243
|
-
const pxToMs = useCallback(clientX=>{
|
|
2244
|
-
if(!areaRef.current) return 0;
|
|
2245
|
-
const rect = areaRef.current.getBoundingClientRect();
|
|
2246
|
-
const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
|
|
2247
|
-
return ratio * scaleMs;
|
|
2248
|
-
},[scaleMs]);
|
|
2249
|
-
|
|
2250
|
-
const doSeek = useCallback(ms=>{
|
|
2251
|
-
if(preview.getState()==="idle" && !startedRef.current){
|
|
2252
|
-
if(!activeId) return;
|
|
2253
|
-
const entry = registry.getEffective(activeId);
|
|
2254
|
-
if(!entry) return;
|
|
2255
|
-
startedRef.current = true;
|
|
2256
|
-
preview.playPaused(entry, ms);
|
|
2257
|
-
} else {
|
|
2258
|
-
preview.seek(ms);
|
|
2259
|
-
}
|
|
2260
|
-
},[preview,registry,activeId]);
|
|
2261
|
-
|
|
2262
|
-
const startScrub = useCallback(e=>{
|
|
2263
|
-
e.preventDefault();
|
|
2264
|
-
startedRef.current = false;
|
|
2265
|
-
doSeek(pxToMs(e.clientX));
|
|
2266
|
-
const onMove = e2 => doSeek(pxToMs(e2.clientX));
|
|
2267
|
-
const onUp = () => { startedRef.current = false; window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2268
|
-
window.addEventListener("mousemove",onMove);
|
|
2269
|
-
window.addEventListener("mouseup",onUp);
|
|
2270
|
-
},[doSeek,pxToMs]);
|
|
2271
|
-
|
|
2272
|
-
return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
|
-
function Tracks({et, selProp, setSelProp, onPropChange, snap, scaleMs}){
|
|
2276
|
-
const t = usePreviewTime();
|
|
2277
|
-
const ratio = Math.min(t / scaleMs, 1);
|
|
2425
|
+
function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
|
|
2278
2426
|
const ROW_H = 48;
|
|
2279
2427
|
const rowsRef = useRef(null);
|
|
2428
|
+
const tracksRef = useRef(null);
|
|
2280
2429
|
const [order, setOrder] = useState([]);
|
|
2281
|
-
const [
|
|
2282
|
-
|
|
2430
|
+
const [dragLane, setDragLane] = useState(null);
|
|
2431
|
+
// Width of the label column (shared by the ruler spacer + every prop-row
|
|
2432
|
+
// head). Draggable via the divider; the bars column is flex:1 so it just
|
|
2433
|
+
// takes the remaining space and the bars stay proportional to scaleMs.
|
|
2434
|
+
const LABEL_MIN = 96, LABEL_TRACK_MIN = 160;
|
|
2435
|
+
const [labelW, setLabelW] = useState(LABEL_W);
|
|
2436
|
+
const [colDragging, setColDragging] = useState(false);
|
|
2437
|
+
const startColResize = useCallback(e=>{
|
|
2438
|
+
e.preventDefault(); e.stopPropagation();
|
|
2439
|
+
const startX = e.clientX, startW = labelW;
|
|
2440
|
+
const maxW = tracksRef.current
|
|
2441
|
+
? Math.max(LABEL_MIN, tracksRef.current.getBoundingClientRect().width - LABEL_TRACK_MIN)
|
|
2442
|
+
: 360;
|
|
2443
|
+
setColDragging(true);
|
|
2444
|
+
document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none";
|
|
2445
|
+
const onMove = e2 => setLabelW(Math.max(LABEL_MIN, Math.min(maxW, startW + (e2.clientX - startX))));
|
|
2446
|
+
const onUp = () => { setColDragging(false); document.body.style.cursor=""; document.body.style.userSelect="";
|
|
2447
|
+
window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2448
|
+
window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
|
|
2449
|
+
},[labelW]);
|
|
2450
|
+
const laneKey = et.map(x=>x.laneId).join("|");
|
|
2283
2451
|
useEffect(()=>{
|
|
2284
|
-
const
|
|
2452
|
+
const ids = et.map(x=>x.laneId);
|
|
2285
2453
|
setOrder(prev=>{
|
|
2286
|
-
const kept = prev.filter(p=>
|
|
2287
|
-
const added =
|
|
2454
|
+
const kept = prev.filter(p=>ids.includes(p));
|
|
2455
|
+
const added = ids.filter(p=>!kept.includes(p));
|
|
2288
2456
|
return [...kept, ...added];
|
|
2289
2457
|
});
|
|
2290
|
-
},[
|
|
2291
|
-
const orderedRows = order.map(p=>et.find(x=>x.
|
|
2292
|
-
const startReorder = useCallback((
|
|
2458
|
+
},[laneKey]);
|
|
2459
|
+
const orderedRows = order.map(p=>et.find(x=>x.laneId===p)).filter(Boolean);
|
|
2460
|
+
const startReorder = useCallback((laneId,e)=>{
|
|
2293
2461
|
e.preventDefault(); e.stopPropagation();
|
|
2294
|
-
|
|
2295
|
-
|
|
2462
|
+
setSelLane(laneId);
|
|
2463
|
+
setDragLane(laneId);
|
|
2296
2464
|
const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
|
|
2297
2465
|
const onMove = e2=>{
|
|
2298
2466
|
const idx = Math.floor((e2.clientY - top) / ROW_H);
|
|
2299
2467
|
setOrder(prev=>{
|
|
2300
|
-
const cur = prev.indexOf(
|
|
2468
|
+
const cur = prev.indexOf(laneId);
|
|
2301
2469
|
if(cur<0) return prev;
|
|
2302
2470
|
const target = Math.max(0, Math.min(prev.length-1, idx));
|
|
2303
2471
|
if(target===cur) return prev;
|
|
2304
2472
|
const next = prev.slice();
|
|
2305
2473
|
next.splice(cur,1);
|
|
2306
|
-
next.splice(target,0,
|
|
2474
|
+
next.splice(target,0,laneId);
|
|
2307
2475
|
return next;
|
|
2308
2476
|
});
|
|
2309
2477
|
};
|
|
2310
|
-
const onUp = ()=>{
|
|
2478
|
+
const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2311
2479
|
window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
|
|
2312
|
-
},[
|
|
2313
|
-
const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
|
|
2480
|
+
},[setSelLane]);
|
|
2481
|
+
const majorStep = scaleMs <= 2000 ? 500 : scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
|
|
2314
2482
|
const minorStep = majorStep / 4;
|
|
2315
2483
|
const ruler=[];
|
|
2316
2484
|
for(let ms=0; ms<=scaleMs; ms+=majorStep){
|
|
@@ -2321,107 +2489,75 @@
|
|
|
2321
2489
|
if(ms%majorStep===0) continue;
|
|
2322
2490
|
ruler.push(h("span",{key:"t"+ms,className:"tick",style:{left:((ms/scaleMs)*100)+"%"}}));
|
|
2323
2491
|
}
|
|
2324
|
-
return h("div",{className:"tl-tracks"},
|
|
2492
|
+
return h("div",{className:"tl-tracks",ref:tracksRef},
|
|
2325
2493
|
h("div",{className:"tl-ruler-row"},
|
|
2326
|
-
h("div",{className:"tl-ruler-spacer"}),
|
|
2494
|
+
h("div",{className:"tl-ruler-spacer",style:{flexBasis:labelW+"px"}}),
|
|
2327
2495
|
h("div",{className:"tl-ruler"},...ruler)),
|
|
2328
2496
|
h("div",{className:"tl-rows",ref:rowsRef},
|
|
2329
2497
|
...orderedRows.map(row=>h(PropTrack,{
|
|
2330
|
-
key:row.
|
|
2498
|
+
key:row.laneId, property:row.property, member:row.member,
|
|
2331
2499
|
delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
|
|
2332
|
-
selected:row.
|
|
2333
|
-
onSelect:()=>
|
|
2334
|
-
onReorder:e=>startReorder(row.
|
|
2335
|
-
onDelayChange:ms=>onPropChange(row.
|
|
2336
|
-
onDurationChange:ms=>onPropChange(row.
|
|
2500
|
+
selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs, labelW,
|
|
2501
|
+
onSelect:()=>setSelLane(row.laneId),
|
|
2502
|
+
onReorder:e=>startReorder(row.laneId,e),
|
|
2503
|
+
onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
|
|
2504
|
+
onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
|
|
2337
2505
|
}))),
|
|
2338
|
-
h(
|
|
2339
|
-
|
|
2340
|
-
h("
|
|
2341
|
-
h("svg",{className:"tl-playhead-head",viewBox:"0 0 16.666 24",width:"16.666",height:"24",fill:"none"},
|
|
2342
|
-
h("path",{d:"M13.666 7.333C13.666 11.609 9.333 15.666 9.333 19.942L9.333 24L7.333 24L7.333 19.942C7.333 15.666 3 11.609 3 7.333C3 4.387 5.387 2 8.333 2C11.279 2 13.666 4.387 13.666 7.333Z",fill:"#1A7AFF"}))),
|
|
2343
|
-
),
|
|
2506
|
+
h("div",{className:cx("tl-col-resizer",colDragging&&"dragging"),style:{left:labelW+"px"},
|
|
2507
|
+
onMouseDown:startColResize,title:"Drag to resize columns","aria-label":"Resize label column",role:"separator"},
|
|
2508
|
+
h("span",{className:"tl-col-resizer-line"})),
|
|
2344
2509
|
);
|
|
2345
2510
|
}
|
|
2346
2511
|
|
|
2347
|
-
function Inspector({entry,
|
|
2512
|
+
function Inspector({entry, selLane, onPropChange, snap}){
|
|
2348
2513
|
const et = entry.effectiveTimings || [];
|
|
2349
|
-
const sel = et.find(t=>t.
|
|
2514
|
+
const sel = et.find(t=>t.laneId===selLane) || et[0];
|
|
2350
2515
|
if(!sel) return h("div",{className:"tl-inspector"});
|
|
2351
2516
|
const cubic = easingToCubic(sel.easing);
|
|
2352
2517
|
const isSpring = !!sel.spring;
|
|
2353
2518
|
return h("div",{className:"tl-inspector"},
|
|
2354
|
-
h("div",{className:"tl-insp-title"},
|
|
2519
|
+
h("div",{className:"tl-insp-title"},
|
|
2520
|
+
sel.member&&h("span",{className:"tl-insp-member"},sel.member),
|
|
2521
|
+
h("span",null,sel.property)),
|
|
2355
2522
|
h(ValueField,{label:"Duration",value:sel.durationMs,min:0,max:5000,step:25,tokens:DURATION_TOKENS,snap,
|
|
2356
2523
|
readOnly:isSpring,
|
|
2357
2524
|
readOnlyHint:"Duration is set by the spring. A spring's settle time is derived from its stiffness, damping and mass \u2014 so it can't be edited directly. Switch to the Easing tab to set a fixed duration.",
|
|
2358
|
-
onChange:v=>onPropChange(sel.
|
|
2525
|
+
onChange:v=>onPropChange(sel.laneId,{durationMs:v})}),
|
|
2359
2526
|
h(ValueField,{label:"Delay",value:sel.delayMs,min:0,max:5000,step:25,tokens:DELAY_TOKENS,snap,
|
|
2360
|
-
onChange:v=>onPropChange(sel.
|
|
2361
|
-
h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.
|
|
2362
|
-
apply:partial=>onPropChange(sel.
|
|
2527
|
+
onChange:v=>onPropChange(sel.laneId,{delayMs:v})}),
|
|
2528
|
+
h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.laneId,
|
|
2529
|
+
apply:partial=>onPropChange(sel.laneId,partial)}),
|
|
2363
2530
|
);
|
|
2364
2531
|
}
|
|
2365
2532
|
|
|
2366
|
-
function Body({entry, onPropChange,
|
|
2533
|
+
function Body({entry, onPropChange, snap}){
|
|
2367
2534
|
const et = entry.effectiveTimings || [];
|
|
2368
|
-
const [
|
|
2369
|
-
|
|
2370
|
-
|
|
2535
|
+
const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
|
|
2536
|
+
// No playback: the real component is the preview. The track scale just
|
|
2537
|
+
// auto-fits the longest lane (duration+delay) with a little headroom so
|
|
2538
|
+
// the static bars stay readable across short and long transitions.
|
|
2539
|
+
const maxEnd = et.reduce((m,t)=>Math.max(m,(t.durationMs||0)+(t.delayMs||0)),0);
|
|
2540
|
+
const scaleMs = Math.max(500, Math.ceil((maxEnd*1.15)/250)*250);
|
|
2371
2541
|
useEffect(()=>{
|
|
2372
|
-
if(et.length && !et.find(t=>t.
|
|
2373
|
-
},[et,
|
|
2542
|
+
if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
|
|
2543
|
+
},[et,selLane]);
|
|
2374
2544
|
|
|
2375
2545
|
return h("div",{className:"tl-body"},
|
|
2376
2546
|
h("div",{className:"tl-main"},
|
|
2377
|
-
h(
|
|
2378
|
-
h(Tracks,{et,selProp,setSelProp,onPropChange,snap,scaleMs}),
|
|
2379
|
-
),
|
|
2380
|
-
h(Inspector,{entry,selProp,onPropChange,snap}),
|
|
2381
|
-
);
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
function Transport({state,disabled,onPlay,onPause,onResume,onRestart,onStop,speed,setSpeed,zoom,setZoom}){
|
|
2385
|
-
const t = usePreviewTime();
|
|
2386
|
-
const spRef = useRef(null);
|
|
2387
|
-
const [sp,setSp] = useState(false);
|
|
2388
|
-
const playing = state==="playing";
|
|
2389
|
-
const onMain = () => playing ? onPause() : state==="paused" ? onResume() : onPlay();
|
|
2390
|
-
return h("div",{className:"tl-transport"},
|
|
2391
|
-
h("div",{className:"tl-transport-left"},
|
|
2392
|
-
h("span",{className:"tl-timecode"}, fmtTimecode(t)),
|
|
2393
|
-
h("button",{ref:spRef,className:cx("tl-ghost-btn","tl-speed",sp&&"is-active"),onClick:()=>setSp(v=>!v)},
|
|
2394
|
-
fmtSpeed(speed), h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
|
|
2395
|
-
h(Dropdown,{open:sp,onClose:()=>setSp(false),triggerRef:spRef,width:120,align:"left"},
|
|
2396
|
-
SPEEDS.map(s=>h(MenuItem,{key:s,active:s===speed,onClick:()=>{setSpeed(s);setSp(false);},
|
|
2397
|
-
right:s===speed&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},fmtSpeed(s)))) ),
|
|
2398
|
-
h("div",{className:"tl-transport-center"},
|
|
2399
|
-
h("button",{className:"tl-play-btn",disabled,onClick:onMain,title:playing?"Pause":"Play"},
|
|
2400
|
-
h("span",{className:"t-icon-swap","data-state":playing?"b":"a"},
|
|
2401
|
-
h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"play",size:12})),
|
|
2402
|
-
h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"pause",size:16}))))),
|
|
2403
|
-
h("div",{className:"tl-transport-right"},
|
|
2404
|
-
h("div",{className:"tl-zoom",title:"Timeline zoom"},
|
|
2405
|
-
h("div",{className:"tl-zoom-track","aria-hidden":true}),
|
|
2406
|
-
h("input",{type:"range",min:ZOOM_MIN,max:ZOOM_MAX,value:zoom,
|
|
2407
|
-
onChange:e=>setZoom(Number(e.target.value))})),
|
|
2408
|
-
h("span",{className:"t-tt-wrap"},
|
|
2409
|
-
h("button",{className:"tl-icon-btn ghost t-tt-trigger",disabled,"aria-label":"Replay",onClick:onRestart},h(Ic,{name:"restart"})),
|
|
2410
|
-
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Replay")),
|
|
2547
|
+
h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
|
|
2411
2548
|
),
|
|
2549
|
+
h(Inspector,{entry,selLane,onPropChange,snap}),
|
|
2412
2550
|
);
|
|
2413
2551
|
}
|
|
2414
2552
|
|
|
2415
2553
|
function TimelinePanel(){
|
|
2416
2554
|
const entries=useReg(); const{active,setActiveId}=useActive();
|
|
2417
|
-
const{
|
|
2418
|
-
const{registry
|
|
2555
|
+
const{setPropOverride}=usePropOverride();
|
|
2556
|
+
const{registry}=useContext(TimelineCtx);
|
|
2419
2557
|
const[copied,setCopied]=useState(false);
|
|
2420
2558
|
const[minimized,setMinimized]=useState(false);
|
|
2421
2559
|
const[panelHeight,setPanelHeight]=useState(440);
|
|
2422
2560
|
const[resizing,setResizing]=useState(false);
|
|
2423
|
-
const[speed,setSpeed]=useState(1);
|
|
2424
|
-
const[loop,setLoop]=useState(false);
|
|
2425
2561
|
const[snap,setSnap]=useState(true);
|
|
2426
2562
|
// ── refine ──
|
|
2427
2563
|
const[refineOpen,setRefineOpen]=useState(false);
|
|
@@ -2434,6 +2570,9 @@
|
|
|
2434
2570
|
// ── accept (write to source) ──
|
|
2435
2571
|
const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
|
|
2436
2572
|
const[acceptError,setAcceptError]=useState(null);
|
|
2573
|
+
// ── grouped scan (agent reads source → Open/Close phases) ──
|
|
2574
|
+
const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
|
|
2575
|
+
const didGroupScanRef=useRef(false);
|
|
2437
2576
|
const[refineLabel,setRefineLabel]=useState(null);
|
|
2438
2577
|
const[appliedIds,setAppliedIds]=useState({});
|
|
2439
2578
|
const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
|
|
@@ -2467,8 +2606,11 @@
|
|
|
2467
2606
|
const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
|
|
2468
2607
|
if(mode!==refineMode)setRefineMode(mode);
|
|
2469
2608
|
try{
|
|
2470
|
-
const
|
|
2471
|
-
const
|
|
2609
|
+
const et=active.effectiveTimings||[];
|
|
2610
|
+
const timings=et.map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
|
|
2611
|
+
const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
|
|
2612
|
+
const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
|
|
2613
|
+
phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType});
|
|
2472
2614
|
setRefineJobId(id);
|
|
2473
2615
|
}catch(e){
|
|
2474
2616
|
setRefinePhase("error");
|
|
@@ -2500,17 +2642,74 @@
|
|
|
2500
2642
|
if(p.durationMs!=null)o.durationMs=p.durationMs;
|
|
2501
2643
|
if(p.delayMs!=null)o.delayMs=p.delayMs;
|
|
2502
2644
|
if(p.easing!=null)o.easing=p.easing;
|
|
2503
|
-
|
|
2645
|
+
// map a property patch onto the matching lane(s) — for a grouped phase the
|
|
2646
|
+
// same property can appear on several members, so apply to each.
|
|
2647
|
+
const et=(active&&active.effectiveTimings)||[];
|
|
2648
|
+
let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
|
|
2649
|
+
if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
|
|
2650
|
+
const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
|
|
2651
|
+
for(const id of ids)setPropOverride(id,o);
|
|
2504
2652
|
setAppliedIds(prev=>({...prev,[s.id]:true}));
|
|
2505
|
-
},[setPropOverride]);
|
|
2653
|
+
},[setPropOverride,active]);
|
|
2506
2654
|
const applyAllSuggestions=useCallback(()=>{
|
|
2507
2655
|
for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
|
|
2508
2656
|
},[refineSuggestions,appliedIds,applySuggestion]);
|
|
2509
2657
|
const panelMinH=200;
|
|
2510
2658
|
const panelMaxH=useCallback(()=>Math.round(window.innerHeight*0.92),[]);
|
|
2511
2659
|
useEffect(()=>{if(!active&&entries.length>0)setActiveId(entries[0].id);},[active,entries,setActiveId]);
|
|
2512
|
-
|
|
2513
|
-
|
|
2660
|
+
// ── live styling ──────────────────────────────────────────────────────
|
|
2661
|
+
// With playback gone, the real component IS the preview. So whenever a
|
|
2662
|
+
// transition has edits, mirror its effective timings onto the live
|
|
2663
|
+
// element(s) as an inline `transition`, and revert when edits are cleared.
|
|
2664
|
+
// Interacting with the component then animates with the edited values.
|
|
2665
|
+
const liveAppliedRef=useRef(new Map()); // el → {prevInline, css}
|
|
2666
|
+
useEffect(()=>{
|
|
2667
|
+
if(typeof document==="undefined")return;
|
|
2668
|
+
const applied=liveAppliedRef.current;
|
|
2669
|
+
const desired=new Map(); // el → css
|
|
2670
|
+
const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel]")))continue; desired.set(el,css); } };
|
|
2671
|
+
for(const item of entries){
|
|
2672
|
+
const ov=registry.getPropOverrides(item.id);
|
|
2673
|
+
if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
|
|
2674
|
+
if(item.kind==="phase"){
|
|
2675
|
+
for(const m of (item.members||[])){
|
|
2676
|
+
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
2677
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
2678
|
+
add(els,transitionLanesToCss(m.lanes));
|
|
2679
|
+
}
|
|
2680
|
+
}else{
|
|
2681
|
+
const lanes=item.effectiveTimings||[];
|
|
2682
|
+
if(!lanes.length)continue;
|
|
2683
|
+
const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
|
|
2684
|
+
add(els,transitionLanesToCss(lanes));
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
// apply new / changed
|
|
2688
|
+
for(const [el,css] of desired){
|
|
2689
|
+
const cur=applied.get(el);
|
|
2690
|
+
if(cur&&cur.css===css)continue;
|
|
2691
|
+
if(!cur){
|
|
2692
|
+
const s=getComputedStyle(el);
|
|
2693
|
+
const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
|
|
2694
|
+
_TX_LIVE_BASE.set(el,{z});
|
|
2695
|
+
applied.set(el,{prevInline:el.style.transition||"",css});
|
|
2696
|
+
}else applied.set(el,{prevInline:cur.prevInline,css});
|
|
2697
|
+
try{el.style.transition=css;}catch{}
|
|
2698
|
+
}
|
|
2699
|
+
// revert elements that no longer have edits
|
|
2700
|
+
for(const [el,rec] of Array.from(applied)){
|
|
2701
|
+
if(desired.has(el))continue;
|
|
2702
|
+
try{el.style.transition=rec.prevInline;}catch{}
|
|
2703
|
+
_TX_LIVE_BASE.delete(el);
|
|
2704
|
+
applied.delete(el);
|
|
2705
|
+
}
|
|
2706
|
+
},[entries,registry]);
|
|
2707
|
+
// restore everything on unmount
|
|
2708
|
+
useEffect(()=>()=>{
|
|
2709
|
+
const applied=liveAppliedRef.current;
|
|
2710
|
+
for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} _TX_LIVE_BASE.delete(el); }
|
|
2711
|
+
applied.clear();
|
|
2712
|
+
},[]);
|
|
2514
2713
|
const startResize=useCallback(e=>{
|
|
2515
2714
|
e.preventDefault();
|
|
2516
2715
|
const startY=e.clientY; const startH=panelHeight;
|
|
@@ -2524,10 +2723,19 @@
|
|
|
2524
2723
|
const copyValues=useCallback(()=>{
|
|
2525
2724
|
if(!active)return;
|
|
2526
2725
|
const et=active.effectiveTimings||[];
|
|
2527
|
-
const
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2726
|
+
const decl=t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`;
|
|
2727
|
+
let text;
|
|
2728
|
+
if(et.some(t=>t.member)){
|
|
2729
|
+
// grouped phase: one transition block per member, labelled
|
|
2730
|
+
const byMember=new Map();
|
|
2731
|
+
for(const t of et){const k=t.memberId||t.member||"";if(!byMember.has(k))byMember.set(k,{member:t.member,selector:t.selector,lanes:[]});byMember.get(k).lanes.push(t);}
|
|
2732
|
+
text=[...byMember.values()].map(m=>
|
|
2733
|
+
`/* ${m.member||"element"}${m.selector?" — "+m.selector:""} */\ntransition: ${m.lanes.map(decl).join(",\n ")};`
|
|
2734
|
+
).join("\n\n");
|
|
2735
|
+
}else{
|
|
2736
|
+
text="transition: "+et.map(decl).join(",\n ")+";";
|
|
2737
|
+
}
|
|
2738
|
+
navigator.clipboard.writeText(text).then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);});
|
|
2531
2739
|
},[active]);
|
|
2532
2740
|
const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
|
|
2533
2741
|
// Accept → send an "apply" job so the agent writes the edited timings into
|
|
@@ -2538,7 +2746,9 @@
|
|
|
2538
2746
|
setAcceptState("saving");setAcceptError(null);
|
|
2539
2747
|
try{
|
|
2540
2748
|
const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
|
|
2541
|
-
selector:active.bindings&&active.bindings.selector,
|
|
2749
|
+
selector:active.bindings&&active.bindings.selector,
|
|
2750
|
+
group:active.groupLabel||null,phase:active.phase||null,component:active.component||null,
|
|
2751
|
+
changes});
|
|
2542
2752
|
let settled=false;
|
|
2543
2753
|
for(let i=0;i<240&&!settled;i++){
|
|
2544
2754
|
await new Promise(r=>setTimeout(r,500));
|
|
@@ -2559,6 +2769,47 @@
|
|
|
2559
2769
|
},[active]);
|
|
2560
2770
|
// reset Accept feedback when switching transitions
|
|
2561
2771
|
useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
|
|
2772
|
+
// Auto-run the grouped scan once, after the flat DOM scan has populated.
|
|
2773
|
+
// The agent reads the source and returns Open/Close phases; if no agent is
|
|
2774
|
+
// live (or the relay is down) we silently keep the flat DOM scan.
|
|
2775
|
+
// Lifecycle is tied to `registry` only (a stable ref), never to
|
|
2776
|
+
// `entries.length`. On dynamic pages the DOM scanner makes entries.length
|
|
2777
|
+
// fluctuate; keying the effect on it tore down the in-flight poll before
|
|
2778
|
+
// the agent answered and the once-guard blocked a restart → permanent
|
|
2779
|
+
// "Grouping…". Here a single run waits for flat entries to appear, fires
|
|
2780
|
+
// one scan job, then polls to completion regardless of DOM churn; only an
|
|
2781
|
+
// actual unmount cancels it.
|
|
2782
|
+
useEffect(()=>{
|
|
2783
|
+
if(didGroupScanRef.current)return;
|
|
2784
|
+
let stopped=false;
|
|
2785
|
+
const run=async()=>{
|
|
2786
|
+
while(!stopped){
|
|
2787
|
+
const flat0=registry.getAll().filter(e=>e.kind!=="phase");
|
|
2788
|
+
if(flat0.length)break;
|
|
2789
|
+
await new Promise(r=>setTimeout(r,300));
|
|
2790
|
+
}
|
|
2791
|
+
if(stopped||didGroupScanRef.current)return;
|
|
2792
|
+
didGroupScanRef.current=true;
|
|
2793
|
+
setGroupScanState("scanning");
|
|
2794
|
+
const flat=registry.getAll().filter(e=>e.kind!=="phase");
|
|
2795
|
+
const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
|
|
2796
|
+
properties:e.properties,
|
|
2797
|
+
timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
|
|
2798
|
+
try{
|
|
2799
|
+
const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
|
|
2800
|
+
for(let i=0;i<520&&!stopped;i++){
|
|
2801
|
+
await new Promise(r=>setTimeout(r,500));
|
|
2802
|
+
const job=await relayGetJob(id);
|
|
2803
|
+
if(stopped)return;
|
|
2804
|
+
if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
|
|
2805
|
+
if(job.status==="error"){setGroupScanState("error");return;}
|
|
2806
|
+
}
|
|
2807
|
+
if(!stopped)setGroupScanState("error");
|
|
2808
|
+
}catch(e){ if(!stopped)setGroupScanState("error"); /* relay down → stay flat */ }
|
|
2809
|
+
};
|
|
2810
|
+
run();
|
|
2811
|
+
return ()=>{stopped=true;};
|
|
2812
|
+
},[registry]);
|
|
2562
2813
|
|
|
2563
2814
|
// whole-component open/close uses the transitions.dev panel reveal:
|
|
2564
2815
|
// keep the panel mounted while it animates, flip data-open on the next
|
|
@@ -2607,11 +2858,11 @@
|
|
|
2607
2858
|
h("div",{className:"tl-panel-body"},
|
|
2608
2859
|
h("div",{className:"tl-panel-main"},
|
|
2609
2860
|
h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
|
|
2610
|
-
|
|
2611
|
-
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError
|
|
2861
|
+
snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
|
|
2862
|
+
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
|
|
2863
|
+
scanning:groupScanState==="scanning"}),
|
|
2612
2864
|
active
|
|
2613
|
-
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
|
|
2614
|
-
state,play,pause,resume,restart,stop,speed,setSpeed,snap})
|
|
2865
|
+
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
|
|
2615
2866
|
:h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
|
|
2616
2867
|
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
2617
2868
|
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
@@ -2731,16 +2982,16 @@
|
|
|
2731
2982
|
}
|
|
2732
2983
|
|
|
2733
2984
|
function App(){
|
|
2734
|
-
const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const
|
|
2735
|
-
useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);
|
|
2736
|
-
const ctx=useMemo(()=>({registry,
|
|
2985
|
+
const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const[activeId,setActiveId]=useState(null);
|
|
2986
|
+
useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
|
|
2987
|
+
const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
|
|
2737
2988
|
// Demo-only tweak controls: hidden by default. Append ?controls to the URL
|
|
2738
2989
|
// to show them for testing. (This whole block is below the inject CUT_MARKER,
|
|
2739
2990
|
// so it never ships in the injected build.)
|
|
2740
2991
|
const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
|
|
2741
2992
|
return h(TimelineCtx.Provider,{value:ctx},
|
|
2742
2993
|
showControls&&h(PanelControls),
|
|
2743
|
-
h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then
|
|
2994
|
+
h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then interact with the component itself to preview it.")),
|
|
2744
2995
|
h("div",{className:"demo-grid"},h(BoxResize),h(BoxOpacity),h(BoxSlide),h(BoxColor))),
|
|
2745
2996
|
h(TimelinePanel));
|
|
2746
2997
|
}
|