mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): a read-only lane stops pretending it is draggable
Three complaints, one cause each. **Handles appeared on a lane nothing could move.** A carve's lanes are read-only — a re-run rewrites them, so an edit there is lost work — but they still lit their point handles on hover like every other lane. The report was "I lost the ability to drag automation points": the affordance was the whole promise, and it was false. `pointHandleOpacity` now keeps them hidden unless the lane is actually editable, while a drag or a selected range still shows them, because those states are the ones where the handles are the feedback. **Nothing said why.** Hiding the handles alone turns a lie into a mystery. Hovering a read-only lane now shows a note inside it, and for a carve lane the note says where the control actually is — the strength knob in the FX rack, or switching the carve off to take the envelopes over by hand. **Group lane labels scrolled away.** The group header was sticky and its lane labels were not, so the labels slid under the curves while the header stayed. They are one column and now live in one sticky wrapper. Wrapping only the labels is what the first attempt did, and it is wrong: as a flex item after the header the wrapper starts at `x = columnWidth`, so at scroll 0 the labels render inside the lane area — which is what the screenshot caught. The lane title is now `laneTitle()` and the note `ReadOnlyNote`, which is what keeps the file under the complexity gate.
This commit is contained in:
@@ -1747,3 +1747,70 @@ describe("TimelineAutomationLane stretch", () => {
|
||||
expect(svg.style.cursor).not.toBe("col-resize");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => {
|
||||
/** Hover the lane the way a pointer entering it does. */
|
||||
function hover(container: HTMLElement): SVGSVGElement {
|
||||
const svg = container.querySelector("svg")!;
|
||||
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
||||
// React implements onPointerEnter through the delegated `pointerover`
|
||||
// event, not a native `pointerenter` — which does not bubble and so never
|
||||
// reaches its listener.
|
||||
fire(svg, "pointerover");
|
||||
return svg as SVGSVGElement;
|
||||
}
|
||||
|
||||
const handles = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[];
|
||||
|
||||
// A grab handle raised on hover is an offer, and a carve lane cannot honour
|
||||
// it: the analysis rewrites these envelopes on every run, so a point moved
|
||||
// here is discarded rather than saved. Dimming alone did not say that — the
|
||||
// handles still came up under the cursor and the drag silently did nothing.
|
||||
it("keeps its point handles hidden on hover", () => {
|
||||
const { container } = render(
|
||||
<TimelineAutomationLane
|
||||
{...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
|
||||
/>,
|
||||
);
|
||||
hover(container);
|
||||
const drawn = handles(container);
|
||||
expect(drawn.length).toBeGreaterThan(0);
|
||||
for (const c of drawn) {
|
||||
expect(c.style.opacity).toBe("0");
|
||||
expect(c.style.cursor).toBe("default");
|
||||
}
|
||||
});
|
||||
|
||||
it("still raises them on an editable lane, so the gate is readOnly and not hover", () => {
|
||||
const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
|
||||
hover(container);
|
||||
const drawn = handles(container);
|
||||
expect(drawn.length).toBeGreaterThan(0);
|
||||
for (const c of drawn) {
|
||||
expect(c.style.opacity).toBe("1");
|
||||
expect(c.style.cursor).toBe("grab");
|
||||
}
|
||||
});
|
||||
|
||||
it("says why, in the lane, once hovered", () => {
|
||||
const { container } = render(
|
||||
<TimelineAutomationLane
|
||||
{...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
|
||||
hover(container);
|
||||
expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain(
|
||||
"Owned by the carve.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing when no reason was given", () => {
|
||||
const { container } = render(
|
||||
<TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true })} />,
|
||||
);
|
||||
hover(container);
|
||||
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,75 @@ function pointCircleStyle(
|
||||
}
|
||||
|
||||
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
|
||||
/**
|
||||
* Whether a point's grab handle is up.
|
||||
*
|
||||
* A raised handle is an offer to drag, so a read-only lane keeps them down: the
|
||||
* carve rewrites its envelopes from the analysis on every run, and a point
|
||||
* moved here is discarded rather than saved. Hidden rather than unmounted
|
||||
* either way, so the hit area survives — a point dragged past the lane's edge
|
||||
* fires pointerleave mid-gesture, and a handle that vanished then would drop
|
||||
* the drag. A live drag and a selected range both keep them up regardless: the
|
||||
* range is the subject of a pending Delete, and which points it caught cannot
|
||||
* depend on where the mouse is.
|
||||
*/
|
||||
/** The svg's tooltip: what this lane's gestures actually are. */
|
||||
function laneTitle(readOnly: boolean | undefined): string {
|
||||
return readOnly
|
||||
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
|
||||
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this lane will not take an edit, in the lane itself.
|
||||
*
|
||||
* Dimming and a default cursor say "not editable" but not WHY, and the why is
|
||||
* the part that matters: the carve rewrites these envelopes from its own
|
||||
* analysis on every run, so a point moved here is discarded rather than saved.
|
||||
* Only while hovered, so a stack of read-only lanes is not a stack of notices,
|
||||
* and never over a selection the author is about to act on.
|
||||
*
|
||||
* Separate from the gesture `hint` slot, which belongs to handlers that do not
|
||||
* run on a read-only lane at all.
|
||||
*/
|
||||
function ReadOnlyNote({
|
||||
readOnly,
|
||||
note,
|
||||
hovered,
|
||||
hasRange,
|
||||
leftPx,
|
||||
widthPx,
|
||||
}: {
|
||||
readOnly: boolean | undefined;
|
||||
note: string | undefined;
|
||||
hovered: boolean;
|
||||
/** A selection is the subject of a pending action; do not cover it. */
|
||||
hasRange: boolean;
|
||||
leftPx: number;
|
||||
widthPx: number;
|
||||
}) {
|
||||
if (!readOnly || !note || !hovered || hasRange) return null;
|
||||
return (
|
||||
<div
|
||||
data-automation-readonly-note=""
|
||||
className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-black/85 px-1.5 py-0.5 text-[9px] text-white/80"
|
||||
style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pointHandleOpacity(args: {
|
||||
readOnly: boolean | undefined;
|
||||
hovered: boolean;
|
||||
dragging: boolean;
|
||||
hasRange: boolean;
|
||||
}): number {
|
||||
if (args.dragging || args.hasRange) return 1;
|
||||
return !args.readOnly && args.hovered ? 1 : 0;
|
||||
}
|
||||
|
||||
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
|
||||
// A stretch handle wins over everything it might also sit above: the handle is
|
||||
// a few px wide and always overlaps whatever is under the selection edge, so
|
||||
@@ -120,6 +189,9 @@ export interface TimelineAutomationLaneProps {
|
||||
snapTimes?: readonly number[];
|
||||
/** Editing writes to the selected element, so an unselected clip is read-only. */
|
||||
readOnly?: boolean;
|
||||
/** Why `readOnly`, shown in the lane on hover. Without it the lane only looks
|
||||
* disabled; the author still has to guess what would let them edit it. */
|
||||
readOnlyNote?: string;
|
||||
/** Called when a read-only lane is pressed: selects the clip so it goes live. */
|
||||
onSelect?(): void;
|
||||
/** Active selection box on THIS lane, or null. */
|
||||
@@ -142,6 +214,7 @@ export function TimelineAutomationLane({
|
||||
onCommit,
|
||||
snapTimes,
|
||||
readOnly,
|
||||
readOnlyNote,
|
||||
onSelect,
|
||||
rangeSelection,
|
||||
onRangeSelect,
|
||||
@@ -372,11 +445,7 @@ export function TimelineAutomationLane({
|
||||
role="group"
|
||||
aria-label={`${range.label} automation`}
|
||||
>
|
||||
<title>
|
||||
{readOnly
|
||||
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
|
||||
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."}
|
||||
</title>
|
||||
<title>{laneTitle(readOnly)}</title>
|
||||
{/* No plate behind the envelope: the lane used to darken its clip's width,
|
||||
which drew a box inside the row and made a stack of lanes read as tiles
|
||||
rather than as rows of one timeline. The row background shows through, and
|
||||
@@ -433,15 +502,14 @@ export function TimelineAutomationLane({
|
||||
fill={accentColor}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
// Hidden rather than unmounted, so the hit area survives: a point
|
||||
// dragged past the lane's edge fires pointerleave mid-gesture, and a
|
||||
// handle that vanishes then would drop the drag. A drag in progress
|
||||
// and a selected range both keep them up for the same reason — the
|
||||
// range is the subject of a pending Delete, and which points it
|
||||
// caught cannot depend on where the mouse is.
|
||||
style={{
|
||||
cursor: readOnly ? "default" : "grab",
|
||||
opacity: hovered || dragIndex !== null || rangeSelection ? 1 : 0,
|
||||
opacity: pointHandleOpacity({
|
||||
readOnly,
|
||||
hovered,
|
||||
dragging: dragIndex !== null,
|
||||
hasRange: Boolean(rangeSelection),
|
||||
}),
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -484,6 +552,15 @@ export function TimelineAutomationLane({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ReadOnlyNote
|
||||
readOnly={readOnly}
|
||||
note={readOnlyNote}
|
||||
hovered={hovered}
|
||||
hasRange={Boolean(rangeSelection)}
|
||||
leftPx={leftPx}
|
||||
widthPx={widthPx}
|
||||
/>
|
||||
|
||||
{menuAt && rangeSelection ? (
|
||||
<AutomationSelectionMenu
|
||||
x={menuAt.x}
|
||||
|
||||
@@ -105,6 +105,16 @@ function ClipAutomationLanes({
|
||||
// editable. Per LANE, not per binding: a carved bed can carry the
|
||||
// author's own volume curve beside the carve's bands.
|
||||
readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)}
|
||||
// Two distinct reasons, so two notes. The carve one names the way
|
||||
// out — change strength, or switch the carve off and own the chain
|
||||
// — because "not editable" without that reads as broken.
|
||||
readOnlyNote={
|
||||
isCarveLane(lane.target, bound.chain)
|
||||
? "Owned by the voiceover carve — re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
|
||||
: bound.readOnly
|
||||
? "Read-only here."
|
||||
: undefined
|
||||
}
|
||||
rangeSelection={
|
||||
bound.selection?.target === lane.target
|
||||
? {
|
||||
|
||||
@@ -122,47 +122,60 @@ export function TimelineGroupRow({
|
||||
borderColor={theme.rowBorder}
|
||||
rovingTargetId={rovingTargetId}
|
||||
>
|
||||
<TimelineGroupHeader
|
||||
label={group.label}
|
||||
memberCount={group.memberTracks.length}
|
||||
isExpanded={!collapsedGroupIds.has(group.id)}
|
||||
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
||||
// The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
|
||||
// §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
|
||||
// counting the members' here made the group advertise curves it does
|
||||
// not own and cannot show.
|
||||
laneCount={groupAutomationLanes([groupElement]).length}
|
||||
isLaneOpen={isLaneOpen}
|
||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||
fxChain={group.fxChain}
|
||||
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
||||
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
||||
auditionSpans={memberElements}
|
||||
onOpenFxRack={openGroupFxRack}
|
||||
// Same width as every other row's header. The group row needs a real
|
||||
// label column, but it gets one by turning `labelMode` on for the whole
|
||||
// timeline (see Timeline.tsx) rather than by overhanging alone — an
|
||||
// overhanging header paints opaquely across the rest of its row and
|
||||
// stays pinned there through horizontal scroll.
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
theme={theme}
|
||||
/>
|
||||
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
||||
{/* Header and its lane labels in ONE sticky column — the shape
|
||||
`TimelineTrackHeader` already uses: a fixed TRACK_H line box with the
|
||||
lane rows absolutely positioned beneath it, the whole thing pinned.
|
||||
The labels used to be SIBLINGS of the header, so `absolute left-0`
|
||||
resolved against the ROW, and the row is what scrolls horizontally —
|
||||
they slid away with the canvas. Sticky lives here rather than on the
|
||||
header, which keeps its own box inside; a zero-width sticky wrapper
|
||||
around the labels alone does not work either, because as a flex item
|
||||
after the header it starts at x = columnWidth, i.e. inside the lanes. */}
|
||||
<div
|
||||
className="sticky left-0 z-[12] shrink-0"
|
||||
style={{ width: contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin }}
|
||||
>
|
||||
<TimelineGroupHeader
|
||||
label={group.label}
|
||||
memberCount={group.memberTracks.length}
|
||||
isExpanded={!collapsedGroupIds.has(group.id)}
|
||||
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
||||
// The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
|
||||
// §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
|
||||
// counting the members' here made the group advertise curves it does
|
||||
// not own and cannot show.
|
||||
laneCount={groupAutomationLanes([groupElement]).length}
|
||||
isLaneOpen={isLaneOpen}
|
||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||
fxChain={group.fxChain}
|
||||
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
||||
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
||||
auditionSpans={memberElements}
|
||||
onOpenFxRack={openGroupFxRack}
|
||||
// Same width as every other row's header. The group row needs a real
|
||||
// label column, but it gets one by turning `labelMode` on for the whole
|
||||
// timeline (see Timeline.tsx) rather than by overhanging alone — an
|
||||
// overhanging header paints opaquely across the rest of its row and
|
||||
// stays pinned there through horizontal scroll.
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
theme={theme}
|
||||
/>
|
||||
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
||||
clip's: the binder writes through the dom-edit selection, so a lane is
|
||||
editable once the group is selected — which clicking its name does. */}
|
||||
{/* The label column for those lanes, on the accent rail. Outside the
|
||||
offset content cell below, because the labels belong to the sticky
|
||||
gutter the row header occupies, not to the scrolling canvas. */}
|
||||
{isLaneOpen && (
|
||||
<TimelineGroupLaneLabels
|
||||
groupElement={groupElement}
|
||||
groupLabel={group.label}
|
||||
top={TRACK_H}
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
accentColor={GROUP_LANE_ACCENT}
|
||||
/>
|
||||
)}
|
||||
{/* The label column for those lanes, on the accent rail — inside the
|
||||
sticky column above, so they pin with the header. */}
|
||||
{isLaneOpen && (
|
||||
<TimelineGroupLaneLabels
|
||||
groupElement={groupElement}
|
||||
groupLabel={group.label}
|
||||
top={TRACK_H}
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
accentColor={GROUP_LANE_ACCENT}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isLaneOpen && (
|
||||
// The same offset content cell a track row wraps its lanes in — the
|
||||
// slot positions absolutely, so mounted straight on the row it resolved
|
||||
|
||||
Reference in New Issue
Block a user