UNPKG

@checksub_team/peaks_timeline

Version:

JavaScript UI component for displaying audio waveforms

980 lines (797 loc) 27.9 kB
/** * @file * * Defines the {@link SegmentsGroup} class. * * @module segments-group */ define([ './segment-shape', '../utils', 'konva' ], function( SegmentShape, Utils, Konva) { 'use strict'; /** * Creates a Konva.Group that displays segment markers against the audio * waveform. * * @class * @alias SegmentsGroup * * @param {Peaks} peaks * @param {WaveformOverview|WaveformZoomView} view * @param {Boolean} allowEditing */ function SegmentsGroup(peaks, view, allowEditing) { this._peaks = peaks; this._view = view; this._allowEditing = allowEditing; this._firstSegmentId = null; this._segments = {}; this._lastSegmentId = null; this._cachedStartSegmentForActive = null; this._segmentShapes = {}; this._group = new Konva.Group(); this._updatedSegments = []; this._isMagnetized = false; this._peaks.on('handler.segments.setMagnetizing', this.setMagnetizing.bind(this)); this._peaks.on('model.segment.setIndicators', this.setIndicators.bind(this)); this._peaks.on('handler.segments.relative_ids_refreshed', this._onRelativeIdsRefreshed.bind(this)); } SegmentsGroup.prototype._onRelativeIdsRefreshed = function() { for (var id in this._segmentShapes) { if (Utils.objectHasProperty(this._segmentShapes, id)) { var segmentShape = this._segmentShapes[id]; var newText = '#' + segmentShape._segment.relativeId + ' ' + Utils.removeLineBreaks(segmentShape._segment.labelText); if (newText === segmentShape._label.text) { return; } segmentShape._label.setText(newText); } } }; SegmentsGroup.prototype.isEmpty = function() { return Object.keys(this._segments).length === 0; }; SegmentsGroup.prototype.countRemainingElements = function() { return Object.keys(this._segments).length; }; /** * Adds the group to the given {Konva.Group}. * * @param {Konva.Group} group */ SegmentsGroup.prototype.moveTo = function(group) { this._group.moveTo(group); }; SegmentsGroup.prototype.moveToTop = function() { this._group.moveToTop(); }; SegmentsGroup.prototype.enableEditing = function(enable) { this._allowEditing = enable; }; SegmentsGroup.prototype.isEditingEnabled = function() { return this._allowEditing; }; SegmentsGroup.prototype.y = function(value) { return this._group.y(value); }; SegmentsGroup.prototype.getActiveSegment = function(time) { var activeSegment = null; var previousSegment = null; var currentSegment = null; if (this._cachedStartSegmentForActive) { if (this._cachedStartSegmentForActive.startTime <= time) { if (this._cachedStartSegmentForActive.endTime > time) { return this._cachedStartSegmentForActive; } else { currentSegment = this._segments[this._cachedStartSegmentForActive.id]; } } } do { if (!currentSegment) { currentSegment = this._segments[this._firstSegmentId]; } else { previousSegment = currentSegment; currentSegment = this._segments[currentSegment.nextSegmentId]; } if (currentSegment) { if (currentSegment.segment.startTime > time) { // We didn't find an active segment and will not in the remainings segments if (previousSegment) { this._cachedStartSegmentForActive = previousSegment.segment; } break; } if (currentSegment.segment.startTime <= time && currentSegment.segment.endTime > time) { activeSegment = currentSegment.segment; this._cachedStartSegmentForActive = activeSegment; break; } } else { break; } } while (currentSegment.nextSegmentId); return activeSegment; }; SegmentsGroup.prototype.onSegmentsUpdate = function(segment) { if (this._segments[segment.id]) { var redraw = false; var segmentShape = this._segmentShapes[segment.id]; var frameOffset = this._view.getFrameOffset(); var width = this._view.getWidth(); var frameStartTime = this._view.pixelsToTime(frameOffset); var frameEndTime = this._view.pixelsToTime(frameOffset + width); this._deleteSegment(segment); this._addSegment(segment); if (segmentShape) { this._removeSegment(segment); redraw = true; } if (segment.isVisible(frameStartTime, frameEndTime)) { this._addSegmentShape(segment); redraw = true; } if (redraw) { this.updateSegments(frameStartTime, frameEndTime); } } }; SegmentsGroup.prototype.onSegmentUpdated = function() { this._peaks.emit('segments.updated', this._updatedSegments); this._updatedSegments = []; }; SegmentsGroup.prototype.onSegmentsAdd = function(segments) { var self = this; var frameOffset = self._view.getFrameOffset(); var width = self._view.getWidth(); var frameStartTime = self._view.pixelsToTime(frameOffset); var frameEndTime = self._view.pixelsToTime(frameOffset + width); segments.forEach(function(segment) { self._addSegment(segment); }); self.updateSegments(frameStartTime, frameEndTime); }; SegmentsGroup.prototype.onSegmentsRemove = function(segments) { var self = this; segments.forEach(function(segment) { var index = self._updatedSegments.indexOf(segment); if (index > -1) { self._updatedSegments.splice(index, 1); } self._removeSegment(segment); self._deleteSegment(segment); }); this._draw(); }; SegmentsGroup.prototype.onSegmentsRemoveAll = function() { this._group.removeChildren(); this._firstSegmentId = null; this._segments = {}; this._lastSegmentId = null; this._segmentShapes = {}; this._draw(); }; SegmentsGroup.prototype._addSegment = function(segment) { var newSegment = { segment: segment, prevSegmentId: null, nextSegmentId: null }; if (this._firstSegmentId) { var currentSegment = null; do { if (!currentSegment) { currentSegment = this._segments[this._firstSegmentId]; } else { currentSegment = this._segments[currentSegment.nextSegmentId]; } if (segment.startTime <= currentSegment.segment.startTime) { if (currentSegment.prevSegmentId) { this._segments[currentSegment.prevSegmentId].nextSegmentId = segment.id; newSegment.prevSegmentId = currentSegment.prevSegmentId; } else { this._firstSegmentId = segment.id; } currentSegment.prevSegmentId = segment.id; newSegment.nextSegmentId = currentSegment.segment.id; this._segments[segment.id] = newSegment; break; } } while (currentSegment.nextSegmentId); if (!newSegment.prevSegmentId && !newSegment.nextSegmentId) { currentSegment.nextSegmentId = segment.id; newSegment.prevSegmentId = currentSegment.segment.id; this._segments[segment.id] = newSegment; this._lastSegmentId = segment.id; } } else { this._firstSegmentId = segment.id; this._segments[segment.id] = newSegment; this._lastSegmentId = segment.id; } }; SegmentsGroup.prototype._deleteSegment = function(segment) { if (this._segments[segment.id].prevSegmentId) { this._segments[this._segments[segment.id].prevSegmentId].nextSegmentId = this._segments[segment.id].nextSegmentId; } if (this._segments[segment.id].nextSegmentId) { this._segments[this._segments[segment.id].nextSegmentId].prevSegmentId = this._segments[segment.id].prevSegmentId; } if (this._firstSegmentId === segment.id) { this._firstSegmentId = this._segments[segment.id].nextSegmentId; } if (this._lastSegmentId === segment.id) { this._lastSegmentId = this._segments[segment.id].prevSegmentId; } delete this._segments[segment.id]; }; SegmentsGroup.prototype.getSegmentsGroupLength = function() { if (this._segments[this._lastSegmentId]) { return this._view.timeToPixels(this._segments[this._lastSegmentId].segment.endTime); } return 0; }; /** * Creates the Konva UI objects for a given segment. * * @private * @param {Segment} segment * @returns {SegmentShape} */ SegmentsGroup.prototype._createSegmentShape = function(segment) { return new SegmentShape(segment, this._peaks, this, this._view); }; /** * Adds a Konva UI object to the group for a given segment. * * @private * @param {Segment} segment * @returns {SegmentShape} */ SegmentsGroup.prototype._addSegmentShape = function(segment) { var segmentShape = this._createSegmentShape(segment); segmentShape.moveTo(this._group, this); this._segmentShapes[segment.id] = segmentShape; return segmentShape; }; SegmentsGroup.prototype.updateSegmentsOnMove = function(segment, marker) { this._updateSegments(segment, marker); }; /** * Updates the positions of all displayed segments in the view. * * @param {Number} startTime The start of the visible range in the view, * in seconds. * @param {Number} endTime The end of the visible range in the view, * in seconds. */ SegmentsGroup.prototype.updateSegments = function(startTime, endTime) { // Update segments in visible time range. var segments = this.find(startTime, endTime); var count = segments.length; segments.forEach(this._updateSegment.bind(this)); // TODO: in the overview all segments are visible, so no need to check count += this._removeInvisibleSegments(startTime, endTime); if (count > 0) { this._draw(); } }; SegmentsGroup.prototype.find = function(startTime, endTime) { var currentSegment = null; var visibleSegments = []; if (this._firstSegmentId) { do { if (!currentSegment) { currentSegment = this._segments[this._firstSegmentId]; } else { currentSegment = this._segments[currentSegment.nextSegmentId]; } if (currentSegment.segment.isVisible(startTime, endTime)) { visibleSegments.push(currentSegment.segment); } else if (visibleSegments.length) { break; } } while (currentSegment.nextSegmentId); } return visibleSegments; }; /** * Returns all segments on this line whose start time is at or after the given time. * * @param {Number} time * @returns {Array<Segment>} */ SegmentsGroup.prototype.getSegmentsAfter = function(time) { const segments = []; var currentId = this._firstSegmentId; while (currentId) { var segmentData = this._segments[currentId]; if (segmentData.segment.startTime >= time) { while (currentId) { segmentData = this._segments[currentId]; segments.push(segmentData.segment); currentId = segmentData.nextSegmentId; } break; } currentId = segmentData.nextSegmentId; } return segments; }; SegmentsGroup.prototype._draw = function() { this._view.batchDrawSourcesLayer(); }; /** * @private * @param {Segment} segment */ SegmentsGroup.prototype._updateSegment = function(segment) { var segmentShape = this._findOrAddSegmentShape(segment); segmentShape.update(); }; SegmentsGroup.prototype.getCurrentHeight = function() { var currentHeight = 0; for (var id in this._segmentShapes) { if (Utils.objectHasProperty(this._segmentShapes, id)) { currentHeight = this._segmentShapes[id].getSegmentHeight(); break; } } if (!currentHeight) { if (this.isEmpty()) { currentHeight = this._peaks.options.emptyLineHeight; } else { currentHeight = this._peaks.options.segmentHeight; } } return currentHeight; }; /** * @private * @param {Segment} segment */ SegmentsGroup.prototype._findOrAddSegmentShape = function(segment) { var segmentShape = this._segmentShapes[segment.id]; if (!segmentShape) { segmentShape = this._addSegmentShape(segment); } return segmentShape; }; /** * Removes any segments that are not visible, i.e., are not within and do not * overlap the given time range. * * @private * @param {Number} startTime The start of the visible time range, in seconds. * @param {Number} endTime The end of the visible time range, in seconds. * @returns {Number} The number of segments removed. */ SegmentsGroup.prototype._removeInvisibleSegments = function(startTime, endTime) { var count = 0; for (var segmentId in this._segmentShapes) { if (Utils.objectHasProperty(this._segmentShapes, segmentId)) { var segment = this._segmentShapes[segmentId].getSegment(); if (!segment.isVisible(startTime, endTime)) { this._removeSegment(segment); count++; } } } return count; }; SegmentsGroup.prototype.getVisibleSegments = function() { return this._getVisibleSegments(); }; SegmentsGroup.prototype._getVisibleSegments = function() { var frameOffset = this._view.getFrameOffset(); var width = this._view.getWidth(); var frameStartTime = this._view.pixelsToTime(frameOffset); var frameEndTime = this._view.pixelsToTime(frameOffset + width); var visibleSegments = []; for (var segmentId in this._segmentShapes) { if (Utils.objectHasProperty(this._segmentShapes, segmentId)) { var segment = this._segmentShapes[segmentId]._segment; if (segment.isVisible(frameStartTime, frameEndTime)) { visibleSegments.push(segment); } } } return visibleSegments; }; SegmentsGroup.prototype.setMagnetizing = function(bool) { this._isMagnetized = bool; }; SegmentsGroup.prototype.isMagnetized = function() { return this._isMagnetized; }; SegmentsGroup.prototype.markSegmentsUpdated = function(segments) { segments.forEach(this.addToUpdatedSegments.bind(this)); }; SegmentsGroup.prototype.setIndicators = function(segment) { var segmentShape = this._segmentShapes[segment.id]; if (segmentShape) { segmentShape.createIndicators(); this._draw(); } }; SegmentsGroup.prototype.setSelected = function(segment) { var segmentShape = this._segmentShapes[segment.id]; if (segmentShape) { segmentShape.setSelected(); this._draw(); } }; SegmentsGroup.prototype.addToUpdatedSegments = function(segment) { if (this._updatedSegments.indexOf(segment) === -1) { this._updatedSegments.push(segment); } }; SegmentsGroup.prototype.hasUpdatedSegments = function() { return this._updatedSegments.length > 0; }; /** * Returns the allowed drag offset bounds for a set of segments that move together. * * @param {Array<Segment>} segments * @param {Object<String, {startTime: Number, endTime: Number}>} initialPositions * @returns {{minTimeOffset: Number, maxTimeOffset: Number}} */ SegmentsGroup.prototype.getSegmentsDragTimeLimits = function(segments, initialPositions) { var selectedIds = {}; var minTimeOffset = -Infinity; var maxTimeOffset = Infinity; segments.forEach(function(segment) { selectedIds[segment.id] = true; }); segments.forEach(function(segment) { var segmentData = this._segments[segment.id]; var initialPosition = initialPositions[segment.id]; var previousSegmentId = segmentData.prevSegmentId; var nextSegmentId = segmentData.nextSegmentId; while (previousSegmentId && selectedIds[previousSegmentId]) { previousSegmentId = this._segments[previousSegmentId].prevSegmentId; } while (nextSegmentId && selectedIds[nextSegmentId]) { nextSegmentId = this._segments[nextSegmentId].nextSegmentId; } if (previousSegmentId) { minTimeOffset = Math.max( minTimeOffset, this._segments[previousSegmentId].segment.endTime - initialPosition.startTime ); } else { minTimeOffset = Math.max(minTimeOffset, -initialPosition.startTime); } if (nextSegmentId) { maxTimeOffset = Math.min( maxTimeOffset, this._segments[nextSegmentId].segment.startTime - initialPosition.endTime ); } }.bind(this)); return { minTimeOffset: minTimeOffset, maxTimeOffset: maxTimeOffset }; }; /** * Updates a set of dragged segments by the same time offset and refreshes only the visible slice. * * @param {Array<Segment>} segments * @param {Object<String, {startTime: Number, endTime: Number}>} initialPositions * @param {Number} timeOffset */ SegmentsGroup.prototype.dragSegmentsByTimeOffset = function(segments, initialPositions, timeOffset) { var hasChanges = false; segments.forEach(function(segment) { var initialPosition = initialPositions[segment.id]; var newStartTime = Utils.roundTime(initialPosition.startTime + timeOffset); var newEndTime = Utils.roundTime(initialPosition.endTime + timeOffset); if (segment.startTime !== newStartTime || segment.endTime !== newEndTime) { segment.updateTimes(newStartTime, newEndTime); hasChanges = true; } }); if (hasChanges) { this._updateVisibleSegmentsInView(); } }; /** * Returns the current visible time range for the view. * * @returns {{startTime: Number, endTime: Number}} */ SegmentsGroup.prototype._getVisibleTimeRange = function() { var frameOffset = this._view.getFrameOffset(); var width = this._view.getWidth(); return { startTime: this._view.pixelsToTime(frameOffset), endTime: this._view.pixelsToTime(frameOffset + width) }; }; /** * Refreshes the rendered segments for the current viewport without forcing an immediate draw. */ SegmentsGroup.prototype._updateVisibleSegmentsInView = function() { var visibleTimeRange = this._getVisibleTimeRange(); this.find(visibleTimeRange.startTime, visibleTimeRange.endTime) .forEach(this._updateSegment.bind(this)); this._removeInvisibleSegments(visibleTimeRange.startTime, visibleTimeRange.endTime); }; SegmentsGroup.prototype.updateSegment = function(segment, newStartX, newEndX, shouldDraw) { var newXs = this.manageCollision(segment, newStartX, newEndX); if (Utils.isNullOrUndefined(shouldDraw)) { shouldDraw = true; } if (!Utils.isNullOrUndefined(newXs.startX)) { segment.updateTimes(this._view.pixelsToTime(newXs.startX), null); } if (!Utils.isNullOrUndefined(newXs.endX)) { segment.updateTimes(null, this._view.pixelsToTime(newXs.endX)); } if (newXs) { this._updateSegment(segment); this.addToUpdatedSegments(segment); if (shouldDraw) { this._draw(); } } }; SegmentsGroup.prototype.manageCollision = function(segment, newStartX, newEndX) { var newStartTime = null; var newEndTime = null; var startLimited = false; var endLimited = false; var segmentMagnetThreshold, width, previousSegment, nextSegment, newXs; if (this._isMagnetized) { segmentMagnetThreshold = this._view.pixelsToTime( this._peaks.options.segmentMagnetThreshold ); if (!Utils.isNullOrUndefined(newStartX) && !Utils.isNullOrUndefined(newEndX)) { width = newEndX - newStartX; } } if (!Utils.isNullOrUndefined(newStartX)) { // startMarker changed newStartTime = this._view.pixelsToTime(newStartX); if (this._segments[segment.id].prevSegmentId) { // there is another segment to the left previousSegment = this._segments[this._segments[segment.id].prevSegmentId].segment; if (this._isMagnetized) { if (newStartTime < previousSegment.endTime + segmentMagnetThreshold) { newStartX = this._view.timeToPixels(previousSegment.endTime); if (width) { newEndX = newStartX + width; } return { startX: newStartX, endX: newEndX }; } } else if (segment.startTime >= newStartTime) { // startMarker moved to the left if (newStartTime < previousSegment.endTime) { // there is collision if (previousSegment.editable) { if (previousSegment.startTime + previousSegment.minSize > newStartTime) { newStartTime = previousSegment.startTime + previousSegment.minSize; startLimited = true; } if (previousSegment.endTime !== newStartTime) { newXs = this.manageCollision( previousSegment, this._view.timeToPixels(previousSegment.startTime), this._view.timeToPixels(newStartTime) ); if (!Utils.isNullOrUndefined(newXs.startX)) { previousSegment.startTime = this._view.pixelsToTime(newXs.startX); } if (!Utils.isNullOrUndefined(newXs.endX)) { previousSegment.endTime = this._view.pixelsToTime(newXs.endX); } this._updateSegment(previousSegment); this.addToUpdatedSegments(previousSegment); } } else { newStartTime = previousSegment.endTime; startLimited = true; } } } } else { if (newStartTime < 0) { newStartTime = 0; startLimited = true; } } } if (!Utils.isNullOrUndefined(newEndX)) { // endMarker changed newEndTime = this._view.pixelsToTime(newEndX); if (this._segments[segment.id].nextSegmentId) { // there is another segment to the right nextSegment = this._segments[this._segments[segment.id].nextSegmentId].segment; if (this._isMagnetized) { if (newEndTime > nextSegment.startTime - segmentMagnetThreshold) { newEndX = this._view.timeToPixels(nextSegment.startTime); if (width) { newStartX = newEndX - width; } return { startX: newStartX, endX: newEndX }; } } else if (segment.endTime <= newEndTime) { // endMarker moved to the right if (newEndTime > nextSegment.startTime) { // there is collision if (nextSegment.editable) { if (nextSegment.endTime - nextSegment.minSize < newEndTime) { newEndTime = nextSegment.endTime - nextSegment.minSize; endLimited = true; } if (nextSegment.startTime !== newEndTime) { newXs = this.manageCollision( nextSegment, this._view.timeToPixels(newEndTime), this._view.timeToPixels(nextSegment.endTime) ); if (!Utils.isNullOrUndefined(newXs.startX)) { nextSegment.startTime = this._view.pixelsToTime(newXs.startX); } if (!Utils.isNullOrUndefined(newXs.endX)) { nextSegment.endTime = this._view.pixelsToTime(newXs.endX); } this._updateSegment(nextSegment); this.addToUpdatedSegments(nextSegment); } } else { newEndTime = nextSegment.startTime; endLimited = true; } } } } else { // No limits on the right } } // Check for minimal size of segment if (!Utils.isNullOrUndefined(newStartTime) && !Utils.isNullOrUndefined(newEndTime)) { if (Utils.roundTime(newEndTime - newStartTime) < segment.minSize) { if (previousSegment && nextSegment) { if (Utils.roundTime(nextSegment.startTime - previousSegment.endTime) < segment.minSize) { return { startX: null, endX: null }; } } if (startLimited) { newEndTime = newStartTime + segment.minSize; } else if (endLimited) { newStartTime = newEndTime - segment.minSize; } } } else if (!Utils.isNullOrUndefined(newStartTime)) { if (Utils.roundTime(segment.endTime - newStartTime) < segment.minSize) { newStartTime = segment.endTime - segment.minSize; } } else if (!Utils.isNullOrUndefined(newEndTime)) { if (Utils.roundTime(newEndTime - segment.startTime) < segment.minSize) { newEndTime = segment.startTime + segment.minSize; } } var output = { startX: null, endX: null }; if (!Utils.isNullOrUndefined(newStartTime)) { output.startX = this._view.timeToPixels(newStartTime); } if (!Utils.isNullOrUndefined(newEndTime)) { output.endX = this._view.timeToPixels(newEndTime); } return output; }; SegmentsGroup.prototype._getOverlappedSegments = function() { var self = this; var segments = this._getVisibleSegments(); return segments.reduce(function(result, segment) { segments.forEach(function(segment2) { if (self._segmentsOverlapped(segment, segment2)) { if (!result.includes(segment2.id)) { result.push(segment2); } } }); return result; }, []); }; /** * Removes the given segment from the view. * * @param {Segment} segment */ SegmentsGroup.prototype._removeSegment = function(segment) { var segmentShape = this._segmentShapes[segment.id]; if (segmentShape) { delete this._segmentShapes[segment.id]; segmentShape.destroy(); } }; /** * Toggles visibility of the segments layer. * * @param {Boolean} visible */ SegmentsGroup.prototype.setVisible = function(visible) { this._group.setVisible(visible); }; SegmentsGroup.prototype.draw = function() { this._draw(); }; SegmentsGroup.prototype._segmentsOverlapped = function(segment1, segment2) { var endsLater = (segment1.startTime < segment2.startTime) && (segment1.endTime > segment2.startTime); var startsEarlier = (segment1.startTime > segment2.startTime) && (segment1.startTime < segment2.endTime); return endsLater || startsEarlier; }; SegmentsGroup.prototype.destroy = function() { this._peaks.off('handler.segments.setMagnetizing', this.setMagnetizing); this._peaks.off('model.segment.setIndicators', this.setIndicators); this._peaks.off('handler.segments.relative_ids_refreshed', this._onRelativeIdsRefreshed); }; SegmentsGroup.prototype.fitToView = function() { for (var segmentId in this._segmentShapes) { if (Utils.objectHasProperty(this._segmentShapes, segmentId)) { var segmentShape = this._segmentShapes[segmentId]; segmentShape.fitToView(); } } }; SegmentsGroup.prototype.contains = function(segment) { for (var id in this._segments) { if (Utils.objectHasProperty(this._segments, id)) { if (id === segment.id) { return true; } } } return false; }; SegmentsGroup.prototype.getHeight = function() { return this._group.getHeight(); }; return SegmentsGroup; });