Skip to content

feat: LEAP-1924: Allow to edit Timeline span by drag-n-drop #7251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { observer } from "mobx-react";
import type { MSTTimelineRegion } from "../../Timeline/Types";
import styles from "./TimelineRegionEditor.module.scss";

export const TimelineRegionEditor = observer(({ region }: { region: any }) => {
export const TimelineRegionEditor = observer(({ region }: { region: MSTTimelineRegion }) => {
const { start, end } = region.ranges[0];
const length = region.object.length;

const changeStartTimeHandler = (value: number) => {
if (+value === region.ranges[0].start) return;
region.setRanges([+value, region.ranges[0].end]);
region.setRange([+value, region.ranges[0].end]);
};

const changeEndTimeHandler = (value: number) => {
if (+value === region.ranges[0].end) return;
region.setRanges([region.ranges[0].start, +value]);
region.setRange([region.ranges[0].start, +value]);
};

return (
Expand Down Expand Up @@ -58,6 +59,8 @@ const Field = ({ label, value: originalValue, onChange: saveValue, region, min,
<span className={styles.labelText}>{label}</span>
<input
className={styles.input}
// hacky way to update value on region change; `onChange` is not called on every change, so input is still not controlled
key={originalValue}
type="number"
step={1}
readOnly={readonly}
Expand Down
4 changes: 2 additions & 2 deletions web/libs/editor/src/components/Timeline/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ const TimelineComponent: FC<TimelineProps> = ({
onAddRegion={(reg) => handlers.onAddRegion?.(reg)}
onDeleteRegion={(id) => handlers.onDeleteRegion?.(id)}
onSelectRegion={(e, id, select) => handlers.onSelectRegion?.(e, id, select)}
onStartDrawing={(frame) => handlers.onStartDrawing?.(frame)}
onFinishDrawing={() => handlers.onFinishDrawing?.()}
onStartDrawing={(options) => handlers.onStartDrawing?.(options)}
onFinishDrawing={(options) => handlers.onFinishDrawing?.(options)}
onSpeedChange={(speed) => handlers.onSpeedChange?.(speed)}
onZoom={props.onZoom}
/>
Expand Down
12 changes: 10 additions & 2 deletions web/libs/editor/src/components/Timeline/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export interface TimelineProps<D extends ViewTypes = "frames"> {
onToggleVisibility?: (id: string, visibility: boolean) => void;
onAddRegion?: (region: Record<string, any>) => any;
onDeleteRegion?: (id: string) => void;
onStartDrawing?: (frame: number) => void;
onFinishDrawing?: () => void;
onStartDrawing?: (options: { frame: number; region?: string }) => MSTTimelineRegion | undefined;
onFinishDrawing?: (options: { mode?: "new" | "edit" }) => void;
onZoom?: (zoom: number) => void;
onSelectRegion?: (event: MouseEvent<HTMLDivElement>, id: string, select?: boolean) => void;
onAction?: (event: MouseEvent, action: string, data?: any) => void;
Expand Down Expand Up @@ -83,6 +83,14 @@ export interface TimelineViewProps {
onSpeedChange?: TimelineProps["onSpeedChange"];
}

// Full region stored in MST store
export interface MSTTimelineRegion {
id: string;
ranges: { start: number; end: number }[];
object: { length: number }; // Video tag
setRange: (range: [number, number], options?: { mode?: "new" | "edit" }) => void;
}

export interface TimelineRegion {
id: string;
index?: number;
Expand Down
66 changes: 55 additions & 11 deletions web/libs/editor/src/components/Timeline/Views/Frames/Frames.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import { type FC, type MouseEvent, useCallback, useEffect, useMemo, useRef, useS
import { useMemoizedHandlers } from "../../../../hooks/useMemoizedHandlers";
import { Block, Elem } from "../../../../utils/bem";
import { isDefined } from "../../../../utils/utilities";
import type { TimelineRegion, TimelineViewProps } from "../../Types";
import type { MSTTimelineRegion, TimelineRegion, TimelineViewProps } from "../../Types";
import { Keypoints } from "./Keypoints";
import "./Frames.scss";

/**
* Effectively returns the frame on the given offset
* @param {number} num The offset to calculate the frame from
* @param {number} step Frame size, technically it's static, but comes from the props
* @returns {number} The frame on the given offset
*/
const toSteps = (num: number, step: number) => {
return Math.floor(num / step);
};
Expand Down Expand Up @@ -165,7 +171,20 @@ export const Frames: FC<TimelineViewProps> = ({
const hoverHandler = useCallback(
(e) => {
if (scrollable.current) {
const currentOffset = e.pageX - scrollable.current.getBoundingClientRect().left - timelineStartOffset;
const offsetLeft = scrollable.current.getBoundingClientRect().left;
const currentOffset = e.pageX - offsetLeft - timelineStartOffset;
const frame = toSteps(currentOffset + currentOffsetX, step) + 1;
const target = e.target as Element;
// every region has `data-id` attribute, so looking for them
const regionRow = target.closest("[data-id]") as HTMLElement | null;
if (regionRow) {
const [start, end] = [regionRow.dataset?.start, regionRow.dataset?.end];
if (start === String(frame) || end === String(frame)) {
regionRow.style.cursor = "col-resize";
} else if (regionRow.style.cursor === "col-resize") {
regionRow.style.cursor = "auto";
}
}

if (currentOffset > 0) {
setHoverOffset(currentOffset);
Expand All @@ -191,6 +210,10 @@ export const Frames: FC<TimelineViewProps> = ({
return value + timelineStartOffset;
}, [position, currentOffsetX, step, length]);

/**
* Main function for all interactions with the timeline.
* It's responsible for creating new regions, updating existing ones and updating the player position.
*/
const onFrameScrub = useCallback(
(e: MouseEvent) => {
const dimensions = scrollable.current!.getBoundingClientRect();
Expand All @@ -203,26 +226,43 @@ export const Frames: FC<TimelineViewProps> = ({
const onKeyframes = e.pageX - offsetLeft > timelineStartOffset;
// don't draw on region lines, only on the empty space or special new line
const isDrawing = onKeyframes && (!regionRow || regionRow.dataset?.id === "new");
let region: any;
let region: MSTTimelineRegion | undefined;
let mode: "new" | "edit" | undefined;

const getMouseToFrame = (e: MouseEvent | globalThis.MouseEvent) => {
const getMouseToOffset = (e: MouseEvent | globalThis.MouseEvent) => {
const mouseOffset = e.pageX - offsetLeft - timelineStartOffset;

return mouseOffset + currentOffsetX;
};

const offset = getMouseToFrame(e);
const baseFrame = toSteps(offset, step) + 1;
const offset = getMouseToOffset(e);
let baseFrame = toSteps(offset, step) + 1;
let isInstant = false;

setIndicatorOffset(offset);

if (isDrawing) {
// always a timeline region
region = props.onStartDrawing?.(baseFrame);
region = props.onStartDrawing?.({ frame: baseFrame });
mode = "new";
} else if (regionRow && onKeyframes) {
region = props.onStartDrawing?.({ region: regionRow.dataset.id, frame: baseFrame });
if (region) {
const { start, end } = region.ranges[0];
mode = "edit";
if (baseFrame === start) {
baseFrame = end;
} else if (baseFrame === end) {
baseFrame = start;
}
if (start === end) {
isInstant = true;
}
}
}

const onMouseMove = (e: globalThis.MouseEvent) => {
const offset = getMouseToFrame(e);
const offset = getMouseToOffset(e);
const frame = toSteps(offset, step) + 1;

if (offset >= currentOffsetX && offset <= rightLimit + currentOffsetX) {
Expand All @@ -232,15 +272,19 @@ export const Frames: FC<TimelineViewProps> = ({
}

if (region) {
const [start, end] = frame > baseFrame ? [baseFrame, frame] : [frame, baseFrame];
region.setRanges([start, end]);
if (isInstant) {
region.setRange([frame, frame], { mode });
} else {
const [start, end] = frame > baseFrame ? [baseFrame, frame] : [frame, baseFrame];
region.setRange([start, end], { mode });
}
}
};

const onMouseUp = () => {
setHoverEnabled(true);
setRegionSelectionDisabled(false);
props.onFinishDrawing?.();
props.onFinishDrawing?.({ mode });
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,18 @@ export const Keypoints: FC<KeypointsProps> = ({ idx, region, startOffset, render
[region.id, onSelectRegion],
);

// will work only for TimelineRegions; sequence for them is 2 or even 1 point (1 for instants)
const range = timeline ? sequence.map((s) => s.frame) : [];

return (
<Block name="keypoints" style={styles} mod={{ selected, timeline }} data-id={region.id}>
<Block
name="keypoints"
style={styles}
mod={{ selected, timeline }}
data-id={region.id}
data-start={range[0]}
data-end={range[1]}
>
<Elem name="label" onClick={onSelectRegionHandler}>
<Elem name="name">{label}</Elem>
<Elem name="data">
Expand Down
21 changes: 17 additions & 4 deletions web/libs/editor/src/regions/TimelineRegion.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ function rangeToSequence(range) {
];
}

/**
* TimelineRegion, a region on the video timeline.
* @see Timeline/Views/Frames#onFrameScrub() - this method creates a region by drawing on the timeline
*/
const Model = types
.model("TimelineRegionModel", {
type: "timelineregion",
Expand Down Expand Up @@ -94,13 +98,22 @@ const Model = types
return true;
},
/**
* Set ranges for the region, for now only one frame,
* Set range for the region, only one frame for now,
* could be extended to multiple frames in a future in a form of (...ranges)
* @param {number[]} [start, end] Start and end frames
* @param {Object} [options]
* @param {"new" | "edit" | undefined} [options.mode] Do we dynamically change the region ("new" one or "edit" existing one) or just edit it precisely (undefined)?
* In first two cases we need to update undo history only once
*/
setRanges([start, end]) {
// we need only one item in undo history, so we'll update current one during drawing
self.parent.annotation.history.setReplaceNextUndoState();
setRange([start, end], { mode } = {}) {
if (mode === "new") {
// we need to update existing history item while drawing a new region
self.parent.annotation.history.setReplaceNextUndoState();
} else if (mode === "edit") {
// we need to skip updating history item while editing existing region and record the state when we finish editing
/** @see Video#finishDrawing() */
self.parent.annotation.history.setSkipNextUndoState();
}
self.ranges = [{ start, end }];
},
}));
Expand Down
27 changes: 23 additions & 4 deletions web/libs/editor/src/tags/object/Video/Video.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,19 +289,38 @@ const Model = types
return self.regs.find((reg) => reg.cleanId === id);
},

/** Create a new timeline region at a given `frame` (only of labels are selected) */
startDrawing(frame) {
/**
* Create a new timeline region at a given `frame` (only if labels are selected) or edit an existing one if `region` is provided
* @param {Object} options
* @param {number} options.frame current frame under the cursor
* @param {string} options.region region id to search for it in the store; used to edit existing region
* @returns {Object} created region
*/
startDrawing({ frame, region: id }) {
if (id) {
const region = self.annotation.regions.find((r) => r.cleanId === id);
const range = region?.ranges?.[0];
return range && [range.start, range.end].includes(frame) ? region : null;
}
const control = self.timelineControl;
// labels should be selected or allow to create region without labels
if (!control?.selectedLabels?.length && !control?.allowempty) return;
if (!control?.selectedLabels?.length && !control?.allowempty) return null;

self.drawingRegion = self.addTimelineRegion({ frame, enabled: false });

return self.drawingRegion;
},

finishDrawing() {
/**
* Finish drawing a region and save its final state to the store if it was edited
* @param {Object} options
* @param {string} options.mode "new" if we are creating a new region, "edit" if we are editing an existing one
*/
finishDrawing({ mode }) {
self.drawingRegion = null;
if (mode === "edit") {
self.annotation.history.recordNow();
}
},
};
});
Expand Down
Loading