diff options
| author | Jules Laplace <julescarbon@gmail.com> | 2020-07-22 14:05:15 +0200 |
|---|---|---|
| committer | Jules Laplace <julescarbon@gmail.com> | 2020-07-22 14:05:15 +0200 |
| commit | ef78bc6a084f92b4794e987b5832240d85b6479e (patch) | |
| tree | b314b630800db6aa60f28ef0b115625e6ca176db /animism-align/frontend/app/views/align | |
| parent | 85d4cb9addf9ca887d3440b2786665d67d9917c4 (diff) | |
refactor app using babel module-resolver
Diffstat (limited to 'animism-align/frontend/app/views/align')
23 files changed, 1552 insertions, 0 deletions
diff --git a/animism-align/frontend/app/views/align/align.actions.js b/animism-align/frontend/app/views/align/align.actions.js new file mode 100644 index 0000000..5176d81 --- /dev/null +++ b/animism-align/frontend/app/views/align/align.actions.js @@ -0,0 +1,93 @@ +import * as types from 'app/types' +import { store, history, dispatch } from 'app/store' +import { api, post, pad, preloadImage } from 'app/utils' +import actions from 'app/actions' +// import { session } from 'app/session' +import throttle from 'lodash.throttle' +import debounce from 'lodash.debounce' + +import { ZOOM_STEPS } from 'app/constants' +import { getFirstPunctuationMarkIndex, cutFirstSentence } from 'app/views/align/align.util' + +export const setScrollPosition = start_ts => dispatch => ( + dispatch({ type: types.align.set_display_setting, key: 'start_ts', value: start_ts }) +) + +export const setZoom = zoom => dispatch => { + if (0 <= zoom && zoom < ZOOM_STEPS.length) { + dispatch({ type: types.align.set_display_setting, key: 'zoom', value: zoom }) + } +} +export const throttledSetZoom = throttle(zoom => dispatch => { + setZoom(zoom)(dispatch) +}, 250, { leading: true }) + +export const setCursor = cursor_ts => dispatch => ( + dispatch({ type: types.align.set_display_setting, key: 'cursor_ts', value: cursor_ts }) +) + +export const setSelectedAnnotation = annotation => dispatch => { + dispatch({ type: types.align.set_selected_annotation, data: annotation }) + debouncedUpdateAnnotation.flush() +} +export const clearSelectedAnnotation = () => dispatch => { + dispatch({ type: types.align.clear_selected_annotation }) + debouncedUpdateAnnotation.flush() +} +export const updateSelectedAnnotation = annotation => dispatch => { + dispatch({ type: types.align.set_selected_annotation, data: { ...annotation } }) + debouncedUpdateAnnotation(annotation) +} +export const debouncedUpdateAnnotation = debounce(annotation => { + console.log('updating annotation', annotation) + actions.annotation.update(annotation) +}, 2000, { leading: false, trailing: true }) + + +export const setSelectedParagraph = paragraph_id => dispatch => { + dispatch({ type: types.align.set_display_setting, key: 'selected_paragraph_id', value: paragraph_id }) +} +export const clearSelectedParagraph = paragraph_id => dispatch => { + dispatch({ type: types.align.set_display_setting, key: 'selected_paragraph_id', value: -1 }) +} + +export const showNewAnnotationForm = (start_ts, text) => dispatch => { + let croppedText; + if (store.getState().align.annotation.start_ts) { + croppedText = store.getState().align.annotation.text + } else { + croppedText = cutFirstSentence(text) + } + // console.log(croppedText) + dispatch({ + type: types.align.set_temporary_annotation, + data: { + id: 'new', + start_ts, + end_ts: 0.0, + text: croppedText, + type: 'sentence', + settings: {}, + } + }) +} +export const showEditAnnotationForm = (annotation) => dispatch => { + dispatch({ + type: types.align.set_temporary_annotation, + data: annotation, + }) +} + +export const updateAnnotationForm = (key, value) => dispatch => { + dispatch({ type: types.align.update_temporary_annotation, key, value }) +} +export const updateAnnotationSettings = (key, value) => dispatch => { + dispatch({ type: types.align.update_temporary_annotation_settings, key, value }) +} + +export const hideAnnotationForm = () => dispatch => { + dispatch({ + type: types.align.set_temporary_annotation, + data: {} + }) +} diff --git a/animism-align/frontend/app/views/align/align.container.js b/animism-align/frontend/app/views/align/align.container.js new file mode 100644 index 0000000..a659fdd --- /dev/null +++ b/animism-align/frontend/app/views/align/align.container.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import './align.css' + +import Timeline from 'app/views/align/containers/timeline.container.js' +import Script from 'app/views/align/containers/script.container.js' +import actions from 'app/actions' + +class Container extends Component { + componentDidMount() { + document.body.scrollTo(0, 0) + document.body.parentNode.scrollTo(0, 0) + } + render() { + return ( + <div className='body'> + <div className='row'> + <Timeline /> + </div> + <Script /> + </div> + ) + } +} + +export default Container diff --git a/animism-align/frontend/app/views/align/align.css b/animism-align/frontend/app/views/align/align.css new file mode 100644 index 0000000..bbf3bc2 --- /dev/null +++ b/animism-align/frontend/app/views/align/align.css @@ -0,0 +1,215 @@ +* { + +} +.body.loading > div { + padding: 1rem; +} +.body { + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + background: linear-gradient( + 0deg, + rgba(0, 0, 64, 0.5), + rgba(64, 64, 128, 0.5) + ); + padding: 0; +} + +/* 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; +} +.ticks .tickLabel { + 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; +} + +/* Audio player */ + +.playButton { + /*position: absolute;*/ + /*top: 0; left: 0;*/ + width: 3rem; height: 3rem; + padding: 1rem; + background: transparent; + cursor: pointer; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} +.playButton.playing { + background-image: url('/static/img/icons_pause_white.svg'); +} +.playButton.paused { + background-image: url('/static/img/icons_play_white.svg'); +} + +/* Script */ + +.script { + height: calc(100vh - 3.15rem); + z-index: 1; +} + +/* Annotations */ + +.annotations { + position: relative; + width: 450px; +} + +/* Annotation form */ + +.annotationForm { + width: 401px; + padding: 0.5rem; + position: absolute; + left: 0.25rem; + background: #448; + box-shadow: 0 0 2px #000, 0 0 4px #000; + z-index: 10; +} +.annotationForm textarea { + width: 100%; +} +.annotationForm .row { + justify-content: space-between; + align-items: center; +} +.annotationForm .row > div { + display: flex; + align-items: center; +} +.annotationForm .buttons { + margin-bottom: 0.5rem; +} +.annotationForm .ts { + color: #fff; +} +.annotationForm .select.media_id { + width: 100%; + margin-right: 0; +} + +/* Annotation index */ + +.annotationIndex { + width: 800px; +} +.annotationIndex .annotation { + position: absolute; + left: 5px; + max-width: 400px; + padding: 0.25rem 0.375rem; + box-shadow: 0px 0px 3px rgba(0,0,0,1.0); + border: 1px solid transparent; + border-radius: 2px; + font-size: 12px; + cursor: pointer; + user-select: none; + background-color: #768; +} +.annotation.selected { + border-color: #bbf; + box-shadow: 0px 0px 4px rgba(0,0,0,1.0), 0px 0px 2px rgba(0,0,0,1.0); + z-index: 1; + background-image: linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.4)); +} +.annotationIndex .annotation.media { + left: calc(405px + 0.5rem); +} +.annotation.sentence.even { + background-color: #83b; +} +.annotation.sentence.odd { + background-color: #537; +} +.annotation.header { + background-color: #838; +} +.annotation.paragraph_end { + background-color: #003; + border-top: 1px solid #888; + width: 100%; + padding: 1px; +} + +/* Condensed layout (first lines) */ + +.annotationIndex.condensed .annotation.sentence { + z-index: 0; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} +.annotationIndex.condensed .annotation.header { + z-index: 1; +} +.annotationIndex.condensed .annotation.paragraph_end { + border-top-color: #888; +} + +/* Collapsed layout (borders) */ + +.annotationIndex.collapsed .annotation.sentence { + height: 2px; overflow: hidden; padding: 0; z-index: 0; +} +.annotationIndex.collapsed .annotation.sentence.selected { + z-index: 1; +} +.annotationIndex.collapsed .annotation.header { + z-index: 2; +} +.annotationIndex.collapsed .annotation.paragraph_end { + border-top-color: #333; +} diff --git a/animism-align/frontend/app/views/align/align.reducer.js b/animism-align/frontend/app/views/align/align.reducer.js new file mode 100644 index 0000000..1f79180 --- /dev/null +++ b/animism-align/frontend/app/views/align/align.reducer.js @@ -0,0 +1,85 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +const initialState = { + timeline: { + cursor_ts: -1, + start_ts: 0, + zoom: 1, + duration: 0, + selected_annotation_id: -1, + selected_paragraph_id: -1, + }, + annotation: {}, + selectedAnnotation: {}, + options: { + } +} + +export default function alignReducer(state = initialState, action) { + // console.log(action.type, action) + switch (action.type) { + case types.peaks.loaded: + console.log('peaks duration:', action.data.length / 10) + return state + + case types.align.set_display_setting: + return { + ...state, + timeline: { + ...state.timeline, + [action.key]: action.value, + } + } + + case types.align.set_selected_annotation: + return { + ...state, + timeline: { + ...state.timeline, + selected_annotation_id: action.data.id, + }, + selectedAnnotation: action.data, + } + + case types.align.clear_selected_annotation: + return { + ...state, + timeline: { + ...state.timeline, + selected_annotation_id: -1, + }, + selectedAnnotation: {}, + } + + case types.align.set_temporary_annotation: + return { + ...state, + annotation: action.data, + } + + case types.align.update_temporary_annotation: + return { + ...state, + annotation: { + ...state.annotation, + [action.key]: action.value, + } + } + + case types.align.update_temporary_annotation_settings: + return { + ...state, + annotation: { + ...state.annotation, + settings: { + ...state.annotation.settings, + [action.key]: action.value, + } + } + } + + default: + return state + } +} diff --git a/animism-align/frontend/app/views/align/align.util.js b/animism-align/frontend/app/views/align/align.util.js new file mode 100644 index 0000000..e873bbf --- /dev/null +++ b/animism-align/frontend/app/views/align/align.util.js @@ -0,0 +1,65 @@ +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' +import actions from 'app/actions' + +import { HEADER_MARGIN, INNER_HEIGHT } from 'app/constants' + +export const positionToTime = (y, { start_ts, zoom, duration }) => { + y -= HEADER_MARGIN + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const widthTimeDuration = INNER_HEIGHT * secondsPerPixel + const timeMin = start_ts + const timeMax = Math.min(start_ts + widthTimeDuration, duration) + const timeWidth = timeMax - timeMin + return clamp(y * secondsPerPixel + start_ts, 0, timeMax) +} + +export const timeToPosition = (ts, { start_ts, zoom, duration }) => { + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const widthTimeDuration = INNER_HEIGHT * secondsPerPixel + const timeMin = start_ts + const timeMax = Math.min(start_ts + widthTimeDuration, duration) + const timeWidth = timeMax - timeMin + const timeHalfHeight = INNER_HEIGHT * secondsPerPixel / 2 + if (ts < timeMin - timeHalfHeight) { + return -9999 + } + if (ts > timeMax) { + return -9999 + } + return (ts - timeMin) / timeWidth * INNER_HEIGHT +} + +export const getFirstPunctuationMarkIndex = text => { + const indexes = [ + text.indexOf('. '), + text.indexOf('? '), + text.indexOf('! '), + text.indexOf('." '), + text.indexOf('?" '), + text.indexOf('!" '), + text.indexOf('.” '), + text.indexOf('?” '), + text.indexOf('!” '), + ] + + return indexes.reduce((a, b) => { + if (b < 0) return a + return Math.min(a, b) + }, Infinity) + 1 +} + +export const cutFirstSentence = text => { + const textToCrop = text.trim().replace("\n", " ").split("\n")[0] + let cropIndex = getFirstPunctuationMarkIndex(textToCrop) + if (!cropIndex) cropIndex = textToCrop.length + const croppedText = textToCrop.substr(0, cropIndex).trim() + const updatedText = text.trim().replace(croppedText, '').trim() + actions.site.updateText(updatedText) + return croppedText +} + +export const thumbnailURL = data => { + if (data.type === 'video') return data.settings.video.thumbnail_url + if (data.type === 'image') return data.settings.thumbnail.url +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotation.form.js b/animism-align/frontend/app/views/align/components/annotations/annotation.form.js new file mode 100644 index 0000000..7d66272 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotation.form.js @@ -0,0 +1,182 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp, timestamp, capitalize } from 'app/utils' +import { timeToPosition } from 'app/views/align/align.util' +import { Select } from 'app/common' + +import { + AnnotationFormVideo, + AnnotationFormImage, +} from './annotationForms' + +const ANNOTATION_TYPES = [ + 'sentence', 'header', 'paragraph_end', 'video', 'image', 'image_carousel', +].map(name => ({ name, label: capitalize(name.replace('_', ' ')) })) + +class AnnotationForm extends Component { + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSettingsSelect = this.handleSettingsSelect.bind(this) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleDestroy = this.handleDestroy.bind(this) + this.textareaRef = React.createRef() + } + componentDidMount() { + if (this.textareaRef && this.textareaRef.current) { + this.textareaRef.current.focus() + } + } + handleKeyDown(e) { + if (e.keyCode === 27) { // escape + actions.align.hideAnnotationForm() + return + } + // console.log(e.keyCode) + if (!e.metaKey && !e.ctrlKey) return + let { start_ts } = this.props.annotation + switch (e.keyCode) { + case 38: // up + e.preventDefault() + start_ts -= 0.1 + actions.align.updateAnnotationForm('start_ts', Math.max(0, start_ts)) + actions.audio.seek(start_ts) + actions.align.setCursor(start_ts) + break + case 40: // down + e.preventDefault() + start_ts += 0.1 + actions.align.updateAnnotationForm('start_ts', Math.max(0, start_ts)) + actions.audio.seek(start_ts) + actions.align.setCursor(start_ts) + break + case 83: // ctrl-S + e.preventDefault() + this.handleSubmit() + default: + break + } + } + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + handleSelect(name, value) { + actions.align.updateAnnotationForm(name, value) + } + handleSettingsSelect(name, value) { + if (name.indexOf('_id') !== -1) value = parseInt(value) || 0 + actions.align.updateAnnotationSettings(name, value) + } + handleSubmit() { + const { annotation } = this.props + if (annotation.type === 'paragraph_end') { + annotation.text = '' + } + if (annotation.id === 'new') { + delete annotation.id + actions.annotation.create(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } else { + actions.annotation.update(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } + } + handleDestroy() { + const { annotation } = this.props + if (annotation.id === 'new') { + actions.align.hideAnnotationForm() + } else { + actions.annotation.destroy(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } + } + render() { + const { timeline, annotation, media } = this.props + if (!annotation.start_ts) return <div></div> + return ( + <div + className='annotationForm' + style={{ + top: timeToPosition(annotation.start_ts, timeline), + }} + > + {this.renderButtons()} + {annotation.type === 'sentence' && this.renderTextarea()} + {annotation.type === 'header' && this.renderTextarea()} + {annotation.type === 'video' && + <AnnotationFormVideo annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + {annotation.type === 'image' && + <AnnotationFormImage annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + {annotation.type === 'image_carousel' && + <AnnotationFormImageCarousel annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + </div> + ) + } + renderButtons() { + const { annotation } = this.props + return ( + <div className='row buttons'> + <div> + <Select + name='type' + selected={annotation.type} + options={ANNOTATION_TYPES} + defaultOption='text' + onChange={this.handleSelect} + /> + <div className='ts'>{timestamp(annotation.start_ts, 1, true)}</div> + </div> + <div> + {annotation.id !== 'new' && <button onClick={this.handleDestroy}>Delete</button>} + <button onClick={this.handleSubmit}>Save</button> + </div> + </div> + ) + } + renderTextarea() { + const { annotation } = this.props + return ( + <div> + <textarea + name='text' + value={annotation.text} + onKeyDown={this.handleKeyDown} + onChange={this.handleChange} + ref={this.textareaRef} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + annotation: state.align.annotation, + timeline: state.align.timeline, + media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationForm) diff --git a/animism-align/frontend/app/views/align/components/annotations/annotation.index.js b/animism-align/frontend/app/views/align/components/annotations/annotation.index.js new file mode 100644 index 0000000..aa31268 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotation.index.js @@ -0,0 +1,126 @@ +import React, { Component } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { ZOOM_STEPS, INNER_HEIGHT } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime, timeToPosition } from 'app/views/align/align.util' + +import { AnnotationElementLookup } from './annotationTypes' + +class AnnotationIndex extends Component { + state = { + items: [], + } + constructor(props){ + super(props) + this.handleClick = this.handleClick.bind(this) + } + componentDidUpdate(prevProps) { + if (this.props.index.loading) return + if (prevProps.timeline !== this.props.timeline || prevProps.index !== this.props.index) { + this.update() + } + } + update() { + let { timeline, index } = this.props + let { start_ts, zoom, duration } = this.props.timeline + const { order, lookup } = index + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + + let timeMin = start_ts - 50.0 + let timeMax = Math.min(start_ts + widthTimeDuration, duration) + + const items = order.filter(id => { + const { start_ts: ts } = lookup[id] + return (timeMin < ts && ts < timeMax) + }).map(id => lookup[id]).reverse() + this.setState({ items }) + } + handleClick(e, annotation) { + e.stopPropagation() + if (e.shiftKey) { + e.preventDefault() + this.handleParagraphSelection(annotation, e.metaKey) + } + actions.audio.seek(annotation.start_ts) + actions.align.setSelectedAnnotation(annotation) + } + handleParagraphSelection(annotation, shouldClear) { + const { selected_paragraph_id } = this.props.timeline + if (!selected_paragraph_id || selected_paragraph_id === -1 || shouldClear) { + if (annotation.paragraph_id && !shouldClear) { + actions.align.setSelectedParagraph(annotation.paragraph_id) + } else { + actions.paragraph.create({ + type: 'paragraph', + start_ts: annotation.start_ts, + }).then(data => { + actions.align.setSelectedParagraph(data.res.id) + annotation.paragraph_id = data.res.id + actions.annotation.update(annotation) + }) + } + } else if (selected_paragraph_id !== annotation.paragraph_id) { + annotation.paragraph_id = selected_paragraph_id + actions.annotation.update(annotation) + } + } + handleDoubleClick(e, annotation) { + e.stopPropagation() + actions.align.showEditAnnotationForm(annotation) + } + render() { + const { timeline, media, annotationInForm, selectedAnnotation } = this.props + const { start_ts, zoom, selected_annotation_id } = timeline + const { items } = this.state + const className = (zoom < 2) + ? 'annotationIndex' + : (zoom < 3) + ? 'annotationIndex condensed' + : 'annotationIndex collapsed' + return ( + <div className={className}> + {items.map(annotation => { + if (annotationInForm && annotation.id === annotationInForm.id) { + return null + } + if (annotation.id === selected_annotation_id) { + annotation = selectedAnnotation + } + const { id, type, start_ts } = annotation + const AnnotationElement = AnnotationElementLookup[type] + const y = timeToPosition(start_ts, timeline) + return ( + <AnnotationElement + key={id} + y={y} + selected={annotation.id === selected_annotation_id} + annotation={annotation} + media={media} + onClick={this.handleClick} + onDoubleClick={this.handleDoubleClick} + /> + ) + })} + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotationInForm: state.align.annotation, + selectedAnnotation: state.align.selectedAnnotation, + index: state.annotation.index, + media: state.media.index.lookup, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationIndex) diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js new file mode 100644 index 0000000..8457b68 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' + +import { Select } from 'app/common' + +export const AnnotationFormImage = ({ annotation, media, handleSettingsSelect }) => { + if (!media.lookup) return <div /> + const { lookup, order } = media + const image_list_items = order.filter(id => lookup[id].type === 'image').map(id => { + const image = lookup[id] + return { + name: image.id, + label: image.author + ' - ' + image.title + } + }) + return ( + <div> + <Select + name='media_id' + className="media_id" + selected={annotation.settings.media_id} + options={image_list_items} + defaultOption='Choose an image' + onChange={handleSettingsSelect} + /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js new file mode 100644 index 0000000..1fb552b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' + +import { Select } from 'app/common' + +export const AnnotationFormVideo = ({ annotation, media, handleSettingsSelect }) => { + if (!media.lookup) return <div /> + const { lookup, order } = media + const video_list_items = order.filter(id => lookup[id].type === 'video').map(id => { + const video = lookup[id] + return { + name: video.id, + label: video.author + ' - ' + video.title + } + }) + return ( + <div> + <Select + name='media_id' + className="media_id" + selected={annotation.settings.media_id} + options={video_list_items} + defaultOption='Choose a video' + onChange={handleSettingsSelect} + /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js new file mode 100644 index 0000000..1411efc --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js @@ -0,0 +1,12 @@ +import { + AnnotationFormVideo, +} from './annotationForm.video' + +import { + AnnotationFormImage, +} from './annotationForm.image' + +export { + AnnotationFormImage, + AnnotationFormVideo, +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js new file mode 100644 index 0000000..ec4d25e --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' + +import { thumbnailURL } from 'app/views/align/align.util' + +import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.util' + +export const AnnotationImage = ({ y, annotation, media, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation media image selected' : 'annotation media image' + if (checkAnnotationMediaNotReady(annotation, media)) { + return <AnnotationMediaLoading y={y} className={className} onClick={onClick} onDoubleClick={onDoubleClick} /> + } + const data = media[annotation.settings.media_id] + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + <div className='img'> + <img src={thumbnailURL(data)} alt={data.title} /> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js new file mode 100644 index 0000000..be4674f --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' + +export const AnnotationSentence = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text, paragraph_id } = annotation + let className = !paragraph_id + ? 'annotation sentence' + : (paragraph_id % 2) + ? 'annotation sentence odd' + : 'annotation sentence even' + if (selected) className += ' selected' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + dangerouslySetInnerHTML={{ __html: text }} + /> + ) +} + +export const AnnotationHeader = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation header selected' : 'annotation header' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + {text} + </div> + ) +} + +export const AnnotationParagraphEnd = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation paragraph_end selected' : 'annotation paragraph_end' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js new file mode 100644 index 0000000..17abebd --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' + +export const checkAnnotationMediaNotReady = (annotation, media) => { + return (!media) || (!(annotation.settings.media_id in media)) +} + +export const AnnotationMediaLoading = ({ y, className, onClick, onDoubleClick }) => { + if (!media) { + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + >LOADING...</div> + ) + } + if (!(annotation.settings.media_id in media)) { + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + >MEDIA NOT FOUND</div> + ) + } +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js new file mode 100644 index 0000000..f51ac71 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' + +import { thumbnailURL } from 'app/views/align/align.util' +import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.util' + +export const AnnotationVideo = ({ y, annotation, media, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation media video selected' : 'annotation media video' + if (checkAnnotationMediaNotReady(annotation, media)) { + return <AnnotationMediaLoading y={y} className={className} onClick={onClick} onDoubleClick={onDoubleClick} /> + } + const data = media[annotation.settings.media_id] + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + <div className='img'> + <img src={thumbnailURL(data)} alt={data.title} /> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} + diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js new file mode 100644 index 0000000..560063b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js @@ -0,0 +1,22 @@ +import React from 'react' + +import { + AnnotationSentence, AnnotationHeader, + AnnotationParagraphEnd, +} from './annotationTypes.text' + +import { + AnnotationVideo, +} from './annotationTypes.video' + +import { + AnnotationImage, +} from './annotationTypes.image' + +export const AnnotationElementLookup = { + sentence: React.memo(AnnotationSentence), + header: React.memo(AnnotationHeader), + paragraph_end: React.memo(AnnotationParagraphEnd), + video: React.memo(AnnotationVideo), + image: React.memo(AnnotationImage), +} diff --git a/animism-align/frontend/app/views/align/components/player/playButton.component.js b/animism-align/frontend/app/views/align/components/player/playButton.component.js new file mode 100644 index 0000000..c6a8487 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/player/playButton.component.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' + +const PlayButton = ({ audio }) => { + return ( + <div + className={audio.playing ? 'playButton playing' : 'playButton paused'} + onClick={() => { + audio.playing ? actions.audio.pause() : actions.audio.play() + }} + /> + ) +} + +const mapStateToProps = state => ({ + audio: state.audio, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(PlayButton) diff --git a/animism-align/frontend/app/views/align/components/timeline/cursor.component.js b/animism-align/frontend/app/views/align/components/timeline/cursor.component.js new file mode 100644 index 0000000..4a94100 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/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/align/components/timeline/playCursor.component.js b/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js new file mode 100644 index 0000000..854c43b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +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, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(PlayCursor) diff --git a/animism-align/frontend/app/views/align/components/timeline/ticks.component.js b/animism-align/frontend/app/views/align/components/timeline/ticks.component.js new file mode 100644 index 0000000..4530863 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/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/align/components/timeline/waveform.component.js b/animism-align/frontend/app/views/align/components/timeline/waveform.component.js new file mode 100644 index 0000000..023b877 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/waveform.component.js @@ -0,0 +1,101 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +// import { bindActionCreators } from 'redux' +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} onClick={this.props.onClick} /> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + peaks: state.site.peaks, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Waveform) diff --git a/animism-align/frontend/app/views/align/containers/annotations.container.js b/animism-align/frontend/app/views/align/containers/annotations.container.js new file mode 100644 index 0000000..e199bd6 --- /dev/null +++ b/animism-align/frontend/app/views/align/containers/annotations.container.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime } from 'app/views/align/align.util' + +import AnnotationForm from 'app/views/align/components/annotations/annotation.form' +import AnnotationIndex from 'app/views/align/components/annotations/annotation.index' + +class Annotations extends Component { + constructor(props){ + super(props) + } + render() { + return ( + <div className='annotations'> + <AnnotationIndex /> + {this.props.annotation.start_ts && + <AnnotationForm /> + } + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotation: state.align.annotation, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Annotations) diff --git a/animism-align/frontend/app/views/align/containers/script.container.js b/animism-align/frontend/app/views/align/containers/script.container.js new file mode 100644 index 0000000..cc340c6 --- /dev/null +++ b/animism-align/frontend/app/views/align/containers/script.container.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +class Timeline extends Component { + constructor(props){ + super(props) + } + render() { + if (this.props.text.loading) return <div /> + return ( + <textarea + className='script' + onChange={e => actions.site.updateText(e.target.value)} + value={this.props.text} + /> + ) + } +} + + +const mapStateToProps = state => ({ + text: state.site.text, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Timeline) diff --git a/animism-align/frontend/app/views/align/containers/timeline.container.js b/animism-align/frontend/app/views/align/containers/timeline.container.js new file mode 100644 index 0000000..c208e08 --- /dev/null +++ b/animism-align/frontend/app/views/align/containers/timeline.container.js @@ -0,0 +1,171 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import Annotations from 'app/views/align/containers/annotations.container' +import Waveform from 'app/views/align/components/timeline/waveform.component' +import Ticks from 'app/views/align/components/timeline/ticks.component' +import Cursor from 'app/views/align/components/timeline/cursor.component' +import PlayButton from 'app/views/align/components/player/playButton.component' +import PlayCursor from 'app/views/align/components/timeline/playCursor.component' + +import { WAVEFORM_SIZE, ZOOM_STEPS, INNER_HEIGHT } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime } from 'app/views/align/align.util' + +class Timeline extends Component { + 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.handleTimelineClick = this.handleTimelineClick.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 + } + 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) + } + } + handleMouseMove(e) { + const cursor_ts = positionToTime(e.pageY, this.props.timeline) + actions.align.setCursor(cursor_ts) + } + handleContainerClick(e) { + actions.align.clearSelectedAnnotation() + actions.align.clearSelectedParagraph() + } + handleTimelineClick(e) { + const play_ts = positionToTime(e.pageY, this.props.timeline) + 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 onClick={this.handleTimelineClick} /> + <Ticks timeline={this.props.timeline} /> + <Cursor timeline={this.props.timeline} annotation={this.props.annotation} /> + </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.site.text, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Timeline) |
