import React from "react"; import { makeObservable, action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import classNames from "classnames"; import { addAlphaToColor } from "eez-studio-shared/color"; import { isRectInsideRect, Point, pointInRect, Rect, rectClone } from "eez-studio-shared/geometry"; import { closestBySelector } from "eez-studio-shared/dom"; import { theme } from "eez-studio-ui/theme"; import { SvgLabel } from "eez-studio-ui/svg-label"; import { Draggable } from "eez-studio-ui/draggable"; import { ProjectEditor } from "project-editor/project-editor-interface"; import { getId, getParent, IEezObject } from "project-editor/core/object"; import { TreeAdapter, ITreeRow } from "project-editor/core/objectAdapter"; import { getAncestorOfType, IPanel } from "project-editor/store"; import { ProjectContext } from "project-editor/project/context"; import { setupDragScroll } from "project-editor/flow/editor/drag-scroll"; import { IPointerEvent } from "project-editor/flow/editor/mouse-handler"; import type { Widget } from "project-editor/flow/component"; import type { TimelineKeyframe } from "project-editor/flow/timeline"; import type { PageTabState } from "project-editor/features/page/PageEditor"; //////////////////////////////////////////////////////////////////////////////// const TIMELINE_X_OFFSET = 10; const TIMELINE_HEIGHT = 40; const ROW_HEIGHT = 20; const POINT_RADIUS = 4; const ROW_GAP = 3; const NEEDLE_WIDTH = 4; const VISIBILITY_TOLERANCE = 10; //////////////////////////////////////////////////////////////////////////////// export class PageTimelineEditorState { isEditorActive: boolean = false; position: number = 0; duration: number; scrollLeft: number; scrollTop: number; secondToPx: number = 200; selectedKeyframes: TimelineKeyframe[] = []; rubberBendRect: Rect | undefined; horizontalScrollBarWidth: number; constructor(private tabState: PageTabState) { this.duration = 60.0; this.scrollLeft = 0; this.scrollTop = 0; makeObservable(this, { isEditorActive: observable, position: observable, duration: observable, scrollLeft: observable, scrollTop: observable, secondToPx: observable, selectedKeyframes: observable, rubberBendRect: observable, horizontalScrollBarWidth: observable, timelineHeight: computed, timelineWidth: computed }); } loadState(state: Partial) { if (state.isEditorActive != undefined) { this.isEditorActive = state.isEditorActive; } if (state.position != undefined) { this.position = state.position; } if (state.secondToPx != undefined) { this.secondToPx = state.secondToPx; } if (state.scrollLeft != undefined) { this.scrollLeft = state.scrollLeft; } } saveState() { return { isEditorActive: this.isEditorActive, position: this.position, secondToPx: this.secondToPx, scrollLeft: this.scrollLeft }; } get treeAdapter() { return new TreeAdapter( this.tabState.widgetContainer, undefined, (object: IEezObject) => { return object instanceof ProjectEditor.WidgetClass; }, true ); } static getTimelineWidth(duration: number, secondToPx: number) { return TIMELINE_X_OFFSET + duration * secondToPx + TIMELINE_X_OFFSET; } get timelineWidth() { return PageTimelineEditorState.getTimelineWidth( this.duration, this.secondToPx ); } get timelineHeight() { return this.treeAdapter.allRows.length * ROW_HEIGHT; } get nextSecondToPx() { if (this.secondToPx < 2000) { return Math.round(this.secondToPx * 1.2); } return this.secondToPx; } get previousSecondToPx() { if (this.secondToPx > 50) { return Math.round(this.secondToPx / 1.2); } return this.secondToPx; } get positionPx() { return this.positionToPx(this.position); } positionToPx(position: number) { return TIMELINE_X_OFFSET + position * this.secondToPx; } pxToPosition(px: number) { return (px - TIMELINE_X_OFFSET) / this.secondToPx; } getKeyframeCircleBoundingRect( rowIndex: number, keyframe: TimelineKeyframe ) { const cx = this.positionToPx(keyframe.end); const cy = TIMELINE_HEIGHT + rowIndex * ROW_HEIGHT + ROW_HEIGHT / 2; const x1 = cx - POINT_RADIUS / 2; const y1 = cy - POINT_RADIUS / 2; const x2 = x1 + POINT_RADIUS; const y2 = y1 + POINT_RADIUS; return { cx, cy, x1, y1, x2, y2 }; } getRowIndexFromY(y: number) { let rowIndex = (y - TIMELINE_HEIGHT) / ROW_HEIGHT; if (rowIndex >= 0) { rowIndex = Math.floor(rowIndex); if (rowIndex < this.treeAdapter.allRows.length) { return rowIndex; } } return -1; } getRowRect(rowIndex: number): Rect { return { left: TIMELINE_X_OFFSET, top: TIMELINE_HEIGHT + rowIndex * ROW_HEIGHT + ROW_GAP / 2, width: this.duration * this.secondToPx, height: ROW_HEIGHT - ROW_GAP }; } getKeyframeRect(rowIndex: number, keyframe: TimelineKeyframe) { return { left: this.positionToPx(keyframe.start), top: TIMELINE_HEIGHT + rowIndex * ROW_HEIGHT + ROW_GAP / 2, width: (keyframe.end - keyframe.start) * this.secondToPx, height: ROW_HEIGHT - ROW_GAP }; } getKeyframeStartPosition(rowIndex: number, keyframe: TimelineKeyframe) { return this.positionToPx(keyframe.start); } getKeyFrameEndPosition(rowIndex: number, keyframe: TimelineKeyframe) { return this.positionToPx(keyframe.end); } isVerticalLineVisible(x: number) { if (x < this.scrollLeft - VISIBILITY_TOLERANCE) { return false; } if ( x > this.scrollLeft + this.horizontalScrollBarWidth + VISIBILITY_TOLERANCE ) { return false; } return true; } getMinDelta( keyframes: TimelineKeyframe[], mode: "keyframe-start" | "keyframe-end" | "keyframe" ) { return Math.max( ...keyframes.map(keyframe => { const widgetTimeline = getParent( keyframe ) as TimelineKeyframe[]; let keyframeIndex = widgetTimeline.indexOf(keyframe); if (mode == "keyframe-end") { return keyframe.start - keyframe.end; } for ( keyframeIndex = keyframeIndex - 1; keyframeIndex >= 0; keyframeIndex-- ) { const previousKeyframe = widgetTimeline[keyframeIndex]; if (keyframes.indexOf(previousKeyframe) == -1) { return previousKeyframe.end - keyframe.start; } keyframe = previousKeyframe; } return 0 - keyframe.start; }) ); } getMaxDelta( keyframes: TimelineKeyframe[], mode: "keyframe-start" | "keyframe-end" | "keyframe" ) { return Math.min( ...keyframes.map(keyframe => { const widgetTimeline = getParent( keyframe ) as TimelineKeyframe[]; let keyframeIndex = widgetTimeline.indexOf(keyframe); if (mode == "keyframe-start") { return keyframe.end - keyframe.start; } for ( keyframeIndex = keyframeIndex + 1; keyframeIndex < widgetTimeline.length; keyframeIndex++ ) { const nextKeyframe = widgetTimeline[keyframeIndex]; if (keyframes.indexOf(nextKeyframe) == -1) { return nextKeyframe.start - keyframe.end; } keyframe = nextKeyframe; } return this.duration - keyframe.end; }) ); } get step() { return this.secondToPx > 600 ? 0.01 : 0.1; } } //////////////////////////////////////////////////////////////////////////////// export const PageTimelineEditor = observer( class PageTimelineEditor extends React.Component<{ tabState: PageTabState; }> implements IPanel { static contextType = ProjectContext; declare context: React.ContextType; verticalScrollBarRef = React.createRef(); horizontalScrollBarRef = React.createRef(); resizeObserver: ResizeObserver; constructor(props: any) { super(props); this.resizeObserver = new ResizeObserver( this.resizeObserverCallback ); } resizeObserverCallback = () => { if (this.horizontalScrollBarRef.current) { let rect = this.horizontalScrollBarRef.current.getBoundingClientRect(); runInAction(() => { this.props.tabState.timeline.horizontalScrollBarWidth = rect.width; }); } }; componentDidMount() { if (this.horizontalScrollBarRef.current) { this.resizeObserver.observe( this.horizontalScrollBarRef.current ); } this.updateHorizontalScoll(); this.context.navigationStore.mountPanel(this); } componentWillUnmount() { if (this.horizontalScrollBarRef.current) { this.resizeObserver.unobserve( this.horizontalScrollBarRef.current ); } this.context.navigationStore.unmountPanel(this); } onVerticalScroll = action(() => { if (this.verticalScrollBarRef.current) { this.props.tabState.timeline.scrollTop = this.verticalScrollBarRef.current.scrollTop; } }); onHorizontalScroll = action(() => { if (this.horizontalScrollBarRef.current) { this.props.tabState.timeline.scrollLeft = this.horizontalScrollBarRef.current.scrollLeft; } }); updateHorizontalScoll = () => { if (this.horizontalScrollBarRef.current) { this.horizontalScrollBarRef.current.scrollLeft = this.props.tabState.timeline.scrollLeft; } }; // interface IPanel implementation get selectedObject() { return this.props.tabState.selectedObject; } get selectedObjects() { return this.props.tabState.selectedObjects; } deleteSelection() { if (this.props.tabState.timeline.selectedKeyframes.length > 0) { this.context.deleteObjects( this.props.tabState.timeline.selectedKeyframes ); runInAction(() => { this.props.tabState.timeline.selectedKeyframes = []; this.props.tabState.selectObjects([]); }); } } onFocus = () => { const navigationStore = this.context.navigationStore; navigationStore.setSelectedPanel(this); }; render() { return (
); } } ); //////////////////////////////////////////////////////////////////////////////// type DragSettings = | { mode: "none"; cursor: string; } | { mode: "timeline-position"; cursor: string; startPoint: Point; } | { mode: "rubber-band"; cursor: string; startPoint: Point; } | { mode: "keyframe"; cursor: string; dragStartPosition: number; keyframe: TimelineKeyframe; keyframeEnd: number; ends: number[]; minDelta: number; maxDelta: number; } | { mode: "keyframe-start"; cursor: string; dragStartPosition: number; keyframe: TimelineKeyframe; keyframeStart: number; starts: number[]; minDelta: number; maxDelta: number; } | { mode: "keyframe-end"; cursor: string; dragStartPosition: number; keyframe: TimelineKeyframe; keyframeEnd: number; ends: number[]; minDelta: number; maxDelta: number; } | { mode: "row"; cursor: string; widget: Widget; }; const TimelineEditor = observer( class TimelineEditor extends React.Component<{ tabState: PageTabState; timelineState: PageTimelineEditorState; updateHorizontalScoll: () => void; }> { static contextType = ProjectContext; declare context: React.ContextType; svgRef = React.createRef(); draggable = new Draggable(this); lastPointerEvent: IPointerEvent | undefined; dragSettings: DragSettings = { mode: "none", cursor: "default" }; dragScrollDispose: (() => void) | undefined; deltaY = 0; constructor(props: any) { super(props); makeObservable(this, { ticks: computed, subticks: computed }); } componentDidMount() { this.draggable.attach(this.svgRef.current); this.svgRef.current!.addEventListener( "wheel", this.onDraggableWheel, { passive: false } ); } componentWillUnmount() { this.draggable.attach(null); this.svgRef.current!.removeEventListener( "wheel", this.onDraggableWheel ); } snapToTicks(position: number) { // snap to subticks let minDiff = this.props.timelineState.duration; let snapPosition = position; for (let subtick of this.subticks) { const diff = Math.abs(subtick - position); if (diff < minDiff) { minDiff = diff; snapPosition = subtick; } } return snapPosition; } setTimelinePosition(x: number) { let position = (this.props.timelineState.scrollLeft + x - TIMELINE_X_OFFSET) / this.props.timelineState.secondToPx; if (position < 0) { position = 0; } if (position > this.props.timelineState.duration) { position = this.props.timelineState.duration; } let snapPosition = this.snapToTicks(position); runInAction(() => { this.props.timelineState.position = snapPosition; }); } limitScrollLeft(scrollLeft: number) { if (scrollLeft < 0) { scrollLeft = 0; } else { const horizontalScrollBarWidth = this.props.timelineState.horizontalScrollBarWidth; const timelineWidth = this.props.timelineState.timelineWidth; const maxScrollLeft = Math.max( timelineWidth - horizontalScrollBarWidth, 0 ); if (scrollLeft > maxScrollLeft) { scrollLeft = maxScrollLeft; } } return scrollLeft; } onDragStart = (e: PointerEvent, x1: number, y1: number) => { this.lastPointerEvent = { clientX: e.clientX, clientY: e.clientY, movementX: e.movementX ?? 0, movementY: e.movementY ?? 0, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, timeStamp: e.timeStamp }; const dragSettings = hitTest(this.props.timelineState, e, x1, y1); if (dragSettings.mode == "timeline-position") { // runInAction(() => { // this.props.timelineState.selectedKeyframes = []; // }); // this.props.tabState.selectObjects([]); this.setTimelinePosition(dragSettings.startPoint.x); } if ( dragSettings.mode == "keyframe" || dragSettings.mode == "keyframe-start" || dragSettings.mode == "keyframe-end" ) { runInAction(() => { if (e.ctrlKey || e.shiftKey) { const i = this.props.timelineState.selectedKeyframes.indexOf( dragSettings.keyframe ); if (i == -1) { this.props.timelineState.selectedKeyframes.push( dragSettings.keyframe ); this.props.timelineState.position = dragSettings.keyframe.end; } else { this.props.timelineState.selectedKeyframes.splice( i, 1 ); if ( this.props.timelineState.selectedKeyframes .length > 0 ) { this.props.timelineState.position = this.props.timelineState.selectedKeyframes[ this.props.timelineState .selectedKeyframes.length - 1 ].end; } } } else { if ( this.props.timelineState.selectedKeyframes.indexOf( dragSettings.keyframe ) == -1 ) { this.props.timelineState.selectedKeyframes = [ dragSettings.keyframe ]; this.props.timelineState.position = dragSettings.keyframe.end; } } this.props.tabState.selectObjects( this.props.tabState.timeline.selectedKeyframes.map( keyframe => getAncestorOfType( keyframe, ProjectEditor.WidgetClass.classInfo )! ) ); }); if (dragSettings.mode == "keyframe-start") { dragSettings.starts = this.props.timelineState.selectedKeyframes.map( keyframe => keyframe.start ); } else { dragSettings.ends = this.props.timelineState.selectedKeyframes.map( keyframe => keyframe.end ); } dragSettings.minDelta = this.props.timelineState.getMinDelta( this.props.timelineState.selectedKeyframes, dragSettings.mode ); dragSettings.maxDelta = this.props.timelineState.getMaxDelta( this.props.timelineState.selectedKeyframes, dragSettings.mode ); } else if (dragSettings.mode == "rubber-band") { runInAction(() => { this.props.timelineState.selectedKeyframes = []; this.props.timelineState.rubberBendRect = { left: dragSettings.startPoint.x, top: dragSettings.startPoint.y, width: 0, height: 0 }; }); this.props.tabState.selectObjects([]); } else if (dragSettings.mode == "row") { runInAction(() => { this.props.timelineState.selectedKeyframes = []; }); this.props.tabState.selectObjects([dragSettings.widget]); } this.dragSettings = dragSettings; if (this.svgRef.current) { if (dragSettings.cursor == "grab") { this.svgRef.current.style.cursor = "grabbing"; } } if (this.dragScrollDispose) { this.dragScrollDispose(); this.dragScrollDispose = undefined; } this.dragScrollDispose = setupDragScroll( this.svgRef.current!, () => this.lastPointerEvent, (point: Point) => { let scrollLeft = this.props.timelineState.scrollLeft - point.x; scrollLeft = this.limitScrollLeft(scrollLeft); runInAction(() => { this.props.timelineState.scrollLeft = scrollLeft; }); this.props.updateHorizontalScoll(); } ); }; onDragMove = (e: PointerEvent, x: number, y: number, params: any) => { this.lastPointerEvent = { clientX: e.clientX, clientY: e.clientY, movementX: e.movementX ?? 0, movementY: e.movementY ?? 0, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, timeStamp: e.timeStamp }; const rectSvg = this.svgRef.current!.getBoundingClientRect(); const dragPosition = this.props.timelineState.pxToPosition( e.clientX - rectSvg.x + this.props.timelineState.scrollLeft ); const dragSettings = this.dragSettings; if (dragSettings.mode == "timeline-position") { this.setTimelinePosition(x + dragSettings.startPoint.x); } else if (dragSettings.mode == "rubber-band") { runInAction(() => { let left; let width; if (x > 0) { left = dragSettings.startPoint.x; width = x; } else { left = dragSettings.startPoint.x + x; width = -x; } let top; let height; if (y > 0) { top = dragSettings.startPoint.y; height = y; } else { top = dragSettings.startPoint.y + y; height = -y; } const rubberBendRect = this.props.timelineState.rubberBendRect!; rubberBendRect.left = left; rubberBendRect.top = top; rubberBendRect.width = width; rubberBendRect.height = height; }); } else if (dragSettings.mode == "keyframe") { let delta = this.snapToTicks( dragPosition - dragSettings.dragStartPosition + dragSettings.keyframeEnd ) - dragSettings.keyframeEnd; delta = Math.min(delta, dragSettings.maxDelta); delta = Math.max(delta, dragSettings.minDelta); const newEnds = this.props.timelineState.selectedKeyframes.map( (keyframe, selectedKeyframeIndex) => { const end = dragSettings.ends[selectedKeyframeIndex]; const newEnd = end + delta; return roundPosition(newEnd); } ); const newStarts = this.props.timelineState.selectedKeyframes.map( (keyframe, selectedKeyframeIndex) => { const newEnd = newEnds[selectedKeyframeIndex]; const duration = keyframe.end - keyframe.start; return roundPosition(newEnd - duration); } ); if (!this.context.undoManager.combineCommands) { this.context.undoManager.setCombineCommands(true); } this.props.timelineState.selectedKeyframes.forEach( (keyframe, selectedKeyframeIndex) => { this.context.updateObject(keyframe, { start: newStarts[selectedKeyframeIndex], end: newEnds[selectedKeyframeIndex] }); if (keyframe == dragSettings.keyframe) { runInAction(() => { this.props.timelineState.position = newEnds[selectedKeyframeIndex]; }); } } ); } else if (dragSettings.mode == "keyframe-start") { let delta = this.snapToTicks( dragPosition - dragSettings.dragStartPosition + dragSettings.keyframeStart ) - dragSettings.keyframeStart; delta = Math.min(delta, dragSettings.maxDelta); delta = Math.max(delta, dragSettings.minDelta); const newStarts = this.props.timelineState.selectedKeyframes.map( (keyframe, selectedKeyframeIndex) => { const start = dragSettings.starts[selectedKeyframeIndex]; const newStart = start + delta; return roundPosition(newStart); } ); if (!this.context.undoManager.combineCommands) { this.context.undoManager.setCombineCommands(true); } this.props.timelineState.selectedKeyframes.forEach( (keyframe, selectedKeyframeIndex) => { this.context.updateObject(keyframe, { start: newStarts[selectedKeyframeIndex] }); } ); } else if (dragSettings.mode == "keyframe-end") { let delta = this.snapToTicks( dragPosition - dragSettings.dragStartPosition + dragSettings.keyframeEnd ) - dragSettings.keyframeEnd; delta = Math.min(delta, dragSettings.maxDelta); delta = Math.max(delta, dragSettings.minDelta); const newEnds = this.props.timelineState.selectedKeyframes.map( (keyframe, selectedKeyframeIndex) => { const end = dragSettings.ends[selectedKeyframeIndex]; const newEnd = end + delta; return roundPosition(newEnd); } ); if (!this.context.undoManager.combineCommands) { this.context.undoManager.setCombineCommands(true); } this.props.timelineState.selectedKeyframes.forEach( (keyframe, selectedKeyframeIndex) => { this.context.updateObject(keyframe, { end: newEnds[selectedKeyframeIndex] }); if (keyframe == dragSettings.keyframe) { runInAction(() => { this.props.timelineState.position = newEnds[selectedKeyframeIndex]; }); } } ); } }; onMove = (e: PointerEvent) => { const hitTestResult = hitTest( this.props.timelineState, e, e.clientX, e.clientY ); if (this.svgRef.current) { this.svgRef.current.style.cursor = hitTestResult.cursor; } }; onDragEnd = ( e: PointerEvent | undefined, cancel: boolean, params: any ) => { const dragSettings = this.dragSettings; if (dragSettings.mode == "rubber-band") { const selectedKeyframes: TimelineKeyframe[] = []; const timelineState = this.props.timelineState; const rubberBendRect = rectClone(timelineState.rubberBendRect!); rubberBendRect.left += timelineState.scrollLeft; rubberBendRect.top += timelineState.scrollTop; timelineState.treeAdapter.allRows.forEach((row, rowIndex) => { const widget = timelineState.treeAdapter.getItemObject( row.item ) as Widget; if (widget.locked || widget.hiddenInEditor) { return; } widget.timeline.forEach(keyframe => { const { x1, y1, x2, y2 } = timelineState.getKeyframeCircleBoundingRect( rowIndex, keyframe ); const keyframeRect: Rect = { left: x1, top: y1, width: x2 - x1, height: y2 - y1 }; if (isRectInsideRect(keyframeRect, rubberBendRect)) { selectedKeyframes.push(keyframe); } }); }); runInAction(() => { if (selectedKeyframes.length > 0) { this.props.timelineState.position = Math.max( ...selectedKeyframes.map(keyframe => keyframe.end) ); } this.props.timelineState.selectedKeyframes = selectedKeyframes; this.props.timelineState.rubberBendRect = undefined; }); this.props.tabState.selectObjects( this.props.tabState.timeline.selectedKeyframes.map( keyframe => getAncestorOfType( keyframe, ProjectEditor.WidgetClass.classInfo )! ) ); } if (this.svgRef.current) { this.svgRef.current.style.cursor = dragSettings.cursor; } this.dragSettings = { mode: "none", cursor: "default" }; if (this.context.undoManager.combineCommands) { this.context.undoManager.setCombineCommands(false); } if (this.dragScrollDispose) { this.dragScrollDispose(); this.dragScrollDispose = undefined; } }; onDraggableWheel = (event: WheelEvent) => { if (event.buttons === 4) { // do nothing if mouse wheel is pressed, i.e. pan will be activated in onMouseDown return; } if (event.ctrlKey) { this.deltaY += event.deltaY; if (Math.abs(this.deltaY) > 10) { let secondToPx: number; if (this.deltaY < 0) { secondToPx = this.props.timelineState.nextSecondToPx; } else { secondToPx = this.props.timelineState.previousSecondToPx; } this.deltaY = 0; const rect = this.svgRef.current!.getBoundingClientRect(); let scrollLeft = ((this.props.timelineState.scrollLeft + event.clientX - rect.x) * secondToPx) / this.props.timelineState.secondToPx - (event.clientX - rect.x); scrollLeft = this.limitScrollLeft(scrollLeft); runInAction(() => { this.props.timelineState.scrollLeft = scrollLeft; this.props.timelineState.secondToPx = secondToPx; }); this.props.updateHorizontalScoll(); } } else { let scrollLeft = this.props.timelineState.scrollLeft + event.deltaY; scrollLeft = this.limitScrollLeft(scrollLeft); runInAction(() => { this.props.timelineState.scrollLeft = scrollLeft; }); this.props.updateHorizontalScoll(); } }; genTicks(delta: number) { const ticks = []; for ( let i = 0; i <= Math.floor(this.props.timelineState.duration / delta); i++ ) { ticks.push(roundPosition(i * delta)); } return ticks; } get ticks() { if (this.props.timelineState.step == 0.01) { return this.genTicks(0.1); } return this.genTicks(1); } get subticks() { if (this.props.timelineState.step == 0.01) { return this.genTicks(0.01); } return this.genTicks(0.1); } render() { const { timelineState } = this.props; return ( {timelineState.rubberBendRect && ( )} ); } } ); //////////////////////////////////////////////////////////////////////////////// const Timeline = observer( ({ timelineState, ticks, subticks }: { timelineState: PageTimelineEditorState; ticks: number[]; subticks: number[]; }) => { return ( {subticks .filter(x => timelineState.isVerticalLineVisible( timelineState.positionToPx(x) ) ) .map(x => ( ))} {ticks .filter(x => timelineState.isVerticalLineVisible( timelineState.positionToPx(x) ) ) .map(x => ( {Math.abs(x - timelineState.position) > 1e-4 && ( {x} )} ))} ); } ); //////////////////////////////////////////////////////////////////////////////// const Rows = observer( ({ timelineState }: { timelineState: PageTimelineEditorState }) => { return ( {timelineState.treeAdapter.allRows.map((row, rowIndex) => ( ))} {timelineState.treeAdapter.allRows.map((row, rowIndex) => { const widget = timelineState.treeAdapter.getItemObject( row.item ) as Widget; return ( {widget.timeline.map(keyframe => { const { cx, cy, x1, y1, x2, y2 } = timelineState.getKeyframeCircleBoundingRect( rowIndex, keyframe ); return ( {timelineState.selectedKeyframes.indexOf( keyframe ) != -1 && keyframe.start == keyframe.end && ( )} ); })} ); })} ); } ); const Row = observer( ({ timelineState, row, rowIndex }: { timelineState: PageTimelineEditorState; row: ITreeRow; rowIndex: number; }) => { const widget = timelineState.treeAdapter.getItemObject( row.item ) as Widget; const rowRect = timelineState.getRowRect(rowIndex); return ( {widget.timeline .filter(keyframe => keyframe.start < keyframe.end) .map(keyframe => ( ))} ); } ); const Keyframe = observer( ({ timelineState, rowIndex, keyframe }: { timelineState: PageTimelineEditorState; rowIndex: number; keyframe: TimelineKeyframe; }) => { const keyframeRect = timelineState.getKeyframeRect(rowIndex, keyframe); const x1 = keyframeRect.left; const x2 = keyframeRect.left + keyframeRect.width; const y1 = keyframeRect.top; const y2 = keyframeRect.top + keyframeRect.height; const KEYFRAME_MAX_OFFSET = 8; const offset = Math.min(x2 - x1, KEYFRAME_MAX_OFFSET); const path = `M${x1},${(y1 + y2) / 2} ` + `L${x1 + offset},${y1} ` + `L${x2},${y1} ` + `L${x2},${y2} ` + `L${x1 + offset},${y2} ` + `L${x1},${(y1 + y2) / 2}`; // const path = // `M${x1},${y2} ` + // `L${x2},${y1} ` + // `L${x2},${y2} ` + // `L${x1},${y2}`; return ( {timelineState.selectedKeyframes.indexOf(keyframe) != -1 && ( )} ); } ); //////////////////////////////////////////////////////////////////////////////// function hitTest( timelineState: PageTimelineEditorState, e: PointerEvent, x: number, y: number ): DragSettings { const svg: SVGSVGElement = closestBySelector(e.target, "svg"); if (!svg) { return { mode: "none", cursor: "default" }; } const rectSvg = svg.getBoundingClientRect(); x -= rectSvg.x; y -= rectSvg.y; const startPoint = { x, y }; if (y < TIMELINE_HEIGHT) { x += timelineState.scrollLeft; return { mode: "timeline-position", startPoint, cursor: Math.abs(timelineState.positionPx - x) < 8 ? "ew-resize" : "default" }; } x += timelineState.scrollLeft; y += timelineState.scrollTop; const point = { x, y }; const dragStartPosition = timelineState.pxToPosition(x); let rowIndex = timelineState.getRowIndexFromY(y); if (rowIndex != -1) { const widget = timelineState.treeAdapter.getItemObject( timelineState.treeAdapter.allRows[rowIndex].item ) as Widget; if (!widget.locked && !widget.hiddenInEditor) { for ( let keyframeIndex = 0; keyframeIndex < widget.timeline.length; keyframeIndex++ ) { const keyframe = widget.timeline[keyframeIndex]; if (timelineState.selectedKeyframes.indexOf(keyframe) != -1) { const hitTestResult = rowHitTest( point, rowIndex, keyframe, timelineState, dragStartPosition ); if (hitTestResult) { return hitTestResult; } } } for ( let keyframeIndex = 0; keyframeIndex < widget.timeline.length; keyframeIndex++ ) { const keyframe = widget.timeline[keyframeIndex]; if (timelineState.selectedKeyframes.indexOf(keyframe) == -1) { const hitTestResult = rowHitTest( point, rowIndex, keyframe, timelineState, dragStartPosition ); if (hitTestResult) { return hitTestResult; } } } return { mode: "row", cursor: "default", widget }; } } return { mode: "rubber-band", cursor: "default", startPoint }; } function rowHitTest( point: Point, rowIndex: number, keyframe: TimelineKeyframe, timelineState: PageTimelineEditorState, dragStartPosition: number ): DragSettings | undefined { const rect1 = timelineState.getKeyframeRect(rowIndex, keyframe); const rect2 = timelineState.getKeyframeCircleBoundingRect( rowIndex, keyframe ); if ( pointInRect(point, rect1) || pointInRect(point, { left: rect2.x1, top: rect2.y1, width: rect2.x2 - rect2.x1, height: rect2.y2 - rect2.y1 }) ) { return { mode: "keyframe", cursor: "grab", dragStartPosition, keyframe, keyframeEnd: keyframe.end, ends: [], minDelta: 0, maxDelta: 0 }; } const D = 20; if ( pointInRect(point, { left: rect1.left - D, top: rect1.top, width: D, height: rect1.height }) || pointInRect(point, { left: rect2.x1 - D, top: rect2.y1, width: D, height: rect2.y2 - rect2.y1 }) ) { return { mode: "keyframe-start", cursor: "ew-resize", dragStartPosition, keyframe, keyframeStart: keyframe.start, starts: [], minDelta: 0, maxDelta: 0 }; } if ( pointInRect(point, { left: rect1.left + rect1.width, top: rect1.top, width: D, height: rect1.height }) || pointInRect(point, { left: rect2.x2, top: rect2.y1, width: D, height: rect2.y2 - rect2.y1 }) ) { return { mode: "keyframe-end", cursor: "ew-resize", dragStartPosition, keyframe, keyframeEnd: keyframe.end, ends: [], minDelta: 0, maxDelta: 0 }; } return undefined; } function roundPosition(position: number) { return Math.round(position * 100) / 100; }