UNPKG

@checksub_team/peaks_timeline

Version:

JavaScript UI component for displaying audio waveforms

1,604 lines (1,296 loc) 49.9 kB
/** * @file * * Defines the {@link SourcesLayer} class. * * @module sources-layer */ define([ './source-group', './line-groups', './data-retriever', './invoker', '../utils', 'konva' ], function( SourceGroup, LineGroups, DataRetriever, Invoker, Utils, Konva) { 'use strict'; /** * Creates a Konva.Layer that displays sources on the timeline. * * @class * @alias SourcesLayer * * @param {Peaks} peaks * @param {WaveformOverview|WaveformZoomView} view * @param {Boolean} allowEditing */ function SourcesLayer(peaks, view, allowEditing) { this._peaks = peaks; this._view = view; this._allowEditing = allowEditing; this._sourcesGroup = {}; this._layer = new Konva.Layer(); this._dataRetriever = new DataRetriever(peaks); this._lineGroups = new LineGroups(peaks, view, this); this._lineGroups.addToLayer(this); // Drag overlay container. // Use a Group inside the main sources layer to avoid adding an extra Stage layer. this._dragGroup = new Konva.Group({ listening: false }); this._layer.add(this._dragGroup); this._loadedData = {}; // Create invoker for performance optimizations this._invoker = new Invoker(); // Version counter for cancellable rescale operations this._rescaleVersion = 0; // Throttled batch draw to prevent excessive redraws this._throttledBatchDraw = this._invoker.throttleTrailing( this._layer.batchDraw.bind(this._layer) ); // Pending draw flag to coalesce multiple draw requests this._drawPending = false; // Used to suppress drag lifecycle callbacks when a dragged source is // temporarily stopped/restarted as part of a rebuild. this._suppressDragLifecycleForSourceId = null; this._sourceHandleDragSession = null; this._segmentDragSession = null; this._peaks.on('handler.sources.add', this._onSourcesAdd.bind(this)); this._peaks.on('handler.sources.destroy', this._onSourcesDestroy.bind(this)); this._peaks.on('handler.sources.show', this._onSourcesShow.bind(this)); this._peaks.on('handler.sources.hide', this._onSourcesHide.bind(this)); this._peaks.on('sources.setSelected', this._onSourcesSetSelected.bind(this)); this._peaks.on('segments.setSelected', this._onSegmentsSetSelected.bind(this)); this._peaks.on('model.source.update', this._onSourceUpdate.bind(this)); this._peaks.on('data.retrieved', this._onDataRetrieved.bind(this)); this._peaks.on('handler.segments.show', this._onSegmentsShow.bind(this)); this._peaks.on('model.source.setIndicators', this.setIndicators.bind(this)); this._peaks.on('handler.view.mouseup', this._stopDrag.bind(this)); this._peaks.on('sources.delayedLineChange', this._onSourcesDelayedLineChanged.bind(this)); } SourcesLayer.prototype._onSourcesDelayedLineChanged = function() { // Update dragged source groups when sources change line after a delay (e.g., after automatic line creation) if (this._draggedElements && this._draggedElements.length > 0) { this._dragSourcesGroup(); } }; SourcesLayer.prototype._stopDrag = function() { const draggedSourceGroup = this._sourcesGroup[this._draggedElementId]; if (draggedSourceGroup) { draggedSourceGroup.stopDrag(); } if (this._segmentDragSession && this._segmentDragSession.draggedNode) { this._segmentDragSession.draggedNode.stopDrag(); } }; SourcesLayer.prototype.fitToView = function() { this._lineGroups.fitToView(); }; SourcesLayer.prototype.getLoadedData = function(id) { return this._loadedData[id]; }; SourcesLayer.prototype.setLoadedData = function(id, data) { this._loadedData[id] = data; }; SourcesLayer.prototype.getSegmentsGroups = function() { return this._lineGroups.getSegmentsGroups(); }; /** * Returns all segments in the given segment group or displayed line whose start time is at or after the given time. * * @param {String|Number} lineId * @param {Number} time * @returns {Array<Segment>} */ SourcesLayer.prototype.getSegmentsOnLineAfter = function(lineId, time) { return this._lineGroups.getSegmentsOnLineAfter(lineId, time); }; SourcesLayer.prototype.add = function(element) { this._layer.add(element); // Keep drag group on top even if other callers add elements later. if (this._dragGroup) { this._dragGroup.moveToTop(); } }; /** * Adds the layer to the given {Konva.Stage}. * * @param {Konva.Stage} stage */ SourcesLayer.prototype.addToStage = function(stage) { stage.add(this._layer); }; SourcesLayer.prototype.enableEditing = function(enable) { this._allowEditing = enable; }; SourcesLayer.prototype.isEditingEnabled = function() { return this._allowEditing; }; SourcesLayer.prototype.refresh = function() { var frameOffset = this._view.getFrameOffset(); var width = this._view.getWidth(); this.updateSources( this._view.pixelsToTime(frameOffset), this._view.pixelsToTime(frameOffset + width) ); }; SourcesLayer.prototype._onSourceUpdate = function(source, meta) { const sourceGroup = this._sourcesGroup[source.id]; const frameOffset = this._view.getFrameOffset(); const width = this._view.getWidth(); const frameStartTime = this._view.pixelsToTime(frameOffset); const frameEndTime = this._view.pixelsToTime(frameOffset + width); var redraw = false; var isSourceGroupHovered = false; var isSourceGroupDragged = false; var isActiveDraggedSource = false; // If a source is updated while being dragged, we still want the update to apply. // But destroying a node mid-drag without stopping the drag can leave an orphaned // visual on Konva's drag layer. So we stop drag, rebuild, and resume. var draggedAbsPos = null; var pointerPos = null; // Fast-path: if only the custom waveform changed, update the existing // preview in-place and redraw without rebuilding the entire group. if (sourceGroup && meta && meta.onlyWaveformData) { sourceGroup.updateWaveformDataPreview(true); return; } if (sourceGroup) { isSourceGroupHovered = sourceGroup.isHovered(); isSourceGroupDragged = sourceGroup.isDragged(); // Only the actively mouse-dragged source should have its Konva drag // stopped/restarted. Secondary sources in a multi-drag are positioned // manually (absolutePosition) and should not be put into Konva's drag // layer. isActiveDraggedSource = Boolean( isSourceGroupDragged && this.isDragInProgress() && this._draggedElementId && this._draggedElementId === source.id ); if (isSourceGroupDragged) { draggedAbsPos = sourceGroup.absolutePosition(); } if (isActiveDraggedSource) { pointerPos = this._view.getPointerPosition(); // Ensure Konva's internal drag state is cleaned up before destruction. // We suppress our drag lifecycle handlers so multi-drag state isn't // torn down/reinitialized for a single frame. this._suppressDragLifecycleForSourceId = source.id; sourceGroup.stopDrag(); sourceGroup.setDragging(false); } this._destroySourceGroup(source); redraw = true; } if (source.isVisible(frameStartTime, frameEndTime) || isSourceGroupDragged) { const newSourceGroup = this._addSourceGroup(source); if (isSourceGroupHovered) { newSourceGroup.startHover(); } if (isSourceGroupDragged) { // Put the rebuilt group back into the drag group. newSourceGroup.setDragging(true); if (draggedAbsPos) { newSourceGroup.moveTo(this._dragGroup); newSourceGroup.absolutePosition(draggedAbsPos); } if (isActiveDraggedSource) { // Recompute offsets so the cursor-to-node relation stays stable. if (pointerPos && draggedAbsPos) { this._dragOffsetX = draggedAbsPos.x - pointerPos.x; this._dragOffsetY = draggedAbsPos.y - pointerPos.y; } else { this._dragOffsetX = undefined; this._dragOffsetY = undefined; } // Only resume Konva drag for the active mouse-dragged source. newSourceGroup.startDrag(); } } redraw = true; } if (this._suppressDragLifecycleForSourceId === source.id) { this._suppressDragLifecycleForSourceId = null; } if (redraw) { this.updateSources(frameStartTime, frameEndTime); } }; SourcesLayer.prototype._onSourcesSetSelected = function(sources) { sources.forEach(function(source) { const sourceGroup = this._sourcesGroup[source.id]; if (sourceGroup) { sourceGroup.setSelected(); } }.bind(this)); this.batchDraw(); }; SourcesLayer.prototype._onSegmentsSetSelected = function(segments) { var segmentsGroups = this._lineGroups.getSegmentsGroups(); segments.forEach(function(segment) { var segmentsGroup = segmentsGroups[segment.line]; if (segmentsGroup) { segmentsGroup.setSelected(segment); } }); this.batchDraw(); }; SourcesLayer.prototype._onSourcesAdd = function(sources) { 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); sources.forEach(function(source) { self._addSourceGroup(source, source.isVisible(frameStartTime, frameEndTime)); }); this._view.updateTimelineLength(); this.updateSources(frameStartTime, frameEndTime); }; SourcesLayer.prototype._onSourcesDestroy = function(sources) { var self = this; sources.forEach(function(source) { self._removeSource(source); }); this._view.updateTimelineLength(); this.batchDraw(); }; SourcesLayer.prototype._onSourcesShow = function(sources) { var self = this; sources.forEach(function(source) { self._sourcesGroup[source.id].setWrapping(false, true); }); this.batchDraw(); }; SourcesLayer.prototype._onSourcesHide = function(sources) { var self = this; sources.forEach(function(source) { self._sourcesGroup[source.id].setWrapping(true, true); }); this.batchDraw(); }; SourcesLayer.prototype._onDataRetrieved = function(data, source, url) { if (data) { var type = data.type.split('/')[0]; var sourceGroup = this._sourcesGroup[source.id]; if (sourceGroup) { switch (type) { case 'image': sourceGroup.addImagePreview(data.content, url, true); break; case 'video': sourceGroup.addVideoPreview(data.content, url, true); break; case 'binary': case 'text': case 'application': case 'other': case 'audio': sourceGroup.addAudioPreview(type, data.content, url, true); break; default: // Type not handled } } } }; SourcesLayer.prototype._onSegmentsShow = function(segmentsGroupId, lineId) { this._lineGroups.addSegments(segmentsGroupId, lineId); this._view.updateTimelineLength(); this.batchDraw(); }; /** * Creates the Konva UI objects for a given source. * * @private * @param {Source} source * @returns {Kanva.Group} */ SourcesLayer.prototype._createSourceGroup = function(source) { return new SourceGroup(source, this._peaks, this, this._view); }; /** * Adds a Konva UI object to the layer for a given source. * * @private * @param {Source} source * @returns {Konva.Group} */ SourcesLayer.prototype._addSourceGroup = function(source, startDataRetrieval = true) { var sourceGroup = this._createSourceGroup(source); this._sourcesGroup[source.id] = sourceGroup; this._lineGroups.addSource(source, sourceGroup); // If a custom waveform is provided on the source, render it immediately. // This is local data, so no network retrieval is needed for the waveform. if (source && source.waveformData) { sourceGroup.addWaveformDataPreview(false); } // After creating and referencing the new group, we can start data retrieval if (startDataRetrieval) { this._dataRetriever.retrieveData(source); } return sourceGroup; }; SourcesLayer.prototype.setIndicators = function(source) { var sourceGroup = this._sourcesGroup[source.id]; if (sourceGroup) { sourceGroup.createIndicators(); this.batchDraw(); } }; /** * Updates the positions of all displayed sources in the view. * Uses optimized batching to reduce draw calls. * * @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. */ SourcesLayer.prototype.updateSources = function(startTime, endTime) { // Update segments this._lineGroups.updateSegments(startTime, endTime); // Update sources in visible time range. var sources = this.findSources(startTime, endTime); var count = sources.length; // Batch update all sources for (var i = 0; i < sources.length; i++) { this._updateSource(sources[i]); } count += this._removeInvisibleSources(startTime, endTime); if (count > 0) { // Use throttled batch draw for consistent performance this.batchDraw(); } }; SourcesLayer.prototype.isDragInProgress = function() { return this._draggedElements && this._draggedElements.length > 0; }; SourcesLayer.prototype.cleanupAfterDrag = function() { this._initialSourcePositions = null; this._dragOffsetX = undefined; this._dragOffsetY = undefined; this._draggedElements = null; this._draggedElementId = null; this._draggedElementsData = null; }; SourcesLayer.prototype.cleanupAfterSourceHandleDrag = function() { this._sourceHandleDragSession = null; }; SourcesLayer.prototype.onSourceHandleDragStart = function(sourceGroup, leftHandle) { var source = sourceGroup.getSource(); this._sourceHandleDragSession = { source: source, leftHandle: leftHandle, mouseDownX: this._view.getPointerPosition().x, initialTimeOffset: this._view.getTimeOffset(), initialStartTime: source.startTime, initialEndTime: source.endTime }; }; SourcesLayer.prototype.onSourceHandleDrag = function(draggedElement) { this._view.updateWithAutoScroll(this._dragSourceHandle.bind(this), null, false); return { x: draggedElement.absolutePosition().x, y: draggedElement.absolutePosition().y }; }; SourcesLayer.prototype._dragSourceHandle = function() { var session = this._sourceHandleDragSession; var pointer = this._view.getPointerPosition(); var pointerX; var diff; var timeOffsetDiff; var shouldRedraw; if (!session) { return; } pointerX = pointer ? pointer.x : session.mouseDownX; diff = this._view.pixelsToTime(pointerX - session.mouseDownX); timeOffsetDiff = this._view.getTimeOffset() - session.initialTimeOffset; shouldRedraw = this.manageSourceMovements( [session.source], session.leftHandle ? session.initialStartTime + diff + timeOffsetDiff : null, session.leftHandle ? null : session.initialEndTime + diff + timeOffsetDiff ); if (shouldRedraw) { this.batchDraw(); } }; SourcesLayer.prototype.onSourceHandleDragEnd = function(sourceGroup) { this.cleanupAfterSourceHandleDrag(); this.processSourceUpdates([sourceGroup.getSource()]); }; SourcesLayer.prototype.isSegmentDragInProgress = function() { return Boolean(this._segmentDragSession); }; SourcesLayer.prototype.cleanupAfterSegmentDrag = function() { this._segmentDragSession = null; }; SourcesLayer.prototype._getSelectedSegmentsForDrag = function(draggedSegment) { var selectedElements = this._view.getSelectedElements(); var selectedSegments; var draggedSegmentIsSelected; if (!Array.isArray(selectedElements)) { selectedElements = Object.values(selectedElements); } selectedSegments = selectedElements.filter(function(element) { return !Utils.isNullOrUndefined(element.line) && Utils.isNullOrUndefined(element.lineId); }); draggedSegmentIsSelected = selectedSegments.some(function(segment) { return segment.id === draggedSegment.id; }); if (!draggedSegmentIsSelected) { selectedSegments = [draggedSegment]; this._view.deselectAll(); } return selectedSegments; }; SourcesLayer.prototype._buildSegmentDragGroups = function(segments) { var segmentsGroups = this._lineGroups.getSegmentsGroups(); var groupsByLine = {}; segments.forEach(function(segment) { var groupContext = groupsByLine[segment.line]; if (!groupContext) { groupContext = { group: segmentsGroups[segment.line], segments: [], initialPositions: {} }; groupsByLine[segment.line] = groupContext; } groupContext.segments.push(segment); groupContext.initialPositions[segment.id] = { startTime: segment.startTime, endTime: segment.endTime }; }); return Object.keys(groupsByLine).map(function(lineId) { groupsByLine[lineId].segments.sort(function(a, b) { return a.startTime - b.startTime; }); return groupsByLine[lineId]; }); }; SourcesLayer.prototype.onSegmentDragStart = function(segmentShape) { var segment = segmentShape.getSegment(); var selectedSegments = this._getSelectedSegmentsForDrag(segment); var groups = this._buildSegmentDragGroups(selectedSegments); var minTimeOffset = -Infinity; var maxTimeOffset = Infinity; var draggable = true; var shouldClampToAdjacentGroups = !(groups.length === 1 && groups[0].segments.length === 1); groups.forEach(function(groupContext) { if (shouldClampToAdjacentGroups) { var limits = groupContext.group.getSegmentsDragTimeLimits( groupContext.segments, groupContext.initialPositions ); minTimeOffset = Math.max(minTimeOffset, limits.minTimeOffset); maxTimeOffset = Math.min(maxTimeOffset, limits.maxTimeOffset); } draggable = draggable && groupContext.segments.every(function(draggedSegment) { return draggedSegment.editable; }); }); this._segmentDragSession = { kind: 'move', draggedNode: segmentShape, draggedSegment: segment, mouseDownX: this._view.getPointerPosition().x, initialTimeOffset: this._view.getTimeOffset(), groups: groups, minTimeOffset: minTimeOffset, maxTimeOffset: maxTimeOffset, appliedTimeOffset: 0, draggable: draggable }; this._peaks.emit('segments.dragstart', segment); }; SourcesLayer.prototype.onSegmentDrag = function(draggedElement) { this._view.updateWithAutoScroll(this._dragSegments.bind(this), null, false); return { x: draggedElement.absolutePosition().x, y: draggedElement.absolutePosition().y }; }; SourcesLayer.prototype._dragSegments = function() { var session = this._segmentDragSession; var pointerPos; var mousePos; var timeDiff; var timeOffsetDiff; var requestedTimeOffset; var appliedTimeOffset; var groupContext; var segment; var initialPosition; if (!session || session.kind !== 'move' || !session.draggable) { return; } pointerPos = this._view.getPointerPosition(); mousePos = Math.min( this._view.getWidth() - this._peaks.options.autoScrollThreshold * this._view.getWidth(), Math.max(0, pointerPos ? pointerPos.x : session.mouseDownX) ); timeDiff = this._view.pixelsToTime(mousePos - session.mouseDownX); timeOffsetDiff = this._view.getTimeOffset() - session.initialTimeOffset; requestedTimeOffset = Utils.roundTime(timeDiff + timeOffsetDiff); appliedTimeOffset = Utils.clamp( requestedTimeOffset, session.minTimeOffset, session.maxTimeOffset ); if (appliedTimeOffset === session.appliedTimeOffset) { return; } if (session.groups.length === 1 && session.groups[0].segments.length === 1) { groupContext = session.groups[0]; segment = groupContext.segments[0]; initialPosition = groupContext.initialPositions[segment.id]; groupContext.group.updateSegment( segment, this._view.timeToPixels(initialPosition.startTime + appliedTimeOffset), this._view.timeToPixels(initialPosition.endTime + appliedTimeOffset), false ); } else { session.groups.forEach(function(groupContext) { groupContext.group.dragSegmentsByTimeOffset( groupContext.segments, groupContext.initialPositions, appliedTimeOffset ); }); } session.appliedTimeOffset = appliedTimeOffset; this._view.updateTimelineLength(); this.batchDraw(); }; SourcesLayer.prototype.onSegmentDragEnd = function(segmentShape) { var session = this._segmentDragSession; if (session && session.kind === 'move' && session.appliedTimeOffset !== 0) { session.groups.forEach(function(groupContext) { groupContext.group.markSegmentsUpdated(groupContext.segments); }); this._view.updateTimelineLength(); } this.cleanupAfterSegmentDrag(); this._peaks.emit('segments.dragend', segmentShape.getSegment()); }; SourcesLayer.prototype.onSegmentHandleDragStart = function(segmentMarker) { this._segmentDragSession = { kind: segmentMarker.isStartMarker() ? 'resize-start' : 'resize-end', draggedNode: segmentMarker, segment: segmentMarker.getSegment(), group: this._lineGroups.getSegmentsGroups()[segmentMarker.getSegment().line], mouseDownX: this._view.getPointerPosition().x, initialTimeOffset: this._view.getTimeOffset(), initialStartTime: segmentMarker.getSegment().startTime, initialEndTime: segmentMarker.getSegment().endTime, startMarker: segmentMarker.isStartMarker() }; this._peaks.emit('segments.dragstart', segmentMarker.getSegment(), segmentMarker.isStartMarker()); }; SourcesLayer.prototype.onSegmentHandleDrag = function(segmentMarker) { this._view.updateWithAutoScroll(this._dragSegmentHandle.bind(this), null, false); return { x: segmentMarker.getAbsolutePosition().x, y: segmentMarker.getAbsolutePosition().y }; }; SourcesLayer.prototype._dragSegmentHandle = function() { var session = this._segmentDragSession; var pointerPos; var pointerX; var timeDiff; var timeOffsetDiff; var newStartTime; var newEndTime; if (!session || (session.kind !== 'resize-start' && session.kind !== 'resize-end')) { return; } pointerPos = this._view.getPointerPosition(); pointerX = pointerPos ? pointerPos.x : session.mouseDownX; timeDiff = this._view.pixelsToTime(pointerX - session.mouseDownX); timeOffsetDiff = this._view.getTimeOffset() - session.initialTimeOffset; if (session.startMarker) { newStartTime = Utils.roundTime(session.initialStartTime + timeDiff + timeOffsetDiff); if (session.segment.duration) { newStartTime = Math.max( newStartTime, Utils.roundTime(session.segment.endTime - session.segment.duration) ); } session.group.updateSegment( session.segment, this._view.timeToPixels(newStartTime), null, false ); } else { newEndTime = Utils.roundTime(session.initialEndTime + timeDiff + timeOffsetDiff); if (session.segment.duration) { newEndTime = Math.min( newEndTime, Utils.roundTime(session.segment.startTime + session.segment.duration) ); } session.group.updateSegment( session.segment, null, this._view.timeToPixels(newEndTime), false ); } this._view.updateTimelineLength(); this.batchDraw(); }; SourcesLayer.prototype.onSegmentHandleDragEnd = function(segmentMarker) { this._view.updateTimelineLength(); this.cleanupAfterSegmentDrag(); this._peaks.emit('segments.dragend', segmentMarker.getSegment(), segmentMarker.isStartMarker()); }; SourcesLayer.prototype.onSourcesGroupDragStart = function(element) { if (this._suppressDragLifecycleForSourceId && element && element.currentTarget && element.currentTarget.attrs && element.currentTarget.attrs.sourceId === this._suppressDragLifecycleForSourceId) { return; } this._mouseDownX = this._view.getPointerPosition().x; this._initialTimeOffset = this._view.getTimeOffset(); this._dragOffsetX = undefined; this._dragOffsetY = undefined; this._draggedElementId = element.currentTarget.attrs.sourceId; var selectedElements = this._view.getSelectedElements(); const shouldDragSelectedElements = Object.keys(selectedElements).includes( this._draggedElementId ); if (shouldDragSelectedElements) { this._draggedElements = Object.values(selectedElements).sort((a, b) => a.startTime - b.startTime); } else { this._draggedElements = [this._sourcesGroup[this._draggedElementId].getSource()]; this._view.deselectAll(); } this._draggedElementsData = this._draggedElements.reduce(function(bounds, source) { bounds.initialStartTime = Math.min(source.startTime, bounds.initialStartTime); bounds.initialEndTime = Math.max(source.endTime, bounds.initialEndTime); bounds.orderable = source.orderable && bounds.orderable; bounds.draggable = source.draggable && bounds.draggable; return bounds; }, { initialStartTime: Infinity, initialEndTime: -Infinity, orderable: true, draggable: true }); var self = this; this._initialSourcePositions = {}; this._draggedElements.forEach(function(source) { // Store initial position for ALL dragged sources (even those outside view) // This is needed for time calculations during drag self._initialSourcePositions[source.id] = { startTime: source.startTime, endTime: source.endTime, lineId: source.lineId }; var sourceGroup = self._sourcesGroup[source.id]; if (sourceGroup) { // Mark as dragging (for all sources, not just the clicked one) sourceGroup.setDragging(true); // Get absolute Y position before moving (relative to line group) var absoluteY = sourceGroup.getAbsoluteY(); // Move source to the drag group so it draws above ALL other sources/segments // without introducing an additional Konva.Layer on the Stage. sourceGroup.moveTo(self._dragGroup); // Restore the Y position (now relative to drag layer, which is at y=0) sourceGroup.y(absoluteY); } }); }; SourcesLayer.prototype.onSourcesGroupDragEnd = function(element) { if (this._suppressDragLifecycleForSourceId && element && element.currentTarget && element.currentTarget.attrs && element.currentTarget.attrs.sourceId === this._suppressDragLifecycleForSourceId) { return; } var self = this; const updatedSources = this._draggedElements.map( function(source) { const sourceGroup = self._sourcesGroup[source.id]; if (sourceGroup) { // Clear dragging state before moving back to line group sourceGroup.setDragging(false); // Move source back to its line group (it was moved to layer during drag) self._lineGroups.addSource(source, sourceGroup); // Reset Y position to 0 relative to parent line group // (source followed cursor during drag, now snap to line position) sourceGroup.y(0); } return source; } ); this.cleanupAfterDrag(); this.refresh(); this.processSourceUpdates(updatedSources); }; SourcesLayer.prototype._getInitialPixelOffsetFromClickedSource = function(draggedSourceId, clickedInitialPixelX) { if (!this._initialSourcePositions) { return null; } var initialPos = this._initialSourcePositions[draggedSourceId]; if (!initialPos) { return null; } var initialPixelX = this._view.timeToPixels(initialPos.startTime); return initialPixelX - clickedInitialPixelX; }; SourcesLayer.prototype._maybeCreateDraggedSourceGroupInViewport = function( draggedSource, clickedSourceX, clickedSourceY, clickedInitialPixelX, viewWidth ) { if (!draggedSource || this._sourcesGroup[draggedSource.id]) { return false; } var pixelOffset = this._getInitialPixelOffsetFromClickedSource(draggedSource.id, clickedInitialPixelX); if (pixelOffset === null) { return false; } var absX = clickedSourceX + pixelOffset; var width = this._view.timeToPixels(draggedSource.endTime - draggedSource.startTime); // If it intersects the visible viewport, create it now. if (absX > viewWidth || (absX + width) < 0) { return false; } var createdGroup = this._addSourceGroup(draggedSource); createdGroup.setDragging(true); createdGroup.moveTo(this._dragGroup); createdGroup.absolutePosition({ x: absX, y: clickedSourceY }); return true; }; SourcesLayer.prototype._createMissingDraggedSourceGroupsInViewport = function(clickedSourceX, clickedSourceY) { if (!this._draggedElements || this._draggedElements.length <= 1 || !this._initialSourcePositions || !this._draggedElementId || !this._initialSourcePositions[this._draggedElementId]) { return; } var clickedInitialPos = this._initialSourcePositions[this._draggedElementId]; var clickedInitialPixelX = this._view.timeToPixels(clickedInitialPos.startTime); var viewWidth = this._view.getWidth(); for (var i = 0; i < this._draggedElements.length; i++) { var draggedSource = this._draggedElements[i]; if (draggedSource && draggedSource.id !== this._draggedElementId) { this._maybeCreateDraggedSourceGroupInViewport( draggedSource, clickedSourceX, clickedSourceY, clickedInitialPixelX, viewWidth ); } } }; SourcesLayer.prototype._positionSecondaryDraggedSourceGroups = function(clickedSourceX, clickedSourceY) { if (!this._draggedElements || this._draggedElements.length <= 1 || !this._initialSourcePositions) { return; } var clickedInitialPos = this._initialSourcePositions[this._draggedElementId]; if (!clickedInitialPos) { return; } var clickedInitialPixelX = this._view.timeToPixels(clickedInitialPos.startTime); var self = this; this._draggedElements.forEach(function(source) { if (source.id === self._draggedElementId) { return; } var sourceGroup = self._sourcesGroup[source.id]; if (!sourceGroup) { return; } var pixelOffset = self._getInitialPixelOffsetFromClickedSource(source.id, clickedInitialPixelX); if (pixelOffset === null) { return; } sourceGroup.absolutePosition({ x: clickedSourceX + pixelOffset, y: clickedSourceY }); }); }; SourcesLayer.prototype.onSourcesGroupDrag = function(draggedElement) { var pointerPos = this._view.getPointerPosition(); this._view.updateWithAutoScroll(this._dragSourcesGroup.bind(this), null, true); // Return position that follows the mouse cursor exactly var clickedSourceGroup = this._sourcesGroup[this._draggedElementId]; if (clickedSourceGroup) { var mouseX = pointerPos.x; var mouseY = pointerPos.y; var offsetX = this._dragOffsetX || 0; var offsetY = this._dragOffsetY || 0; // Calculate offset on first drag if not set if (this._dragOffsetX === undefined) { var currentPos = draggedElement.absolutePosition(); this._dragOffsetX = currentPos.x - mouseX; this._dragOffsetY = currentPos.y - mouseY; offsetX = this._dragOffsetX; offsetY = this._dragOffsetY; } var clickedSourceX = mouseX + offsetX; var clickedSourceY = mouseY + offsetY; // If we're dragging multiple sources, some dragged sources might not yet // have a SourceGroup (they were outside the view when the drag started). // Create them as soon as their on-canvas bounds intersect the viewport. this._createMissingDraggedSourceGroupsInViewport(clickedSourceX, clickedSourceY); // Position all other dragged sources relative to the clicked source. this._positionSecondaryDraggedSourceGroups(clickedSourceX, clickedSourceY); return { x: clickedSourceX, y: clickedSourceY }; } return { x: draggedElement.absolutePosition().x, y: draggedElement.absolutePosition().y }; }; SourcesLayer.prototype._dragSourcesGroup = function() { var mousePos = Math.min( this._view.getWidth() - this._peaks.options.autoScrollThreshold * this._view.getWidth(), Math.max( 0, this._view.getPointerPosition().x ) ); const timeDiff = this._view.pixelsToTime(mousePos - this._mouseDownX); const timeOffsetDiff = this._view.getTimeOffset() - this._initialTimeOffset; const mousePosX = this._view.getPointerPosition().x; const mousePosY = this._view.getPointerPosition().y; const { initialStartTime, initialEndTime, orderable, draggable } = this._draggedElementsData; if (!draggable) { return; } var newStartTime = Utils.roundTime(initialStartTime + timeOffsetDiff + timeDiff); var newEndTime = Utils.roundTime(initialEndTime + timeOffsetDiff + timeDiff); const shouldRedraw = this.manageSourceMovements( this._draggedElements, newStartTime, newEndTime, orderable, mousePosX, mousePosY ); if (shouldRedraw) { this.batchDraw(); } }; SourcesLayer.prototype.processSourceUpdates = function(updatedSources) { this._view.batchDrawSourcesLayer(); this._view.updateTimelineLength(); this._peaks.emit('sources.updated', updatedSources); }; SourcesLayer.prototype.findSources = function(startTime, endTime) { var sources = this._peaks.sourceHandler.find(startTime, endTime); var lineIds = this._lineGroups.getVisibleLines(); return sources.filter( function(source) { return lineIds[source.lineId]; } ); }; /** * Updates source times during drag using initial positions. * * @private * @param {Number} newStartTime The new start time for the first source */ SourcesLayer.prototype._updateSourceTimesDuringDrag = function(newStartTime) { if (!this._initialSourcePositions || !this._draggedElements) { return; } var self = this; var firstSourceInitial = this._initialSourcePositions[this._draggedElements[0].id]; if (!firstSourceInitial) { return; } // Calculate time diff from INITIAL position, not current position var timeDiff = Utils.roundTime(newStartTime - firstSourceInitial.startTime); this._draggedElements.forEach(function(source) { var initialPos = self._initialSourcePositions[source.id]; if (initialPos) { source.updateTimes( Utils.roundTime(initialPos.startTime + timeDiff), Utils.roundTime(initialPos.endTime + timeDiff) ); } }); }; SourcesLayer.prototype._applyTimeChangesToSources = function(sources, initialStartTime, newStartTime, newEndTime ) { if (!sources || sources.length === 0) { return; } // Single-source updates can be moves or resizes; Source.updateTimes handles // undefined for one side. if (sources.length === 1) { sources[0].updateTimes(newStartTime, newEndTime); return; } // Multi-source updates are treated as a block move, so we need a numeric // start time to compute a delta. (Single-source resizes can pass null/undefined // for one side, but that does not apply to multi-source moves.) if (typeof newStartTime !== 'number') { return; } // During an active drag, always compute a single delta from the drag-start // bounds and apply it from the stored drag-start positions. This prevents // drift/jumps when sources are rebuilt or lines are changed repeatedly // without releasing the drag. if (this.isDragInProgress() && this._draggedElementsData && this._initialSourcePositions && typeof this._draggedElementsData.initialStartTime === 'number' && isFinite(this._draggedElementsData.initialStartTime)) { var timeDiffFromDragStart = Utils.roundTime(newStartTime - this._draggedElementsData.initialStartTime); for (var d = 0; d < sources.length; d++) { var draggedSource = sources[d]; var initialPos = this._initialSourcePositions[draggedSource.id]; // Be defensive: if a source enters the dragged set mid-drag (or an // entry was missing), capture its baseline once to avoid drift. if (!initialPos) { this._initialSourcePositions[draggedSource.id] = { startTime: draggedSource.startTime, endTime: draggedSource.endTime, lineId: draggedSource.lineId }; initialPos = this._initialSourcePositions[draggedSource.id]; } draggedSource.updateTimes( Utils.roundTime(initialPos.startTime + timeDiffFromDragStart), Utils.roundTime(initialPos.endTime + timeDiffFromDragStart) ); } return; } var timeDiff = Utils.roundTime(newStartTime - initialStartTime); if (timeDiff !== 0) { for (var s = 0; s < sources.length; s++) { var source = sources[s]; source.updateTimes( Utils.roundTime(source.startTime + timeDiff), Utils.roundTime(source.endTime + timeDiff) ); } } }; // WARNING: This assumes that no sources between the start and the end are unselected SourcesLayer.prototype.manageSourceMovements = function(sources, newStartTime, newEndTime, orderable, mouseX, mouseY ) { newStartTime = typeof newStartTime === 'number' ? Utils.roundTime(newStartTime) : newStartTime; newEndTime = typeof newEndTime === 'number' ? Utils.roundTime(newEndTime) : newEndTime; if (this._peaks.options.canMoveSourcesBetweenLines && typeof mouseY === 'number') { this.manageVerticalPosition(sources, newStartTime, newEndTime, mouseX, mouseY); } if (orderable) { ({ newStartTime, newEndTime } = this.manageOrder(sources, newStartTime, newEndTime)); } ({ newStartTime, newEndTime } = this.manageCollision(sources, newStartTime, newEndTime)); this._applyTimeChangesToSources(sources, sources[0].startTime, newStartTime, newEndTime); this._view.setTimelineLength( this._view.timeToPixels(sources[sources.length - 1].endTime) + this._view.getWidth() ); this.refresh(); return true; }; SourcesLayer.prototype.manageVerticalPosition = function(sources, startTime, endTime, mouseX, mouseY) { return this._lineGroups.manageVerticalPosition(sources, startTime, endTime, mouseX, mouseY); }; SourcesLayer.prototype.manageOrder = function(sources, startTime, endTime) { return this._lineGroups.manageOrder(sources, startTime, endTime); }; SourcesLayer.prototype.manageCollision = function(sources, newStartTime, newEndTime) { return this._lineGroups.manageCollision(sources, newStartTime, newEndTime); }; /** * @private */ SourcesLayer.prototype._updateSource = function(source) { var sourceGroup = this._findOrAddSourceGroup(source); sourceGroup.update(); }; SourcesLayer.prototype._findOrAddSourceGroup = function(source) { var sourceGroup = this._sourcesGroup[source.id]; var isNewlyCreated = false; if (!sourceGroup) { sourceGroup = this._addSourceGroup(source); isNewlyCreated = true; } // If this source is being dragged and was just recreated (came back into view), // set it up properly for dragging if (isNewlyCreated && this._initialSourcePositions && this._initialSourcePositions[source.id]) { // Mark as dragging sourceGroup.setDragging(true); // Compute where this source should be while the group drag is in progress. // We prefer positioning relative to the clicked dragged source so the // source appears immediately when it enters the viewport. var targetAbsPos = sourceGroup.absolutePosition(); var clickedId = this._draggedElementId; var clickedGroup = clickedId ? this._sourcesGroup[clickedId] : null; var clickedInitial = clickedId && this._initialSourcePositions ? this._initialSourcePositions[clickedId] : null; var currentInitial = this._initialSourcePositions[source.id]; if (clickedGroup && clickedInitial && currentInitial) { var clickedAbsPos = clickedGroup.absolutePosition(); var clickedInitialPixelX = this._view.timeToPixels(clickedInitial.startTime); var currentInitialPixelX = this._view.timeToPixels(currentInitial.startTime); var pixelOffset = currentInitialPixelX - clickedInitialPixelX; targetAbsPos = { x: clickedAbsPos.x + pixelOffset, y: clickedAbsPos.y }; } // Move to drag group and apply the computed absolute position. sourceGroup.moveTo(this._dragGroup); sourceGroup.absolutePosition(targetAbsPos); } return sourceGroup; }; /** * Removes any sources 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 sources removed. */ SourcesLayer.prototype._removeInvisibleSources = function(startTime, endTime) { var count = 0; for (var sourceId in this._sourcesGroup) { if (Utils.objectHasProperty(this._sourcesGroup, sourceId)) { var sourceGroup = this._sourcesGroup[sourceId]; if (sourceGroup.isActive()) { sourceGroup.update(); } else { var source = this._sourcesGroup[sourceId].getSource(); if (!this._isSourceVisible(source, startTime, endTime)) { this._destroySourceGroup(source); count++; } } } } return count; }; SourcesLayer.prototype._isSourceVisible = function(source, startTime, endTime) { return source.isVisible(startTime, endTime) && this._lineGroups.isLineVisible(source.lineId); }; /** * Get all visible sources. * * @private * @returns {Array<Source>} The visible sources. */ SourcesLayer.prototype._getVisibleSources = 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 visibleSources = []; for (var sourceId in this._sourcesGroup) { if (Utils.objectHasProperty(this._sourcesGroup, sourceId)) { var source = this._sourcesGroup[sourceId].data; if (source.isVisible(frameStartTime, frameEndTime)) { visibleSources.push(source); } } } return visibleSources; }; SourcesLayer.prototype._getOverlappedSources = function() { var self = this; var sources = this._getVisibleSources(); return sources.reduce(function(result, source) { sources.forEach(function(source2) { if (self._sourcesGroupOverlapped(source, source2)) { if (!result.includes(source2.id)) { result.push(source2); } } }); return result; }, []); }; /** * Removes the given source from the view. * * @param {Source} source */ SourcesLayer.prototype._removeSource = function(source) { const sourceGroup = this._sourcesGroup[source.id]; if (sourceGroup) { delete this._sourcesGroup[source.id]; this._lineGroups.removeSource(source); sourceGroup.destroy(); } }; SourcesLayer.prototype._destroySourceGroup = function(source) { const sourceGroup = this._sourcesGroup[source.id]; if (sourceGroup) { delete this._sourcesGroup[source.id]; this._lineGroups.removeSourceGroup(source); sourceGroup.destroy(); } }; /** * Toggles visibility of the sources layer. * * @param {Boolean} visible */ SourcesLayer.prototype.setVisible = function(visible) { this._layer.setVisible(visible); }; /** * Schedules a batch draw using RAF throttling for better performance. * Multiple calls within the same frame are coalesced into one draw. */ SourcesLayer.prototype.batchDraw = function() { this._throttledBatchDraw(); }; SourcesLayer.prototype.listening = function(bool) { this._layer.listening(bool); }; SourcesLayer.prototype.stopDrag = function() { this._layer.stopDrag(); }; SourcesLayer.prototype.getSourceGroupById = function(sourceId) { return this._sourcesGroup[sourceId]; }; SourcesLayer.prototype.getSourcesOnLineAfter = function(lineId, time) { return this._lineGroups.getSourcesOnLineAfter(lineId, time); }; SourcesLayer.prototype._sourcesOverlapped = function(source1, source2) { var endsLater = (source1.startTime < source2.startTime) && (source1.endTime > source2.startTime); var startsEarlier = (source1.startTime > source2.startTime) && (source1.startTime < source2.endTime); return endsLater || startsEarlier; }; SourcesLayer.prototype.rescale = function() { // Increment the rescale version to cancel any in-progress rescale this._rescaleVersion = (this._rescaleVersion || 0) + 1; this._rescale(this._rescaleVersion); }; SourcesLayer.prototype._rescale = function(version) { var self = this; var ids = Object.keys(this._sourcesGroup); var urls = []; var index = 0; function processNext() { // Check if this rescale was cancelled (a newer one started) if (self._rescaleVersion !== version) { return; } if (index >= ids.length) { // Done processing all sources self.batchDraw(); return; } var id = ids[index]; var sourceGroup = self._sourcesGroup[id]; // Skip if source group was removed during async processing if (sourceGroup) { var audioPreviews = sourceGroup.getAudioPreview(); audioPreviews.forEach(function(audioPreview) { if (self._shouldResampleAudio(audioPreview.url, urls)) { self._loadedData[audioPreview.url + '-scaled'] = { data: self._resampleAudio(audioPreview.url), scale: self._view.getTimeToPixelsScale() }; urls.push(audioPreview.url); } }); } index++; // Yield to allow cancellation and UI updates Utils.scheduleIdle(processNext, { timeout: 16 }); } processNext(); }; SourcesLayer.prototype._shouldResampleAudio = function(audioUrl, urls) { return this._loadedData[audioUrl + '-scaled'] && !urls.includes(audioUrl) && this._loadedData[audioUrl + '-scaled'].scale !== this._view.getTimeToPixelsScale(); }; SourcesLayer.prototype._resampleAudio = function(audioUrl) { return this._loadedData[audioUrl].resample({ scale: this._loadedData[audioUrl].sample_rate / this._view.getTimeToPixelsScale() }); }; SourcesLayer.prototype.destroy = function() { this._peaks.off('handler.sources.add', this._onSourcesAdd); this._peaks.off('handler.sources.destroy', this._onSourcesDestroy); this._peaks.off('handler.sources.show', this._onSourcesShow); this._peaks.off('handler.sources.hide', this._onSourcesHide); this._peaks.off('model.source.update', this._onSourceUpdate); this._peaks.off('data.retrieved', this._onDataRetrieved); this._peaks.off('handler.segments.show', this._onSegmentsShow); this._peaks.off('model.source.setIndicators', this.setIndicators); this._peaks.off('handler.view.mouseup', this._stopDrag); // Cancel any in-progress rescale this._rescaleVersion++; // Clean up invoker resources if (this._invoker) { this._invoker.destroy(); } }; SourcesLayer.prototype.getHeight = function() { return this._layer.getHeight(); }; SourcesLayer.prototype.getFullHeight = function() { return this._lineGroups.height(); }; SourcesLayer.prototype.getLength = function() { return this._lineGroups.linesLength(); }; SourcesLayer.prototype.getLineByPosition = function(pos) { return this._lineGroups.getLineByPosition(pos); }; SourcesLayer.prototype.getLineGroups = function() { return this._lineGroups; }; /** * Object for storing data and UI of a source. * * @typedef {Object} CompleteSource * @global * @property {Source} data * @property {Konva.Group} ui */ return SourcesLayer; });