wavesurfer.js
Version:
Interactive navigable audio visualization using Web Audio and Canvas
876 lines (762 loc) • 30.3 kB
JavaScript
/**
* @since 4.0.0
*
* (Single) Region plugin class
*
* Must be turned into an observer before instantiating. This is done in
* `RegionsPlugin` (main plugin class).
*
* @extends {Observer}
*/
export class Region {
constructor(params, regionsUtils, ws) {
this.wavesurfer = ws;
this.wrapper = ws.drawer.wrapper;
this.util = ws.util;
this.style = this.util.style;
this.regionsUtil = regionsUtils;
this.vertical = ws.drawer.params.vertical;
this.id = params.id == null ? ws.util.getId() : params.id;
this.start = Number(params.start) || 0;
this.end =
params.end == null
? // small marker-like region
this.start +
(4 / this.wrapper.scrollWidth) * this.wavesurfer.getDuration()
: Number(params.end);
this.resize =
params.resize === undefined ? true : Boolean(params.resize);
this.drag = params.drag === undefined ? true : Boolean(params.drag);
this.contentEditable = Boolean(params.contentEditable);
this.removeButton = Boolean(params.removeButton);
// reflect resize and drag state of region for region-updated listener
this.isResizing = false;
this.isDragging = false;
this.loop = Boolean(params.loop);
this.color = params.color || 'rgba(0, 0, 0, 0.1)';
// The left and right handleStyle properties can be set to 'none' for
// no styling or can be assigned an object containing CSS properties.
this.handleStyle = params.handleStyle || {
left: {},
right: {}
};
this.handleLeftEl = null;
this.handleRightEl = null;
this.data = params.data || {};
this.attributes = params.attributes || {};
this.showTooltip = params.showTooltip ?? true;
this.maxLength = params.maxLength;
// It assumes the minLength parameter value, or the regionsMinLength parameter value, if the first one not provided
this.minLength = params.minLength;
this._onRedraw = () => this.updateRender();
this.scroll = params.scroll !== false && ws.params.scrollParent;
this.scrollSpeed = params.scrollSpeed || 1;
this.scrollThreshold = params.scrollThreshold || 10;
// Determines whether the context menu is prevented from being opened.
this.preventContextMenu =
params.preventContextMenu === undefined
? false
: Boolean(params.preventContextMenu);
// select channel ID to set region
let channelIdx =
params.channelIdx == null ? -1 : parseInt(params.channelIdx);
this.channelIdx = channelIdx;
this.regionHeight = '100%';
this.marginTop = '0px';
if (channelIdx !== -1) {
let channelCount =
this.wavesurfer.backend.buffer != null
? this.wavesurfer.backend.buffer.numberOfChannels
: -1;
if (channelCount >= 0 && channelIdx < channelCount) {
this.regionHeight = Math.floor((1 / channelCount) * 100) + '%';
this.marginTop =
this.wavesurfer.getHeight() * channelIdx + 'px';
}
}
this.formatTimeCallback = params.formatTimeCallback;
this.edgeScrollWidth = params.edgeScrollWidth;
this.bindInOut();
this.render();
this.wavesurfer.on('zoom', this._onRedraw);
this.wavesurfer.on('redraw', this._onRedraw);
this.wavesurfer.fireEvent('region-created', this);
}
/* Update region params. */
update(params, eventParams) {
if (params.start != null) {
this.start = Number(params.start);
}
if (params.end != null) {
this.end = Number(params.end);
}
if (params.loop != null) {
this.loop = Boolean(params.loop);
}
if (params.color != null) {
this.color = params.color;
}
if (params.handleStyle != null) {
this.handleStyle = params.handleStyle;
}
if (params.data != null) {
this.data = params.data;
}
if (params.resize != null) {
this.resize = Boolean(params.resize);
this.updateHandlesResize(this.resize);
}
if (params.drag != null) {
this.drag = Boolean(params.drag);
}
if (params.maxLength != null) {
this.maxLength = Number(params.maxLength);
}
if (params.minLength != null) {
this.minLength = Number(params.minLength);
}
if (params.attributes != null) {
this.attributes = params.attributes;
}
this.updateRender();
this.fireEvent('update');
this.wavesurfer.fireEvent('region-updated', this, eventParams);
}
/* Remove a single region. */
remove() {
if (this.element) {
this.element.remove();
this.element = null;
this.fireEvent('remove');
this.wavesurfer.un('zoom', this._onRedraw);
this.wavesurfer.un('redraw', this._onRedraw);
this.wavesurfer.fireEvent('region-removed', this);
}
}
/**
* Play the audio region.
* @param {number} start Optional offset to start playing at
*/
play(start) {
const s = start || this.start;
this.wavesurfer.play(s, this.end);
this.fireEvent('play');
this.wavesurfer.fireEvent('region-play', this);
}
/**
* Play the audio region in a loop.
* @param {number} start Optional offset to start playing at
* */
playLoop(start) {
this.loop = true;
this.play(start);
}
/**
* Set looping on/off.
* @param {boolean} loop True if should play in loop
*/
setLoop(loop) {
this.loop = loop;
}
/* Render a region as a DOM element. */
render() {
this.element = this.util.withOrientation(
this.wrapper.appendChild(document.createElement('region')),
this.vertical
);
this.element.className = 'wavesurfer-region';
if (this.showTooltip) {
this.element.title = this.formatTime(this.start, this.end);
}
this.element.setAttribute('data-id', this.id);
for (const attrname in this.attributes) {
this.element.setAttribute(
'data-region-' + attrname,
this.attributes[attrname]
);
}
this.style(this.element, {
position: 'absolute',
zIndex: 3,
height: this.regionHeight,
top: this.marginTop
});
/* Button Remove Region */
if (this.removeButton){
const removeButtonEl = document.createElement('div');
removeButtonEl.className = 'remove-region-button';
removeButtonEl.textContent = '⨯';
this.removeButtonEl = this.element.appendChild(removeButtonEl);
const css = {
zIndex: 4,
position: 'absolute',
bottom: 0,
right: '4px',
cursor:'pointer',
fontSize: '20px',
lineHeight: '21px',
color: 'grey'
};
this.style(this.removeButtonEl, css);
}
/* Edit content */
if (this.contentEditable){
const contentEl = document.createElement('div');
contentEl.className = 'region-content';
contentEl.contentEditable = 'true';
contentEl.innerText = this.data.text || '';
this.contentEl = this.element.appendChild(contentEl);
const css = {
zIndex: 4,
padding: '2px 5px',
cursor:'text'};
this.style(this.contentEl, css);
}
/* Resize handles */
if (this.resize) {
this.handleLeftEl = this.util.withOrientation(
this.element.appendChild(document.createElement('handle')),
this.vertical
);
this.handleRightEl = this.util.withOrientation(
this.element.appendChild(document.createElement('handle')),
this.vertical
);
this.handleLeftEl.className = 'wavesurfer-handle wavesurfer-handle-start';
this.handleRightEl.className = 'wavesurfer-handle wavesurfer-handle-end';
// Default CSS properties for both handles.
const css = {
cursor: this.vertical ? 'row-resize' : 'col-resize',
position: 'absolute',
top: '0px',
width: '2px',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 1)'
};
// Merge CSS properties per handle.
const handleLeftCss =
this.handleStyle.left !== 'none'
? Object.assign(
{ left: '0px' },
css,
this.handleStyle.left
)
: null;
const handleRightCss =
this.handleStyle.right !== 'none'
? Object.assign(
{ right: '0px' },
css,
this.handleStyle.right
)
: null;
if (handleLeftCss) {
this.style(this.handleLeftEl, handleLeftCss);
}
if (handleRightCss) {
this.style(this.handleRightEl, handleRightCss);
}
}
this.updateRender();
this.bindEvents();
}
formatTime(start, end) {
if (this.formatTimeCallback) {
return this.formatTimeCallback(start, end);
}
return (start == end ? [start] : [start, end])
.map((time) =>
[
Math.floor((time % 3600) / 60), // minutes
('00' + Math.floor(time % 60)).slice(-2) // seconds
].join(':')
)
.join('-');
}
getWidth() {
return this.wavesurfer.drawer.width / this.wavesurfer.params.pixelRatio;
}
/* Update element's position, width, color. */
updateRender() {
// duration varies during loading process, so don't overwrite important data
const dur = this.wavesurfer.getDuration();
const width = this.getWidth();
let startLimited = this.start;
let endLimited = this.end;
if (startLimited < 0) {
startLimited = 0;
endLimited = endLimited - startLimited;
}
if (endLimited > dur) {
endLimited = dur;
startLimited = dur - (endLimited - startLimited);
}
if (this.minLength != null) {
endLimited = Math.max(startLimited + this.minLength, endLimited);
}
if (this.maxLength != null) {
endLimited = Math.min(startLimited + this.maxLength, endLimited);
}
if (this.element != null) {
// Calculate the left and width values of the region such that
// no gaps appear between regions.
const left = Math.round((startLimited / dur) * width);
const regionWidth = Math.round((endLimited / dur) * width) - left;
this.style(this.element, {
left: left + 'px',
width: regionWidth + 'px',
backgroundColor: this.color,
cursor: this.drag ? 'move' : 'default'
});
for (const attrname in this.attributes) {
this.element.setAttribute(
'data-region-' + attrname,
this.attributes[attrname]
);
}
if (this.showTooltip) {
this.element.title = this.formatTime(this.start, this.end);
}
}
}
/* Bind audio events. */
bindInOut() {
this.firedIn = false;
this.firedOut = false;
const onProcess = (time) => {
let start = Math.round(this.start * 10) / 10;
let end = Math.round(this.end * 10) / 10;
time = Math.round(time * 10) / 10;
if (
!this.firedOut &&
this.firedIn &&
(start > time || end <= time)
) {
this.firedOut = true;
this.firedIn = false;
this.fireEvent('out');
this.wavesurfer.fireEvent('region-out', this);
}
if (!this.firedIn && start <= time && end > time) {
this.firedIn = true;
this.firedOut = false;
this.fireEvent('in');
this.wavesurfer.fireEvent('region-in', this);
}
};
this.wavesurfer.backend.on('audioprocess', onProcess);
this.on('remove', () => {
this.wavesurfer.backend.un('audioprocess', onProcess);
});
/* Loop playback. */
this.on('out', () => {
if (this.loop) {
const realTime = this.wavesurfer.getCurrentTime();
if (realTime >= this.start && realTime <= this.end) {
this.wavesurfer.play(this.start);
}
}
});
}
/* Bind DOM events. */
bindEvents() {
const preventContextMenu = this.preventContextMenu;
this.element.addEventListener('mouseenter', (e) => {
this.fireEvent('mouseenter', e);
this.wavesurfer.fireEvent('region-mouseenter', this, e);
});
this.element.addEventListener('mouseleave', (e) => {
this.fireEvent('mouseleave', e);
this.wavesurfer.fireEvent('region-mouseleave', this, e);
});
this.element.addEventListener('click', (e) => {
e.preventDefault();
this.fireEvent('click', e);
this.wavesurfer.fireEvent('region-click', this, e);
});
this.element.addEventListener('dblclick', (e) => {
e.stopPropagation();
e.preventDefault();
this.fireEvent('dblclick', e);
this.wavesurfer.fireEvent('region-dblclick', this, e);
});
this.element.addEventListener('contextmenu', (e) => {
if (preventContextMenu) {
e.preventDefault();
}
this.fireEvent('contextmenu', e);
this.wavesurfer.fireEvent('region-contextmenu', this, e);
});
/* Drag or resize on mousemove. */
if (this.drag || this.resize) {
this.bindDragEvents();
}
/* Edit content */
if (this.contentEditable){
this.contentEl.addEventListener('blur', this.onContentBlur.bind(this));
this.contentEl.addEventListener('click', this.onContentClick.bind(this));
}
/* Remove button */
if (this.removeButton){
this.removeButtonEl.addEventListener('click', this.onRemove.bind(this));
}
}
bindDragEvents() {
const container = this.wavesurfer.drawer.container;
const scrollSpeed = this.scrollSpeed;
let startTime;
let touchId;
let drag;
let maxScroll;
let resize;
let updated = false;
let scrollDirection;
let wrapperRect;
let regionLeftHalfTime;
let regionRightHalfTime;
// Scroll when the user is dragging within the threshold
const edgeScroll = (event) => {
let orientedEvent = this.util.withOrientation(event, this.vertical);
const duration = this.wavesurfer.getDuration();
if (!scrollDirection || (!drag && !resize)) {
return;
}
const x = orientedEvent.clientX;
let distanceBetweenCursorAndWrapperEdge = 0;
let regionHalfTimeWidth = 0;
let adjustment = 0;
// Get the currently selected time according to the mouse position
let time = this.regionsUtil.getRegionSnapToGridValue(
this.wavesurfer.drawer.handleEvent(event) * duration
);
if (drag) {
// Considering the point of contact with the region while edgescrolling
if (scrollDirection === -1) {
regionHalfTimeWidth = regionLeftHalfTime * this.wavesurfer.params.minPxPerSec;
distanceBetweenCursorAndWrapperEdge = x - wrapperRect.left;
} else {
regionHalfTimeWidth = regionRightHalfTime * this.wavesurfer.params.minPxPerSec;
distanceBetweenCursorAndWrapperEdge = wrapperRect.right - x;
}
} else {
// Considering minLength while edgescroll
let minLength = this.minLength;
if (!minLength) {
minLength = 0;
}
if (resize === 'start') {
if (time > this.end - minLength) {
time = this.end - minLength;
adjustment = scrollSpeed * scrollDirection;
}
if (time < 0) {
time = 0;
}
} else if (resize === 'end') {
if (time < this.start + minLength) {
time = this.start + minLength;
adjustment = scrollSpeed * scrollDirection;
}
if (time > duration) {
time = duration;
}
}
}
// Don't edgescroll if region has reached min or max limit
const wrapperScrollLeft = this.wrapper.scrollLeft;
if (scrollDirection === -1) {
if (Math.round(wrapperScrollLeft) === 0) {
return;
}
if (Math.round(wrapperScrollLeft - regionHalfTimeWidth + distanceBetweenCursorAndWrapperEdge) <= 0) {
return;
}
} else {
if (Math.round(wrapperScrollLeft) === maxScroll) {
return;
}
if (Math.round(wrapperScrollLeft + regionHalfTimeWidth - distanceBetweenCursorAndWrapperEdge) >= maxScroll) {
return;
}
}
// Update scroll position
let scrollLeft = wrapperScrollLeft - adjustment + scrollSpeed * scrollDirection;
if (scrollDirection === -1) {
const calculatedLeft = Math.max(0 + regionHalfTimeWidth - distanceBetweenCursorAndWrapperEdge, scrollLeft);
this.wrapper.scrollLeft = scrollLeft = calculatedLeft;
} else {
const calculatedRight = Math.min(maxScroll - regionHalfTimeWidth + distanceBetweenCursorAndWrapperEdge, scrollLeft);
this.wrapper.scrollLeft = scrollLeft = calculatedRight;
}
const delta = time - startTime;
startTime = time;
// Continue dragging or resizing
drag ? this.onDrag(delta) : this.onResize(delta, resize);
// Repeat
window.requestAnimationFrame(() => {
edgeScroll(event);
});
};
const onDown = (event) => {
const duration = this.wavesurfer.getDuration();
if (event.touches && event.touches.length > 1) {
return;
}
touchId = event.targetTouches ? event.targetTouches[0].identifier : null;
// stop the event propagation, if this region is resizable or draggable
// and the event is therefore handled here.
if (this.drag || this.resize) {
event.stopPropagation();
}
// Store the selected startTime we begun dragging or resizing
startTime = this.regionsUtil.getRegionSnapToGridValue(
this.wavesurfer.drawer.handleEvent(event, true) * duration
);
// Store the selected point of contact when we begin dragging
regionLeftHalfTime = startTime - this.start;
regionRightHalfTime = this.end - startTime;
// Store for scroll calculations
maxScroll = this.wrapper.scrollWidth - this.wrapper.clientWidth;
wrapperRect = this.util.withOrientation(
this.wrapper.getBoundingClientRect(),
this.vertical
);
this.isResizing = false;
this.isDragging = false;
if (event.target.tagName.toLowerCase() === 'handle') {
this.isResizing = true;
resize = event.target.classList.contains('wavesurfer-handle-start')
? 'start'
: 'end';
} else {
this.isDragging = true;
drag = true;
resize = false;
}
};
const onUp = (event) => {
if (event.touches && event.touches.length > 1) {
return;
}
if (drag || resize) {
this.isDragging = false;
this.isResizing = false;
drag = false;
scrollDirection = null;
resize = false;
}
if (updated) {
updated = false;
this.util.preventClick();
this.fireEvent('update-end', event);
this.wavesurfer.fireEvent('region-update-end', this, event);
}
};
const onMove = (event) => {
const duration = this.wavesurfer.getDuration();
let orientedEvent = this.util.withOrientation(event, this.vertical);
let delta = null;
if (event.touches && event.touches.length > 1) {
return;
}
if (event.targetTouches && event.targetTouches[0].identifier != touchId) {
return;
}
if (!drag && !resize) {
return;
}
const oldTime = startTime;
let time = this.regionsUtil.getRegionSnapToGridValue(
this.wavesurfer.drawer.handleEvent(event) * duration
);
if (drag) {
// To maintain relative cursor start point while dragging
const maxEnd = this.wavesurfer.getDuration();
if (time > maxEnd - regionRightHalfTime) {
time = maxEnd - regionRightHalfTime;
}
if (time - regionLeftHalfTime < 0) {
time = regionLeftHalfTime;
}
}
if (resize) {
// To maintain relative cursor start point while resizing
// we have to handle for minLength
let minLength = this.minLength;
if (!minLength) {
minLength = 0;
}
if (resize === 'start') {
if (time > this.end - minLength) {
time = this.end - minLength;
}
if (time < 0) {
time = 0;
}
} else if (resize === 'end') {
if (time < this.start + minLength) {
// Calculate the end time based on the min length of the region.
time = this.start + minLength;
delta = time - (this.end + (time - startTime));
}
if (time > duration) {
time = duration;
}
}
}
if (!delta) {
delta = time - startTime;
}
startTime = time;
// Drag
if (this.drag && drag) {
updated = updated || !!delta;
this.onDrag(delta);
}
// Resize
if (this.resize && resize) {
updated = updated || !!delta;
this.onResize(delta, resize);
}
if (
this.scroll && container.clientWidth < this.wrapper.scrollWidth
) {
// Triggering edgescroll from within edgeScrollWidth
let x = orientedEvent.clientX;
// Check direction
if (x < wrapperRect.left + this.edgeScrollWidth) {
scrollDirection = -1;
} else if (x > wrapperRect.right - this.edgeScrollWidth) {
scrollDirection = 1;
} else {
scrollDirection = null;
}
if (scrollDirection) {
edgeScroll(event);
}
}
};
this.element.addEventListener('mousedown', onDown);
this.element.addEventListener('touchstart', onDown);
document.body.addEventListener('mousemove', onMove);
document.body.addEventListener('touchmove', onMove, {passive: false});
document.addEventListener('mouseup', onUp);
document.body.addEventListener('touchend', onUp);
this.on('remove', () => {
document.removeEventListener('mouseup', onUp);
document.body.removeEventListener('touchend', onUp);
document.body.removeEventListener('mousemove', onMove);
document.body.removeEventListener('touchmove', onMove);
});
this.wavesurfer.on('destroy', () => {
document.removeEventListener('mouseup', onUp);
document.body.removeEventListener('touchend', onUp);
});
}
onDrag(delta) {
const maxEnd = this.wavesurfer.getDuration();
if (this.end + delta > maxEnd) {
delta = maxEnd - this.end;
}
if (this.start + delta < 0) {
delta = this.start * -1;
}
const eventParams = {
direction: this._getDragDirection(delta),
action: 'drag'
};
this.update({
start: this.start + delta,
end: this.end + delta
}, eventParams);
}
/**
* Returns the direction of dragging region based on delta
* Negative delta means region is moving to the left
* Positive - to the right
* For zero delta the direction is not defined
* @param {number} delta Drag offset
* @returns {string|null} Direction 'left', 'right' or null
*/
_getDragDirection(delta) {
if (delta < 0) {
return 'left';
}
if (delta > 0) {
return 'right';
}
return null;
}
/**
* @example
* onResize(-5, 'start') // Moves the start point 5 seconds back
* onResize(0.5, 'end') // Moves the end point 0.5 seconds forward
*
* @param {number} delta How much to add or subtract, given in seconds
* @param {string} direction 'start 'or 'end'
*/
onResize(delta, direction) {
const duration = this.wavesurfer.getDuration();
const eventParams = {
action: 'resize',
direction: direction === 'start' ? 'left' : 'right'
};
if (direction === 'start') {
// Check if changing the start by the given delta would result in the region being smaller than minLength
if (delta > 0 && this.end - (this.start + delta) < this.minLength) {
delta = this.end - this.minLength - this.start;
}
// Check if changing the start by the given delta would result in the region being larger than maxLength
if (delta < 0 && this.end - (this.start + delta) > this.maxLength) {
delta = this.end - this.start - this.maxLength;
}
if (delta < 0 && (this.start + delta) < 0) {
delta = this.start * -1;
}
this.update({
start: Math.min(this.start + delta, this.end),
end: Math.max(this.start + delta, this.end)
}, eventParams);
} else {
// Check if changing the end by the given delta would result in the region being smaller than minLength
if (delta < 0 && this.end + delta - this.start < this.minLength) {
delta = this.start + this.minLength - this.end;
}
// Check if changing the end by the given delta would result in the region being larger than maxLength
if (delta > 0 && this.end + delta - this.start > this.maxLength) {
delta = this.maxLength - (this.end - this.start);
}
if (delta > 0 && (this.end + delta) > duration) {
delta = duration - this.end;
}
this.update({
start: Math.min(this.end + delta, this.start),
end: Math.max(this.end + delta, this.start)
}, eventParams);
}
}
onContentBlur(event){
const {text: oldText} = this.data || {};
const text = event.target.innerText;
const data = {...this.data, text };
const eventParams = {action: 'contentEdited', oldText, text};
this.update({data}, eventParams);
}
onContentClick(event){
event.stopPropagation();
}
onRemove(event){
event.stopPropagation();
this.remove();
}
updateHandlesResize(resize) {
let cursorStyle;
if (resize) {
cursorStyle = this.vertical ? 'row-resize' : 'col-resize';
} else {
cursorStyle = 'auto';
}
this.handleLeftEl && this.style(this.handleLeftEl, { cursor: cursorStyle });
this.handleRightEl && this.style(this.handleRightEl, { cursor: cursorStyle });
}
}