UNPKG

labo-components

Version:
541 lines (465 loc) 17.2 kB
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;