@checksub_team/peaks_timeline
Version:
JavaScript UI component for displaying audio waveforms
1,604 lines (1,296 loc) • 49.9 kB
JavaScript
/**
* @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;
});