summaryrefslogtreecommitdiff
path: root/animism-align/frontend/app/views/align/components/annotations
diff options
context:
space:
mode:
Diffstat (limited to 'animism-align/frontend/app/views/align/components/annotations')
-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
10 files changed, 539 insertions, 0 deletions
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),
+}