diff options
Diffstat (limited to 'animism-align/frontend/app/views/editor/timeline')
7 files changed, 544 insertions, 0 deletions
diff --git a/animism-align/frontend/app/views/editor/timeline/components/cursor.component.js b/animism-align/frontend/app/views/editor/timeline/components/cursor.component.js new file mode 100644 index 0000000..4a94100 --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/components/cursor.component.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const Cursor = ({ timeline, annotation }) => { + const { start_ts, zoom, cursor_ts, duration } = timeline + const ts = annotation.start_ts || cursor_ts + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const y = (ts - start_ts) / secondsPerPixel + return ( + <div + className='cursor' + style={{ + top: y, + }} + > + <div className='line' /> + <div className='tickLabel'> + {timestamp(ts, 1)} + </div> + </div> + ) +} + +export default Cursor
\ No newline at end of file diff --git a/animism-align/frontend/app/views/editor/timeline/components/cursorRegion.component.js b/animism-align/frontend/app/views/editor/timeline/components/cursorRegion.component.js new file mode 100644 index 0000000..a0c9bd7 --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/components/cursorRegion.component.js @@ -0,0 +1,28 @@ +import React from 'react' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const CursorRegion = ({ timeline }) => { + const { start_ts, zoom, cursor_region } = timeline + if (!cursor_region) return null + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const duration = cursor_region.b_ts - cursor_region.a_ts + const y = (cursor_region.a_ts - start_ts) / secondsPerPixel + const height = (duration) / secondsPerPixel + return ( + <div + className='cursor_region' + style={{ + top: y, + height, + }} + > + <div className='tickLabel'> + {timestamp(duration, 1, true)} + </div> + </div> + ) +} + +export default CursorRegion
\ No newline at end of file diff --git a/animism-align/frontend/app/views/editor/timeline/components/playCursor.component.js b/animism-align/frontend/app/views/editor/timeline/components/playCursor.component.js new file mode 100644 index 0000000..80da31f --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/components/playCursor.component.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const PlayCursor = ({ timeline, audio }) => { + const { start_ts, zoom, duration } = timeline + const { play_ts } = audio + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const y = (play_ts - start_ts) / secondsPerPixel + // console.log(play_ts, y) + return ( + <div + className='cursor playCursor' + style={{ + top: y, + }} + > + <div className='line' /> + </div> + ) +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + audio: state.audio, +}) + +export default connect(mapStateToProps)(PlayCursor) diff --git a/animism-align/frontend/app/views/editor/timeline/components/ticks.component.js b/animism-align/frontend/app/views/editor/timeline/components/ticks.component.js new file mode 100644 index 0000000..4530863 --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/components/ticks.component.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react' + +import { ZOOM_STEPS, ZOOM_LABEL_STEPS, ZOOM_TICK_STEPS, INNER_HEIGHT } from 'app/constants' +import { timestamp } from 'app/utils' + +export default class Ticks extends Component { + render() { + let { start_ts, zoom, duration } = this.props.timeline + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + + let timeMin = start_ts + let timeMax = Math.min(start_ts + widthTimeDuration, duration) + let timeWidth = timeMax - timeMin + + let pixelMin = timeMin / secondsPerPixel + + let secondsPerLabel = ZOOM_LABEL_STEPS[zoom] // secs + let pixelsPerLabel = secondsPerLabel / secondsPerPixel + let secondsPerTick = ZOOM_TICK_STEPS[zoom] + let pixelsPerTick = secondsPerTick / secondsPerPixel + + let startOffset = pixelsPerLabel - (pixelMin % pixelsPerLabel) + let startTiming = (pixelMin + startOffset) * secondsPerPixel + + let labelCount = Math.ceil(INNER_HEIGHT / pixelsPerLabel) + 1 + let offset, timing, tickLabels = [], ticks = [] + for (var i = -1; i < labelCount; i++) { + offset = i * pixelsPerLabel + startOffset + if (offset > INNER_HEIGHT) continue + timing = i * secondsPerLabel + startTiming + if (timing > duration) { + break + } + tickLabels.push( + <div className='tickLabel' key={"tickLabel_" + i} + style={{ + top: Math.floor(offset) + }}> + {timestamp(timing)} + </div> + ) + } + + let durationOffset = duration / secondsPerPixel - pixelMin + if (timing > duration) { + tickLabels.push( + <div className='tickLabel tickLabelTotal' key={"tickLabel_total"} + style={{ + top: durationOffset + }}> + {timestamp(duration, 1)} + </div> + ) + ticks.push( + <div className='tick' key={"tick_total"} + style={{ + top: Math.floor(durationOffset), + }} + /> + ) + } + let tickCount = Math.ceil(INNER_HEIGHT / pixelsPerTick) + 6 + for (var i = 0; i < tickCount; i += 1) { + offset = i * pixelsPerTick + startOffset - pixelsPerLabel + if (offset > durationOffset) { + break + } + ticks.push( + <div className='tick' key={"tick_" + i} + style={{ + top: Math.floor(offset), + }} + /> + ) + } + // console.log(ticks.length) + + return ( + <div className='ticks'> + {ticks} + {tickLabels} + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/editor/timeline/components/waveform.component.js b/animism-align/frontend/app/views/editor/timeline/components/waveform.component.js new file mode 100644 index 0000000..0161129 --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/components/waveform.component.js @@ -0,0 +1,100 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { + WAVEFORM_SIZE, INNER_HEIGHT, + ZOOM_STEPS, ZOOM_LABEL_STEPS, ZOOM_TICK_STEPS, +} from 'app/constants' + +class Waveform extends Component { + constructor(props){ + super(props) + this.canvasRef = React.createRef() + } + componentDidMount() { + this.resize() + this.draw() + } + componentDidUpdate() { + this.draw() + } + resize() { + const canvas = this.canvasRef.current + canvas.width = WAVEFORM_SIZE + canvas.height = INNER_HEIGHT + } + draw() { + const canvas = this.canvasRef.current + const ctx = canvas.getContext('2d') + const h = INNER_HEIGHT + this.clearCanvas(ctx, h) + this.drawCurve(ctx, h) + } + clearCanvas(ctx, h) { + const w = WAVEFORM_SIZE + ctx.clearRect(0, 0, w, h) + ctx.fillStyle = 'rgba(0,0,0,0.5)' + ctx.fillRect(0, 0, w, h) + ctx.fillStyle = 'rgba(64,128,192,0.5)' + } + drawCurve(ctx, h) { + let { peaks, timeline } = this.props + let { start_ts, zoom, duration } = timeline + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let stepsPerPixel = ZOOM_STEPS[zoom] // 0.1 sec / step + + let widthTimeDuration = h * secondsPerPixel // secs per pixel + + let timeMin = Math.round(start_ts / secondsPerPixel) * secondsPerPixel + let timeMax = Math.min(timeMin + widthTimeDuration, duration) + let timeWidth = timeMax - timeMin + + let stepMin = Math.floor(timeMin * 10) + let pixelWidth = Math.ceil(timeWidth / secondsPerPixel) + + let i = 0 + let step = stepMin + let waveformPeak = WAVEFORM_SIZE / 2 + let origin = (1 - peaks[step]) * waveformPeak + let y + let peak + // console.log(stepMin, pixelWidth * stepsPerPixel + stepMin) + ctx.beginPath() + ctx.moveTo(origin, 0) + for (i = 0; i < pixelWidth; i++) { + step = i * stepsPerPixel + stepMin + peak = peaks[step] + y = (1 - peak) * waveformPeak + ctx.lineTo(y, i) + } + for (i = pixelWidth - 1; i > 0; i--) { + step = i * stepsPerPixel + stepMin + peak = peaks[step] + y = (1 + peak) * waveformPeak + ctx.lineTo(y, i) + } + ctx.lineTo(origin, 0) + ctx.fillStyle = 'rgba(255,255,255,0.8)' + ctx.fill() + } + render() { + return ( + <canvas + ref={this.canvasRef} + onMouseDown={this.props.onMouseDown} + onMouseUp={this.props.onMouseUp} + /> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + peaks: state.align.peaks, +}) + +export default connect(mapStateToProps)(Waveform) diff --git a/animism-align/frontend/app/views/editor/timeline/timeline.container.js b/animism-align/frontend/app/views/editor/timeline/timeline.container.js new file mode 100644 index 0000000..3b7a2dd --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/timeline.container.js @@ -0,0 +1,199 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import './timeline.css' + +import actions from 'app/actions' + +import Annotations from 'app/views/editor/annotation/annotations.container' + +import Waveform from './components/waveform.component' +import Ticks from './components/ticks.component' +import Cursor from './components/cursor.component' +import PlayCursor from './components/playCursor.component' +import CursorRegion from './components/cursorRegion.component' + +import { WAVEFORM_SIZE, ZOOM_STEPS, INNER_HEIGHT } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime } from 'app/utils/align.utils' + +class Timeline extends Component { + state = { + dragging: false, + a_ts: -1, + } + constructor(props){ + super(props) + this.handleKeydown = this.handleKeydown.bind(this) + this.handleMouseMove = this.handleMouseMove.bind(this) + this.handleWheel = this.handleWheel.bind(this) + this.handleContainerClick = this.handleContainerClick.bind(this) + this.handleTimelineMouseDown = this.handleTimelineMouseDown.bind(this) + this.handleTimelineMouseUp = this.handleTimelineMouseUp.bind(this) + } + componentDidMount() { + this.bind() + } + componentWillUnmount() { + this.unbind() + } + shouldComponentUpdate(nextProps) { + return ( + nextProps.timeline !== this.props.timeline || + nextProps.annotation !== this.props.annotation + ) + } + bind() { + document.addEventListener('keydown', this.handleKeydown) + } + unbind() { + document.removeEventListener('keydown', this.handleKeydown) + } + handleKeydown(e) { + if (document.activeElement !== document.body) { + return + } + console.log(e.keyCode) + if (e.metaKey && this.props.selectedAnnotation.id) { + const { selectedAnnotation } = this.props + switch (e.keyCode) { + case 38: // up + e.preventDefault() + selectedAnnotation.start_ts = Math.max(selectedAnnotation.start_ts - (e.shiftKey ? 1 : 0.1), 0) + actions.align.updateSelectedAnnotation(selectedAnnotation) + actions.audio.seek(selectedAnnotation.start_ts) + actions.align.setCursor(selectedAnnotation.start_ts) + break + case 40: // down + e.preventDefault() + selectedAnnotation.start_ts += e.shiftKey ? 1 : 0.1 + actions.align.updateSelectedAnnotation(selectedAnnotation) + actions.audio.seek(selectedAnnotation.start_ts) + actions.align.setCursor(selectedAnnotation.start_ts) + break + case 68: // D + e.preventDefault() + actions.align.cloneSelectedAnnotation(selectedAnnotation) + } + return + } + if (e.shiftKey) { + switch (e.keyCode) { + case 187: // plus + actions.align.setZoom(this.props.timeline.zoom - 1) + break + case 189: // minus + actions.align.setZoom(this.props.timeline.zoom + 1) + break + } + } else { + // console.log(e.keyCode) + switch (e.keyCode) { + case 27: // escape + actions.align.hideAnnotationForm() + break + case 65: // A - add + e.preventDefault() + actions.align.showNewAnnotationForm(this.props.audio.play_ts, this.props.text) + break + case 32: // spacebar + actions.audio.toggle() + break + case 38: // up + actions.audio.jump(- ZOOM_STEPS[this.props.timeline.zoom] * 0.1) + break + case 40: // down + actions.audio.jump(ZOOM_STEPS[this.props.timeline.zoom] * 0.1) + break + } + } + } + handleWheel(e) { + let { start_ts, zoom, duration } = this.props.timeline + let { deltaY } = e + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + start_ts += Math.round((deltaY) * ZOOM_STEPS[zoom]) + start_ts = clamp(start_ts, 0, Math.max(0, duration - widthTimeDuration / 2)) + if (e.shiftKey) { + if (Math.abs(deltaY) < 2) return + if (e.deltaY > 0) { + actions.align.throttledSetZoom(this.props.timeline.zoom + 1) + } else { + actions.align.throttledSetZoom(this.props.timeline.zoom - 1) + } + } else if (e.altKey) { + actions.audio.jump(e.deltaY * ZOOM_STEPS[zoom]) + } else { + actions.align.setScrollPosition(start_ts) + } + } + handleContainerClick(e) { + actions.align.clearSelectedAnnotation() + actions.align.clearSelectedParagraph() + } + handleTimelineMouseDown(e) { + const cursor_ts = positionToTime(e.pageY, this.props.timeline) + actions.align.clearCursorRegion() + actions.align.setCursor(cursor_ts) + this.setState({ + dragging: true, + a_ts: cursor_ts, + }) + } + handleMouseMove(e) { + const cursor_ts = positionToTime(e.pageY, this.props.timeline) + if (this.state.dragging) { + actions.align.setCursorRegion( + Math.min(this.state.a_ts, cursor_ts), + Math.max(this.state.a_ts, cursor_ts), + ) + } else { + actions.align.setCursor(cursor_ts) + } + } + handleTimelineMouseUp(e) { + this.setState({ dragging: false }) + const play_ts = positionToTime(e.pageY, this.props.timeline) + if (e.metaKey) { + actions.align.spliceTime(play_ts) + } else if (e.pageX < WAVEFORM_SIZE * 0.67) { + actions.audio.seek(play_ts) + } else { + actions.align.showNewAnnotationForm(play_ts, this.props.text) + } + } + render() { + return ( + <div + className='timeline' + onClick={this.handleContainerClick} + onWheel={this.handleWheel} + onMouseMove={this.handleMouseMove} + > + <div className='timelineColumn'> + <Waveform + onMouseDown={this.handleTimelineMouseDown} + onMouseUp={this.handleTimelineMouseUp} + /> + <Ticks timeline={this.props.timeline} /> + <Cursor timeline={this.props.timeline} annotation={this.props.annotation} /> + <CursorRegion timeline={this.props.timeline} /> + </div> + <Annotations timeline={this.props.timeline} /> + <PlayCursor /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotation: state.align.annotation, + selectedAnnotation: state.align.selectedAnnotation, + audio: state.audio, + text: state.align.text, +}) + +export default connect(mapStateToProps)(Timeline) diff --git a/animism-align/frontend/app/views/editor/timeline/timeline.css b/animism-align/frontend/app/views/editor/timeline/timeline.css new file mode 100644 index 0000000..2a5769f --- /dev/null +++ b/animism-align/frontend/app/views/editor/timeline/timeline.css @@ -0,0 +1,72 @@ +/* Timeline */ + +canvas { + display: block; +} +.timeline { + display: flex; + flex-direction: row; + position: relative; + width: 300px; + cursor: crosshair; +} +.timelineColumn { + position: relative; +} +.ticks .tick { + position: absolute; + right: 0; + width: 4px; + height: 1px; + background: #ddd; + pointer-events: none; +} +.ticks .tickLabel { + pointer-events: none; + position: absolute; + right: 6px; + font-size: 12px; + width: 40px; + margin-top: -7px; + text-align: right; + text-shadow: 0 0 2px #00f; +} +.timeline .cursor { + width: 100%; + position: absolute; + left: 0; + pointer-events: none; +} +.timeline .cursor .line { + width: 100%; + height: 1px; + background: #00f; +} +.timeline .cursor.playCursor .line { + background: #ddd; +} +.timeline .cursor .tickLabel { + position: absolute; + pointer-events: none; + right: 6px; + font-size: 12px; + width: 40px; + margin-top: -7px; + text-align: right; + text-shadow: 0 0 2px #000, 0 0 2px #000, 0 0 2px #000; + user-select: none; +} +.timeline .cursor_region { + position: absolute; + left: 0; + width: 100%; + border-top: 1px solid #33f; + border-bottom: 1px solid #33f; + background: rgba(32,64,255,0.2); +} +.timeline .cursor_region .tickLabel { + position: absolute; + top: 2px; + left: 6px; + user-select: none; +} |
