summaryrefslogtreecommitdiff
path: root/animism-align/frontend/app/views/align
diff options
context:
space:
mode:
authorJules Laplace <julescarbon@gmail.com>2020-07-22 14:05:15 +0200
committerJules Laplace <julescarbon@gmail.com>2020-07-22 14:05:15 +0200
commitef78bc6a084f92b4794e987b5832240d85b6479e (patch)
treeb314b630800db6aa60f28ef0b115625e6ca176db /animism-align/frontend/app/views/align
parent85d4cb9addf9ca887d3440b2786665d67d9917c4 (diff)
refactor app using babel module-resolver
Diffstat (limited to 'animism-align/frontend/app/views/align')
-rw-r--r--animism-align/frontend/app/views/align/align.actions.js93
-rw-r--r--animism-align/frontend/app/views/align/align.container.js29
-rw-r--r--animism-align/frontend/app/views/align/align.css215
-rw-r--r--animism-align/frontend/app/views/align/align.reducer.js85
-rw-r--r--animism-align/frontend/app/views/align/align.util.js65
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotation.form.js182
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotation.index.js126
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js27
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js27
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js12
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js33
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js49
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js28
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js33
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js22
-rw-r--r--animism-align/frontend/app/views/align/components/player/playButton.component.js31
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/cursor.component.js26
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/playCursor.component.js36
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/ticks.component.js88
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/waveform.component.js101
-rw-r--r--animism-align/frontend/app/views/align/containers/annotations.container.js40
-rw-r--r--animism-align/frontend/app/views/align/containers/script.container.js33
-rw-r--r--animism-align/frontend/app/views/align/containers/timeline.container.js171
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)