labo-components
Version:
541 lines (465 loc) • 17.2 kB
JSX
import React from "react";
import PropTypes from "prop-types";
import IDUtil from "../../../../util/IDUtil";
import { stringContains } from "./_stringHelpers";
import Actions from "./Actions";
import Cursor from "./Cursor";
import Layers, { LayersPropTypes } from "./Layers";
import LayerHeaders from "./LayerHeaders";
import Axis from "./Axis";
import ZoomDragBox from "./ZoomDragBox";
// Main component that combines all sub components to a fully functional timeline
class Timeline extends React.Component {
constructor(props) {
super(props);
this.state = {
// timeline view start
start: this.props.viewStart ? this.props.viewStart : 0,
// timeline view end
end: this.props.viewEnd ? this.props.viewEnd : 30,
// current position
position: 1,
// bounding box of right column
boundingBox: { x: 0, y: 0, width: 1, height: 1 },
// layers filtered by search Term
layers: [],
// search terms
searchTerms: [],
// active layer id
activeLayerId: null,
// active section id
activeSectionId: "",
};
// optional settings
this.autoScrollOffset = this.props.autoScrollOffset
? this.props.autoScrollOffset
: 2;
this.minDuration = this.props.minDuration ? this.props.minDuration : 3;
// refs
this.rightColumnRef = React.createRef();
}
componentDidMount() {
// prepare data
this.updateLayers();
// bounding box
this.updateBoundingBox();
// request a new bounding box on page resize
window.addEventListener("resize", this.updateBoundingBox);
}
componentWillUnmount() {
window.removeEventListener("resize", this.updateBoundingBox);
}
// set new bounding box to the state
updateBoundingBox = () => {
this.setState({
boundingBox: this.rightColumnRef.current.getBoundingClientRect(),
});
};
// limit the given position value to the max start and end time
limit = (position) =>
Math.min(this.props.end, Math.max(this.props.start, position));
// set cursor position
// call parent in this.props.setPosition if callParent
setPosition = (position, callParent = true, cursorToCenter = false) => {
// set position
position = this.limit(position);
this.setState({ position });
const autoScrollOffset = Math.min(
Math.abs(this.props.start - this.state.position),
Math.min(
Math.abs(this.props.end - this.state.position),
this.autoScrollOffset / this.getPixelsPerSecond()
)
);
// cursor to timeline center
if (cursorToCenter) {
const duration = this.state.end - this.state.start;
const mid = this.state.start + duration / 2;
if (
position - duration * 0.1 > this.state.end ||
position + duration * 0.1 < this.state.start
) {
// put in center immediately
this.onMove(position - mid);
} else if (position > mid) {
// animate to center
this.onMove(1 / this.getPixelsPerSecond());
}
}
// auto scroll start
if (position - autoScrollOffset < this.state.start) {
this.onMove(position - autoScrollOffset - this.state.start);
}
// auto scroll end
if (position + autoScrollOffset > this.state.end) {
this.onMove(position + autoScrollOffset - this.state.end);
}
this.autoSelectActive();
// external callback
if (callParent) {
this.props.setPosition(position);
}
};
// can be called externally using a ref
setPositionExternal = (pos) => {
this.setPosition(pos, false, true);
};
autoSelectActive() {
// auto select the active section in the active layer
if (this.state.activeLayerId) {
const layer = this.getLayerById(this.state.activeLayerId);
if (layer) {
for (let i = layer.sections.length - 1, section; i >= 0; i--) {
section = layer.sections[i];
if (
section.start <= this.state.position &&
section.end > this.state.position
) {
this.setState({
activeSectionId: section.id,
});
break;
}
}
}
}
}
selectLayer = (activeLayerId) => {
this.setState({ activeLayerId }, () => {
this.autoSelectActive();
});
};
// Get active layer id
// can be called externally using a ref
getActiveLayer = () => {
return this.state.activeLayerId;
};
setStart = (start) =>
this.setState({
start: this.limit(start),
});
setEnd = (end) =>
this.setState({
end: this.limit(end),
});
// move the timeline by the given step
onMove = (step, autoScroll = false) => {
// calculate new start, end
const duration = this.state.end - this.state.start;
let start = this.limit(this.state.start + step);
let end = start + duration;
if (end > this.props.end) {
start -= end - this.props.end;
end = this.props.end;
}
// auto scroll
// get scroll offset
let autoScrollOffset =
this.autoScrollOffset / this.getPixelsPerSecond();
// - make scroll offset 0 near start/end
if (
Math.min(
this.props.end - this.state.position,
this.state.position - this.props.start
) < autoScrollOffset
) {
autoScrollOffset = 0;
}
let position =
autoScroll && start > this.state.position
? start
: autoScroll && end < this.state.position
? end
: this.state.position;
if (autoScroll && position !== this.state.position) {
this.setPosition(position, true);
}
// set the state
this.setState({ start, end });
};
// zoom the timeline by the given factor
// perc(entage) indicates where is zoomed, so we zoom in/out on perc% of the view
onZoom = (factor, perc) => {
const duration = this.state.end - this.state.start;
if (duration < this.minDuration && factor > 1) {
return;
}
const newDuration = duration * factor;
const durationDiff = newDuration - duration;
let start = this.limit(this.state.start + durationDiff * perc);
let end = this.limit(this.state.end - durationDiff * (1 - perc));
// handle case when heavy scrolling on touchpad results in incorrect values
if (start > end) {
if (end === 0) {
end = this.minDuration;
}
start = end - this.minDuration;
}
this.setState({ start, end });
};
// use the given search term to extract searchterms that are stored in the state
onSearch = (searchTerm) => {
this.setState({
searchTerms: searchTerm
? searchTerm.trim().toLowerCase().split(" ")
: [],
});
};
// helper: Get a layer by the given id
getLayerById = (id) => {
const { layers } = this.state;
for (let i = 0, len = layers.length; i < len; i++) {
if (layers[i].id === id) {
return layers[i];
}
}
return null;
};
// helper: Get a Section by the given layer and section ids
getSectionById = (layerId, sectionId) => {
const layer = this.getLayerById(layerId);
if (!layer) {
return null;
}
for (let i = 0, len = layer.sections.length; i < len; i++) {
if (layer.sections[i].id === sectionId) {
return layer.sections[i];
}
}
return null;
};
// zoom to active section
onZoomToSelected = () => {
const { activeLayerId, activeSectionId } = this.state;
const section = this.getSectionById(activeLayerId, activeSectionId);
if (!section) {
return;
}
this.setState({
start: this.limit(section.start),
end: this.limit(section.end),
});
};
// select active layer and section
onSelect = (activeLayerId, activeSectionId) => {
this.setState({
activeLayerId,
activeSectionId,
});
const section = this.getSectionById(activeLayerId, activeSectionId);
if (!section) {
return;
}
this.setPosition(section.start);
// keep section (start) in screen
if (section.start < this.state.start) {
this.onMove(section.start - this.state.start);
} else if (section.start >= this.state.end) {
section.end - section.start < this.state.end - this.state.start
? this.onMove(
section.start +
(section.end - section.start) -
this.state.end
)
: this.onMove(section.start - this.state.start);
}
};
// select next section in active layer
// in case there are searchterms, go the next match
onSelectNext = () => {
const { activeLayerId, activeSectionId, searchTerms } = this.state;
const layer = this.getLayerById(activeLayerId);
if (!layer) {
return;
}
const useSearch = searchTerms.length > 0;
// select next
for (
let i = 0, hit = false, len = layer.sections.length;
i < len;
i++
) {
// get index of active section
if (layer.sections[i].id === activeSectionId) {
hit = true;
continue;
}
// select section on first hit
if (
hit &&
(!useSearch ||
stringContains(layer.sections[i].rawData, searchTerms))
) {
this.onSelect(activeLayerId, layer.sections[i].id);
break;
}
}
};
// select previous section in active layer
// in case there are searchterms, go the previous match
onSelectPrev = () => {
const { activeLayerId, activeSectionId, searchTerms } = this.state;
const layer = this.getLayerById(activeLayerId);
if (!layer) {
return;
}
const useSearch = searchTerms.length > 0;
// select prev
for (let i = layer.sections.length - 1, hit = false; i >= 0; i--) {
// get index of active section
if (layer.sections[i].id === activeSectionId) {
hit = true;
continue;
}
// select section on first hit
if (
hit &&
(!useSearch ||
stringContains(layer.sections[i].rawData, searchTerms))
) {
this.onSelect(activeLayerId, layer.sections[i].id);
break;
}
}
};
// helper: pixels per second, needed for calculations
// can be called externally using a ref
getPixelsPerSecond = () =>
this.state.boundingBox.width / (this.state.end - this.state.start);
// update the layers data in the state
// indicated of sections match the current search terms
updateLayers() {
const { searchTerms } = this.state;
const useSearch = searchTerms.length > 0;
const layers = this.props.layers.map((layer) =>
Object.assign({}, layer, {
sections: layer.sections.map((section) => {
section.match = useSearch
? stringContains(section.rawData, searchTerms)
: true;
return section;
}),
})
);
this.setState({ layers });
}
componentDidUpdate(prevProps, prevState) {
// Check if layers should be updated
if (
// new search term
prevState.searchTerms !== this.state.searchTerms ||
// new layer data
prevProps.layers !== this.props.layers
) {
this.updateLayers();
}
}
render() {
const pixelsPerSecond = this.getPixelsPerSecond();
// Vars from state
const {
start,
end,
position,
boundingBox,
layers,
activeLayerId,
activeSectionId,
} = this.state;
// Vars from props
const { highlightSectionId, onDoubleClick } = this.props;
return (
<div className={IDUtil.cssClassName("timeline")}>
{/* Actions */}
<Actions
onSearch={this.onSearch}
onMove={this.onMove}
onZoom={this.onZoom}
duration={end - start}
onZoomToSelected={
activeLayerId != null && activeSectionId
? this.onZoomToSelected
: null
}
onSelectNext={
activeLayerId != null && activeSectionId
? this.onSelectNext
: null
}
onSelectPrev={
activeLayerId != null && activeSectionId
? this.onSelectPrev
: null
}
/>
{/* Row that contains the left/right columns */}
<div className="tl-row">
{/* Left column */}
<div className="tl-left">
<div className="tl-annotation-actions">
{this.props.actions}
</div>
<LayerHeaders
layers={layers}
activeLayerId={activeLayerId}
onClick={this.selectLayer}
/>
</div>
{/* Right column */}
<div className="tl-right" ref={this.rightColumnRef}>
{/* Zoom Drag Box */}
<ZoomDragBox
start={start}
position={position}
pixelsPerSecond={pixelsPerSecond}
boundingBox={boundingBox}
height={40}
setPosition={this.setPosition}
onMove={this.onMove}
onZoom={this.onZoom}
>
{/* Axis */}
<Axis
start={start}
end={end}
position={position}
pixelsPerSecond={pixelsPerSecond}
width={boundingBox.width}
height={40}
/>
{/* Layers */}
<Layers
layers={layers}
start={start}
end={end}
pixelsPerSecond={pixelsPerSecond}
onSectionClick={this.onSelect}
activeLayerId={activeLayerId}
highlightSectionId={highlightSectionId}
/>
{/* Cursor */}
<Cursor
start={start}
position={position}
pixelsPerSecond={pixelsPerSecond}
width={boundingBox.width}
/>
</ZoomDragBox>
</div>
</div>
</div>
);
}
}
Timeline.propTypes = {
start: PropTypes.number.isRequired,
end: PropTypes.number.isRequired,
setPosition: PropTypes.func.isRequired,
viewStart: PropTypes.number,
viewEnd: PropTypes.number,
layers: LayersPropTypes,
autoScrollOffset: PropTypes.number,
minDuration: PropTypes.number,
actions: PropTypes.object,
};
export default Timeline;