@hyperframes/studio 0.7.57 → 0.7.58
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/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { classifyZone } from "./timelineZones";
|
|
3
|
+
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
|
|
4
|
+
import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
|
|
5
|
+
import { samePaintScope } from "./timelineStackingSync";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mirror a canvas z-order action (Bring to Front / Bring Forward / Send Backward /
|
|
9
|
+
* Send to Back) into a timeline LANE move — the pure resolver, no UI wiring.
|
|
10
|
+
*
|
|
11
|
+
* ── The model ────────────────────────────────────────────────────────────────
|
|
12
|
+
* Track order is the DEFAULT paint order; authored z is the ADVANCED override.
|
|
13
|
+
* Render truth stays z — the renderer never reads track index — and the studio
|
|
14
|
+
* maintains z ↔ track consistency at EDIT time: a deliberate vertical lane move
|
|
15
|
+
* syncs z (timelineStackingSync), and a z-order menu action calls THIS resolver
|
|
16
|
+
* to compute the accompanying lane move. When the user authors z that diverges
|
|
17
|
+
* from track order, the mirror never fights the authored override — it only
|
|
18
|
+
* keeps the default in step.
|
|
19
|
+
*
|
|
20
|
+
* ── Locked rules (agreed design — do not re-litigate here) ───────────────────
|
|
21
|
+
* - The mirror computes a lane move to ACCOMPANY a z action on a timeline clip;
|
|
22
|
+
* it never replaces the z patch.
|
|
23
|
+
* - ONE-ELEMENT STEP (bring-forward / send-backward): the z action stepped past
|
|
24
|
+
* exactly ONE element — the reference neighbor — so the lane move must too.
|
|
25
|
+
* Target = the free lane (whole-span, file-agnostic occupancy, same zone)
|
|
26
|
+
* closest to the reference, searched STRICTLY BETWEEN the reference's lane
|
|
27
|
+
* and the next temporally-overlapping same-file visual element's lane in the
|
|
28
|
+
* direction (exclusive bound). No free lane in that open interval (the common
|
|
29
|
+
* back-to-back case) → CREATE one at the boundary immediately beyond the
|
|
30
|
+
* reference neighbor (commitTrackInsert semantics) — never scan past the
|
|
31
|
+
* second element to a farther free lane, which would overshoot the paint
|
|
32
|
+
* order. With no second overlapping element
|
|
33
|
+
* beyond the reference, the bound is the zone edge: closest free lane beyond
|
|
34
|
+
* the neighbor, else insert immediately beyond it.
|
|
35
|
+
* - bring-to-front / send-to-back move past the WHOLE overlap set: closest free
|
|
36
|
+
* lane beyond the extreme overlap in the direction, else insert adjacent to
|
|
37
|
+
* the extreme.
|
|
38
|
+
* - Direction: bring-forward/front = toward LOWER display lanes (up = above);
|
|
39
|
+
* send-backward/back = toward HIGHER lanes, but only within the visual zone —
|
|
40
|
+
* the audio zone is untouched and never crossed (a bottom-of-zone insert lands
|
|
41
|
+
* AT the visual/audio boundary, i.e. still a visual lane).
|
|
42
|
+
* - Reference scope: same source file AND same stacking context (see
|
|
43
|
+
* samePaintScope — a file can contain several stacking contexts, and leaf z
|
|
44
|
+
* is only comparable within one). Lane FREENESS stays file-agnostic (any
|
|
45
|
+
* clip in the zone occupies its lane for everyone).
|
|
46
|
+
* - Non-clip decorations (no timeline presence) are out of scope — callers keep
|
|
47
|
+
* z-only behavior for them. Audio elements never mirror (z on audio is
|
|
48
|
+
* meaningless); the resolver returns null.
|
|
49
|
+
*
|
|
50
|
+
* ── OPEN product question ────────────────────────────────────────────────────
|
|
51
|
+
* send-to-back / bring-to-front scope: below/above EVERYTHING visual, or only
|
|
52
|
+
* the clips that temporally overlap the moved clip? The default implemented
|
|
53
|
+
* here is TEMPORAL-OVERLAP scope (the extreme is computed over same-file clips
|
|
54
|
+
* that overlap the moved clip in time), pending M/Bin sign-off. A clip with no
|
|
55
|
+
* temporal overlaps in the direction is "already at the extreme" → null.
|
|
56
|
+
*
|
|
57
|
+
* Deterministic: a pure function of its inputs — no Date, no randomness, no DOM.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
export type ZMirrorAction = "bring-to-front" | "bring-forward" | "send-backward" | "send-to-back";
|
|
61
|
+
|
|
62
|
+
export interface ZMirrorInput {
|
|
63
|
+
action: ZMirrorAction;
|
|
64
|
+
/** The clip acted on — store/display space (post-normalizeToZones lanes),
|
|
65
|
+
* carrying `authoredTrack` when the display lane diverges from the file. */
|
|
66
|
+
element: TimelineElement;
|
|
67
|
+
/** The expanded display element set (same set the drag commit reasons on). */
|
|
68
|
+
elements: TimelineElement[];
|
|
69
|
+
/** Timeline key of the neighbor the z action stepped over (forward/backward),
|
|
70
|
+
* when known — see resolveCrossedNeighbor in canvasContextMenuZOrder. */
|
|
71
|
+
crossedKey?: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ZMirrorLaneMove =
|
|
75
|
+
| {
|
|
76
|
+
/** Land on an existing display lane. */
|
|
77
|
+
kind: "move";
|
|
78
|
+
/** Display lane to move to (store space). */
|
|
79
|
+
displayTrack: number;
|
|
80
|
+
/** Authored-space value to write (authoredTrackForLane translation). */
|
|
81
|
+
persistTrack: number;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
/** Create a new lane: boundary row compatible with commitTrackInsert's
|
|
85
|
+
* insertRow (index into the ascending display trackOrder; 0 = above the
|
|
86
|
+
* top lane, length = below the bottom). */
|
|
87
|
+
kind: "insert";
|
|
88
|
+
insertRow: number;
|
|
89
|
+
}
|
|
90
|
+
| null;
|
|
91
|
+
|
|
92
|
+
const keyOf = (el: TimelineElement): string => el.key ?? el.id;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lane candidates for an EXPANDED sub-comp child: only its own siblings' lanes
|
|
96
|
+
* (same file). An expanded child's display row is synthetic host-space — landing
|
|
97
|
+
* it on an arbitrary host lane has no same-file occupant to translate the
|
|
98
|
+
* authored track from, and a track INSERT would renumber host space from a
|
|
99
|
+
* child origin. Ordinary top-level clips return null (no restriction).
|
|
100
|
+
*/
|
|
101
|
+
function expandedChildAllowedLanes(
|
|
102
|
+
element: TimelineElement,
|
|
103
|
+
elements: TimelineElement[],
|
|
104
|
+
): ReadonlySet<number> | null {
|
|
105
|
+
if (element.expandedParentStart == null) return null;
|
|
106
|
+
const selfKey = keyOf(element);
|
|
107
|
+
return new Set(
|
|
108
|
+
elements
|
|
109
|
+
.filter((el) => keyOf(el) !== selfKey && sameSourceFile(el, element))
|
|
110
|
+
.map((el) => el.track),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Ascending unique display lanes of `elements` — identical to how Timeline.tsx
|
|
115
|
+
* builds `trackOrder`, so `insertRow` indexes the same boundary space. Exported
|
|
116
|
+
* so the mirror wiring can hand commitZMirrorLaneMove the matching trackOrder. */
|
|
117
|
+
export function displayTrackOrder(elements: TimelineElement[]): number[] {
|
|
118
|
+
return [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the timeline lane move that mirrors a z-order action on `element`.
|
|
123
|
+
* Returns null when no timeline mirror applies: audio / zero-length clips, no
|
|
124
|
+
* reference neighbor in the action's direction (the menu action was likely
|
|
125
|
+
* disabled or a no-op), or the clip is already laned where the action puts it.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveZMirrorLaneMove(input: ZMirrorInput): ZMirrorLaneMove {
|
|
128
|
+
const { action, element, elements } = input;
|
|
129
|
+
if (classifyZone(element) === "audio") return null;
|
|
130
|
+
if (!(element.duration > 0)) return null;
|
|
131
|
+
|
|
132
|
+
const selfKey = keyOf(element);
|
|
133
|
+
const start = element.start;
|
|
134
|
+
const end = element.start + element.duration;
|
|
135
|
+
const up = action === "bring-forward" || action === "bring-to-front";
|
|
136
|
+
|
|
137
|
+
// Same paint scope (source file + stacking context), visual, temporally overlapping.
|
|
138
|
+
const overlapSet = elements.filter(
|
|
139
|
+
(el) =>
|
|
140
|
+
keyOf(el) !== selfKey &&
|
|
141
|
+
classifyZone(el) === "visual" &&
|
|
142
|
+
samePaintScope(el, element) &&
|
|
143
|
+
timeRangesOverlap(start, end, el.start, el.start + el.duration),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const referenceLane = resolveReferenceLane(input, overlapSet, up);
|
|
147
|
+
if (referenceLane == null) return null;
|
|
148
|
+
|
|
149
|
+
const order = displayTrackOrder(elements);
|
|
150
|
+
const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
|
|
151
|
+
const refIdx = visualLanes.indexOf(referenceLane);
|
|
152
|
+
if (refIdx === -1) return null; // reference is not a visual lane — no mirror
|
|
153
|
+
|
|
154
|
+
const boundLane = stepBoundLane(action, overlapSet, referenceLane, up);
|
|
155
|
+
const allowedLanes = expandedChildAllowedLanes(element, elements);
|
|
156
|
+
const lane = closestFreeLane({
|
|
157
|
+
elements,
|
|
158
|
+
visualLanes,
|
|
159
|
+
refIdx,
|
|
160
|
+
up,
|
|
161
|
+
boundLane,
|
|
162
|
+
start,
|
|
163
|
+
end,
|
|
164
|
+
selfKey,
|
|
165
|
+
allowedLanes,
|
|
166
|
+
});
|
|
167
|
+
if (lane != null) {
|
|
168
|
+
// The closest free lane is the clip's OWN lane (possible only when z and
|
|
169
|
+
// track had diverged): the clip already sits where the action puts it.
|
|
170
|
+
if (lane === element.track) return null;
|
|
171
|
+
return {
|
|
172
|
+
kind: "move",
|
|
173
|
+
displayTrack: lane,
|
|
174
|
+
persistTrack: authoredTrackForLane(lane, elements, element),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Expanded children never INSERT: a new lane is a host-lane-space renumber,
|
|
179
|
+
// meaningless in the child's own file (buildTrackInsertEdits refuses too).
|
|
180
|
+
if (allowedLanes) return null;
|
|
181
|
+
// No free lane before the bound (or the zone edge) → create one adjacent to
|
|
182
|
+
// the reference: the boundary between its lane and the next in direction.
|
|
183
|
+
return { kind: "insert", insertRow: order.indexOf(referenceLane) + (up ? 0 : 1) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ZRepositionInput {
|
|
187
|
+
/** The clip the Layers-panel drag moved — store/display space. */
|
|
188
|
+
element: TimelineElement;
|
|
189
|
+
/** The expanded display element set (same set the drag commit reasons on). */
|
|
190
|
+
elements: TimelineElement[];
|
|
191
|
+
/**
|
|
192
|
+
* Timeline keys of the reordered sibling set in DESIRED render order,
|
|
193
|
+
* bottom→top, the moved element's own key included at its new slot. Siblings
|
|
194
|
+
* with no timeline presence carry null — they are skipped when resolving the
|
|
195
|
+
* nearest clip neighbors.
|
|
196
|
+
*/
|
|
197
|
+
desiredOrderKeys: ReadonlyArray<string | null>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Mirror an ARBITRARY z repositioning (a Layers-panel drag, which can jump
|
|
202
|
+
* several siblings in one drop) into a timeline lane move — the "equal jump"
|
|
203
|
+
* generalization of {@link resolveZMirrorLaneMove}'s one-step rule: the clip
|
|
204
|
+
* lands between its NEW paint neighbors' lanes.
|
|
205
|
+
*
|
|
206
|
+
* The reference lanes are the nearest siblings in the desired render order
|
|
207
|
+
* that are visual, same-file timeline clips: `above` = the first such sibling
|
|
208
|
+
* that now paints above the moved clip, `below` = the first that paints below.
|
|
209
|
+
* Target lane = the free lane (whole-span, same zone) strictly between the
|
|
210
|
+
* above-neighbor's lane and the below-neighbor's lane, closest to the above
|
|
211
|
+
* neighbor; when only one neighbor exists the zone edge bounds the search on
|
|
212
|
+
* the open side. No free lane in the interval → INSERT a new lane immediately
|
|
213
|
+
* beyond the above neighbor (below it), or immediately above the below
|
|
214
|
+
* neighbor when dropped on top — commitTrackInsert semantics, exactly like the
|
|
215
|
+
* menu mirror's insert fallback.
|
|
216
|
+
*
|
|
217
|
+
* Null when no mirror applies: audio / zero-length clips, no clip neighbor in
|
|
218
|
+
* the set (a z-only decoration shuffle), or the clip already sits where the
|
|
219
|
+
* drop puts it.
|
|
220
|
+
*/
|
|
221
|
+
// fallow-ignore-next-line complexity
|
|
222
|
+
export function resolveRepositionLaneMove(input: ZRepositionInput): ZMirrorLaneMove {
|
|
223
|
+
const { element, elements, desiredOrderKeys } = input;
|
|
224
|
+
if (classifyZone(element) === "audio") return null;
|
|
225
|
+
if (!(element.duration > 0)) return null;
|
|
226
|
+
|
|
227
|
+
const selfKey = keyOf(element);
|
|
228
|
+
const selfIdx = desiredOrderKeys.indexOf(selfKey);
|
|
229
|
+
if (selfIdx === -1) return null;
|
|
230
|
+
|
|
231
|
+
const clipLaneForKey = (key: string | null): number | null => {
|
|
232
|
+
if (key == null) return null;
|
|
233
|
+
const el = elements.find((candidate) => keyOf(candidate) === key);
|
|
234
|
+
return el &&
|
|
235
|
+
classifyZone(el) === "visual" &&
|
|
236
|
+
el.duration > 0 &&
|
|
237
|
+
samePaintScope(el, element) &&
|
|
238
|
+
keyOf(el) !== selfKey
|
|
239
|
+
? el.track
|
|
240
|
+
: null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Nearest clip neighbor painting ABOVE (later in bottom→top order) / BELOW.
|
|
244
|
+
let aboveLane: number | null = null;
|
|
245
|
+
for (let i = selfIdx + 1; i < desiredOrderKeys.length && aboveLane == null; i++) {
|
|
246
|
+
aboveLane = clipLaneForKey(desiredOrderKeys[i]);
|
|
247
|
+
}
|
|
248
|
+
let belowLane: number | null = null;
|
|
249
|
+
for (let i = selfIdx - 1; i >= 0 && belowLane == null; i--) {
|
|
250
|
+
belowLane = clipLaneForKey(desiredOrderKeys[i]);
|
|
251
|
+
}
|
|
252
|
+
if (aboveLane == null && belowLane == null) return null;
|
|
253
|
+
|
|
254
|
+
const order = displayTrackOrder(elements);
|
|
255
|
+
const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
|
|
256
|
+
const allowedLanes = expandedChildAllowedLanes(element, elements);
|
|
257
|
+
const args = {
|
|
258
|
+
elements,
|
|
259
|
+
visualLanes,
|
|
260
|
+
start: element.start,
|
|
261
|
+
end: element.start + element.duration,
|
|
262
|
+
selfKey,
|
|
263
|
+
allowedLanes,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let lane: number | null;
|
|
267
|
+
let insertRow: number;
|
|
268
|
+
if (aboveLane != null) {
|
|
269
|
+
// Paints above = LOWER display lane: search DOWNWARD from the above
|
|
270
|
+
// neighbor (closest lane under it first), bounded by the below neighbor
|
|
271
|
+
// (exclusive) when one exists, else by the zone edge.
|
|
272
|
+
const refIdx = visualLanes.indexOf(aboveLane);
|
|
273
|
+
if (refIdx === -1) return null;
|
|
274
|
+
lane = closestFreeLane({ ...args, refIdx, up: false, boundLane: belowLane });
|
|
275
|
+
insertRow = order.indexOf(aboveLane) + 1;
|
|
276
|
+
} else {
|
|
277
|
+
// Dropped above everything that remains: search UPWARD from the below
|
|
278
|
+
// neighbor toward the zone top.
|
|
279
|
+
const refIdx = visualLanes.indexOf(belowLane!);
|
|
280
|
+
if (refIdx === -1) return null;
|
|
281
|
+
lane = closestFreeLane({ ...args, refIdx, up: true, boundLane: null });
|
|
282
|
+
insertRow = order.indexOf(belowLane!);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (lane != null) {
|
|
286
|
+
if (lane === element.track) return null;
|
|
287
|
+
return {
|
|
288
|
+
kind: "move",
|
|
289
|
+
displayTrack: lane,
|
|
290
|
+
persistTrack: authoredTrackForLane(lane, elements, element),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// Expanded children never INSERT (host-lane-space renumber from a child
|
|
294
|
+
// origin) — see expandedChildAllowedLanes.
|
|
295
|
+
if (allowedLanes) return null;
|
|
296
|
+
return { kind: "insert", insertRow };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* ONE-ELEMENT-STEP bound (bring-forward / send-backward only): the lane of the
|
|
301
|
+
* NEXT temporally-overlapping same-file visual element strictly beyond the
|
|
302
|
+
* reference in the direction — the free-lane search may not reach it
|
|
303
|
+
* (exclusive). Front/back have no bound (they step past the whole overlap
|
|
304
|
+
* set), and neither does a step with no second overlapping element beyond the
|
|
305
|
+
* reference (the zone edge bounds instead).
|
|
306
|
+
*/
|
|
307
|
+
function stepBoundLane(
|
|
308
|
+
action: ZMirrorAction,
|
|
309
|
+
overlapSet: TimelineElement[],
|
|
310
|
+
referenceLane: number,
|
|
311
|
+
up: boolean,
|
|
312
|
+
): number | null {
|
|
313
|
+
if (action !== "bring-forward" && action !== "send-backward") return null;
|
|
314
|
+
const beyond = overlapSet
|
|
315
|
+
.map((el) => el.track)
|
|
316
|
+
.filter((lane) => (up ? lane < referenceLane : lane > referenceLane));
|
|
317
|
+
if (beyond.length === 0) return null;
|
|
318
|
+
return (up ? Math.max : Math.min)(...beyond);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Closest free lane strictly beyond the reference, lane-by-lane in direction,
|
|
323
|
+
* whole-span freeness, same zone (visual lanes only — never into audio),
|
|
324
|
+
* stopping at the exclusive bound when one applies. Null → no free lane in
|
|
325
|
+
* the open interval.
|
|
326
|
+
*/
|
|
327
|
+
function closestFreeLane(args: {
|
|
328
|
+
elements: TimelineElement[];
|
|
329
|
+
visualLanes: number[];
|
|
330
|
+
refIdx: number;
|
|
331
|
+
up: boolean;
|
|
332
|
+
boundLane: number | null;
|
|
333
|
+
start: number;
|
|
334
|
+
end: number;
|
|
335
|
+
selfKey: string;
|
|
336
|
+
/** When set, only these lanes are candidates (expanded-child scoping). */
|
|
337
|
+
allowedLanes?: ReadonlySet<number> | null;
|
|
338
|
+
}): number | null {
|
|
339
|
+
const { elements, visualLanes, refIdx, up, boundLane, start, end, selfKey, allowedLanes } = args;
|
|
340
|
+
const step = up ? -1 : 1;
|
|
341
|
+
for (let i = refIdx + step; i >= 0 && i < visualLanes.length; i += step) {
|
|
342
|
+
const lane = visualLanes[i];
|
|
343
|
+
if (pastSearchBound(lane, boundLane, up)) break;
|
|
344
|
+
if (allowedLanes && !allowedLanes.has(lane)) continue;
|
|
345
|
+
if (isLaneFree(elements, lane, start, end, selfKey)) return lane;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The exclusive one-element-step bound: stop at (never on/past) `boundLane`. */
|
|
351
|
+
function pastSearchBound(lane: number, boundLane: number | null, up: boolean): boolean {
|
|
352
|
+
if (boundLane == null) return false;
|
|
353
|
+
return up ? lane <= boundLane : lane >= boundLane;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The lane the search starts from (the "reference neighbor"):
|
|
358
|
+
* - forward/backward: the crossed neighbor when provided and valid (a visual
|
|
359
|
+
* clip in the set); otherwise the closest temporally-overlapping same-file
|
|
360
|
+
* clip in the direction. None → null (the menu was probably disabled).
|
|
361
|
+
* - front/back: the extreme of the temporal-overlap set — topmost (lowest lane)
|
|
362
|
+
* for front, bottommost (highest lane) for back — restricted to overlaps
|
|
363
|
+
* strictly beyond the clip's own lane. None → already at the extreme → null.
|
|
364
|
+
*/
|
|
365
|
+
function resolveReferenceLane(
|
|
366
|
+
input: ZMirrorInput,
|
|
367
|
+
overlapSet: TimelineElement[],
|
|
368
|
+
up: boolean,
|
|
369
|
+
): number | null {
|
|
370
|
+
const stepAction = input.action === "bring-forward" || input.action === "send-backward";
|
|
371
|
+
if (stepAction) {
|
|
372
|
+
const crossedLane = crossedNeighborLane(input);
|
|
373
|
+
// Unknown / absent / non-visual crossed key → the temporal neighbor below.
|
|
374
|
+
if (crossedLane != null) return crossedLane;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Overlapping same-file lanes strictly beyond the moved clip's lane, in direction.
|
|
378
|
+
const lanes = overlapSet
|
|
379
|
+
.map((el) => el.track)
|
|
380
|
+
.filter((lane) => (up ? lane < input.element.track : lane > input.element.track));
|
|
381
|
+
if (lanes.length === 0) return null;
|
|
382
|
+
|
|
383
|
+
// Step actions want the CLOSEST lane in direction (max when up, min when
|
|
384
|
+
// down); front/back want the EXTREME of the set (min when up, max when down).
|
|
385
|
+
return (stepAction === up ? Math.max : Math.min)(...lanes);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The crossed neighbor's display lane, when the key names a visual clip in the set. */
|
|
389
|
+
function crossedNeighborLane({ elements, crossedKey }: ZMirrorInput): number | null {
|
|
390
|
+
if (crossedKey == null) return null;
|
|
391
|
+
const crossed = elements.find((el) => keyOf(el) === crossedKey);
|
|
392
|
+
return crossed && classifyZone(crossed) === "visual" ? crossed.track : null;
|
|
393
|
+
}
|
|
@@ -45,11 +45,11 @@ describe("getTimelinePixelsPerSecond", () => {
|
|
|
45
45
|
|
|
46
46
|
describe("getNextTimelineZoomPercent", () => {
|
|
47
47
|
it("zooms out from fit relative to 100 percent", () => {
|
|
48
|
-
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(
|
|
48
|
+
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it("zooms in from fit relative to 100 percent", () => {
|
|
52
|
-
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(
|
|
52
|
+
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
it("clamps the lower bound", () => {
|
|
@@ -2,9 +2,13 @@ import type { ZoomMode } from "../store/playerStore";
|
|
|
2
2
|
|
|
3
3
|
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
4
4
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// CapCut-strength steps: one button press / pinch gesture moves the zoom
|
|
6
|
+
// meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
|
|
7
|
+
// "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
|
|
8
|
+
// Kept reciprocal (2 × 0.5 = 1) so in+out round-trips.
|
|
9
|
+
const ZOOM_OUT_FACTOR = 0.5;
|
|
10
|
+
const ZOOM_IN_FACTOR = 2;
|
|
11
|
+
const PINCH_ZOOM_SENSITIVITY = 0.014;
|
|
8
12
|
|
|
9
13
|
export function clampTimelineZoomPercent(percent: number): number {
|
|
10
14
|
if (!Number.isFinite(percent)) return 100;
|
|
@@ -76,7 +76,7 @@ interface UseTimelineClipDragInput {
|
|
|
76
76
|
* commitDraggedClipMove. Both optional → absent = no-op (backward compatible).
|
|
77
77
|
*/
|
|
78
78
|
readZIndex?: (element: TimelineElement) => number;
|
|
79
|
-
onStackingPatches?: (patches: StackingPatch[]) => void;
|
|
79
|
+
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function useTimelineClipDrag({
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { buildTimelineGapStrips } from "./useTimelineGapHighlights";
|
|
4
|
+
|
|
5
|
+
function el(id: string, track: number, start: number, duration: number): TimelineElement {
|
|
6
|
+
return { id, key: id, tag: "video", start, duration, track, domId: id };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const laneA = [el("a1", 0, 1, 2), el("a2", 0, 5, 2)]; // gaps: [0,1), [3,5)
|
|
10
|
+
const laneB = [el("b1", 1, 0, 2), el("b2", 1, 2, 3)]; // contiguous
|
|
11
|
+
const tracks: [number, TimelineElement[]][] = [
|
|
12
|
+
[0, laneA],
|
|
13
|
+
[1, laneB],
|
|
14
|
+
];
|
|
15
|
+
const expandedElements = [...laneA, ...laneB];
|
|
16
|
+
|
|
17
|
+
const base = {
|
|
18
|
+
gapHighlight: null,
|
|
19
|
+
tracks,
|
|
20
|
+
selectedElementId: null,
|
|
21
|
+
selectedElementIds: new Set<string>(),
|
|
22
|
+
expandedElements,
|
|
23
|
+
dragActive: false,
|
|
24
|
+
displayDuration: 60,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("buildTimelineGapStrips", () => {
|
|
28
|
+
it("emits a loud hover strip set from the gap-menu highlight", () => {
|
|
29
|
+
const strips = buildTimelineGapStrips({
|
|
30
|
+
...base,
|
|
31
|
+
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
|
|
32
|
+
});
|
|
33
|
+
expect(strips).toEqual([{ track: 0, intervals: [{ start: 3, end: 5 }], kind: "hover" }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("click-selection lights the WHOLE lane minus clips, trailing space included", () => {
|
|
37
|
+
const strips = buildTimelineGapStrips({ ...base, selectedElementId: "a1" });
|
|
38
|
+
expect(strips).toEqual([
|
|
39
|
+
{
|
|
40
|
+
track: 0,
|
|
41
|
+
intervals: [
|
|
42
|
+
{ start: 0, end: 1 },
|
|
43
|
+
{ start: 3, end: 5 },
|
|
44
|
+
{ start: 7, end: 60 },
|
|
45
|
+
],
|
|
46
|
+
kind: "selected",
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a contiguous lane still lights its trailing open space", () => {
|
|
52
|
+
expect(buildTimelineGapStrips({ ...base, selectedElementId: "b1" })).toEqual([
|
|
53
|
+
{ track: 1, intervals: [{ start: 5, end: 60 }], kind: "selected" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("a lane filling the whole rendered extent emits nothing", () => {
|
|
58
|
+
const laneFull = [el("f1", 5, 0, 60)];
|
|
59
|
+
const strips = buildTimelineGapStrips({
|
|
60
|
+
...base,
|
|
61
|
+
tracks: [...tracks, [5, laneFull]],
|
|
62
|
+
expandedElements: [...expandedElements, ...laneFull],
|
|
63
|
+
selectedElementId: "f1",
|
|
64
|
+
});
|
|
65
|
+
expect(strips).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a one-member selectedElementIds mirror of the click still counts as single", () => {
|
|
69
|
+
// The store mirrors a plain click into selectedElementIds = {clicked}.
|
|
70
|
+
const strips = buildTimelineGapStrips({
|
|
71
|
+
...base,
|
|
72
|
+
selectedElementId: "a1",
|
|
73
|
+
selectedElementIds: new Set(["a1"]),
|
|
74
|
+
});
|
|
75
|
+
expect(strips).toHaveLength(1);
|
|
76
|
+
expect(strips[0].kind).toBe("selected");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("marquee multi-selection never emits the selected hint", () => {
|
|
80
|
+
const strips = buildTimelineGapStrips({
|
|
81
|
+
...base,
|
|
82
|
+
selectedElementId: "a1",
|
|
83
|
+
selectedElementIds: new Set(["a1", "b1"]),
|
|
84
|
+
});
|
|
85
|
+
expect(strips).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("hover wins on its own lane — no doubled strips for the same track", () => {
|
|
89
|
+
const strips = buildTimelineGapStrips({
|
|
90
|
+
...base,
|
|
91
|
+
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
|
|
92
|
+
selectedElementId: "a1",
|
|
93
|
+
});
|
|
94
|
+
expect(strips).toHaveLength(1);
|
|
95
|
+
expect(strips[0].kind).toBe("hover");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("hover and selection on DIFFERENT lanes coexist", () => {
|
|
99
|
+
const laneC = [el("c1", 2, 4, 2)];
|
|
100
|
+
const strips = buildTimelineGapStrips({
|
|
101
|
+
...base,
|
|
102
|
+
tracks: [...tracks, [2, laneC]],
|
|
103
|
+
expandedElements: [...expandedElements, ...laneC],
|
|
104
|
+
gapHighlight: { track: 2, intervals: [{ start: 0, end: 4 }] },
|
|
105
|
+
selectedElementId: "a1",
|
|
106
|
+
});
|
|
107
|
+
expect(strips.map((s) => s.kind)).toEqual(["hover", "selected"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a live drag suppresses every strip", () => {
|
|
111
|
+
const strips = buildTimelineGapStrips({
|
|
112
|
+
...base,
|
|
113
|
+
dragActive: true,
|
|
114
|
+
gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
|
|
115
|
+
selectedElementId: "a1",
|
|
116
|
+
});
|
|
117
|
+
expect(strips).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { laneGapFloor, resolveLaneEmptyIntervals, type TrackGapInterval } from "./timelineGaps";
|
|
4
|
+
import type { TrackGapHighlight } from "./useTrackGapMenu";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One lane's gap strips for the TimelineCanvas overlay.
|
|
8
|
+
*
|
|
9
|
+
* kind "hover" — the gap(s) a hovered "Close gap" / "Close all gaps" menu
|
|
10
|
+
* row would collapse: the loud affordance.
|
|
11
|
+
* kind "selected" — every empty interval on a click-selected clip's lane: the
|
|
12
|
+
* quiet always-on hint (single click-selection only — a
|
|
13
|
+
* marquee multi-select spans lanes and would paint noise).
|
|
14
|
+
*/
|
|
15
|
+
export interface TimelineLaneGapStrips {
|
|
16
|
+
track: number;
|
|
17
|
+
intervals: TrackGapInterval[];
|
|
18
|
+
kind: "hover" | "selected";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derive the gap strips TimelineCanvas paints. The menu-hover highlight wins
|
|
23
|
+
* on its lane (painting both would just double the same strips); the
|
|
24
|
+
* selected-clip hint renders on the selection's lane otherwise. Suppressed
|
|
25
|
+
* entirely during a live drag — the drop placeholder / insert line own that
|
|
26
|
+
* moment, and the lane set is in flux.
|
|
27
|
+
*/
|
|
28
|
+
interface GapHighlightInput {
|
|
29
|
+
gapHighlight: TrackGapHighlight | null;
|
|
30
|
+
tracks: [number, TimelineElement[]][];
|
|
31
|
+
selectedElementId: string | null;
|
|
32
|
+
selectedElementIds: ReadonlySet<string>;
|
|
33
|
+
expandedElements: TimelineElement[];
|
|
34
|
+
dragActive: boolean;
|
|
35
|
+
/** Rendered timeline extent (seconds) — the selected-lane highlight spans the
|
|
36
|
+
* WHOLE lane minus its clips, trailing open space included. */
|
|
37
|
+
displayDuration: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Single selection only: the store mirrors a plain click into a one-member
|
|
42
|
+
* selectedElementIds set (setSelectedElementId collapses the multi-select),
|
|
43
|
+
* so "single" means empty OR exactly the selected clip itself. A marquee
|
|
44
|
+
* multi-select spans lanes and stays hint-free.
|
|
45
|
+
*/
|
|
46
|
+
function isSingleSelection(selectedElementId: string, ids: ReadonlySet<string>): boolean {
|
|
47
|
+
return ids.size === 0 || (ids.size === 1 && ids.has(selectedElementId));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The subtle strips for a single click-selected clip's lane, or null. */
|
|
51
|
+
function selectedLaneStrips(input: GapHighlightInput): TimelineLaneGapStrips | null {
|
|
52
|
+
const { selectedElementId, selectedElementIds, expandedElements, tracks, gapHighlight } = input;
|
|
53
|
+
if (!selectedElementId || !isSingleSelection(selectedElementId, selectedElementIds)) return null;
|
|
54
|
+
const selected = expandedElements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
55
|
+
if (!selected || selected.track === gapHighlight?.track) return null;
|
|
56
|
+
const laneElements = tracks.find(([t]) => t === selected.track)?.[1] ?? [];
|
|
57
|
+
const intervals = resolveLaneEmptyIntervals(
|
|
58
|
+
laneElements,
|
|
59
|
+
input.displayDuration,
|
|
60
|
+
undefined,
|
|
61
|
+
laneGapFloor(laneElements),
|
|
62
|
+
);
|
|
63
|
+
return intervals.length > 0 ? { track: selected.track, intervals, kind: "selected" } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pure strip derivation — exported for direct unit testing. */
|
|
67
|
+
export function buildTimelineGapStrips(input: GapHighlightInput): TimelineLaneGapStrips[] {
|
|
68
|
+
if (input.dragActive) return [];
|
|
69
|
+
const strips: TimelineLaneGapStrips[] = [];
|
|
70
|
+
if (input.gapHighlight && input.gapHighlight.intervals.length > 0) {
|
|
71
|
+
strips.push({ ...input.gapHighlight, kind: "hover" });
|
|
72
|
+
}
|
|
73
|
+
// Single click-selection → subtle gap hint on that clip's lane.
|
|
74
|
+
const selected = selectedLaneStrips(input);
|
|
75
|
+
if (selected) strips.push(selected);
|
|
76
|
+
return strips;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useTimelineGapHighlights(input: GapHighlightInput): TimelineLaneGapStrips[] {
|
|
80
|
+
const {
|
|
81
|
+
gapHighlight,
|
|
82
|
+
tracks,
|
|
83
|
+
selectedElementId,
|
|
84
|
+
selectedElementIds,
|
|
85
|
+
expandedElements,
|
|
86
|
+
dragActive,
|
|
87
|
+
displayDuration,
|
|
88
|
+
} = input;
|
|
89
|
+
return useMemo(
|
|
90
|
+
() =>
|
|
91
|
+
buildTimelineGapStrips({
|
|
92
|
+
gapHighlight,
|
|
93
|
+
tracks,
|
|
94
|
+
selectedElementId,
|
|
95
|
+
selectedElementIds,
|
|
96
|
+
expandedElements,
|
|
97
|
+
dragActive,
|
|
98
|
+
displayDuration,
|
|
99
|
+
}),
|
|
100
|
+
[
|
|
101
|
+
gapHighlight,
|
|
102
|
+
tracks,
|
|
103
|
+
selectedElementId,
|
|
104
|
+
selectedElementIds,
|
|
105
|
+
expandedElements,
|
|
106
|
+
dragActive,
|
|
107
|
+
displayDuration,
|
|
108
|
+
],
|
|
109
|
+
);
|
|
110
|
+
}
|