diff --git a/.vscode/settings.json b/.vscode/settings.json index 24b00c4b5..ad7b4a8e2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -88,6 +88,7 @@ // Spell Checker ////////////////////////////////////// "cSpell.words": [ + "commontator", "turbolinks" ] } \ No newline at end of file diff --git a/app/abilities/annotation_ability.rb b/app/abilities/annotation_ability.rb new file mode 100644 index 000000000..1d95ba944 --- /dev/null +++ b/app/abilities/annotation_ability.rb @@ -0,0 +1,11 @@ +class AnnotationAbility + include CanCan::Ability + + def initialize(user) + can [:edit, :update, :destroy], Annotation do |annotation| + annotation.user == user + end + + can [:new, :create, :update_annotations, :num_nearby_posted_mistake_annotations], Annotation + end +end diff --git a/app/abilities/medium_ability.rb b/app/abilities/medium_ability.rb index 01c767b9c..41d068288 100644 --- a/app/abilities/medium_ability.rb +++ b/app/abilities/medium_ability.rb @@ -5,7 +5,7 @@ def initialize(user) user ||= User.new clear_aliased_actions - can [:index, :new, :search], Medium + can [:index, :new, :search, :check_annotation_visibility], Medium can [:show, :show_comments], Medium do |medium| medium.visible_for_user?(user) && @@ -16,7 +16,7 @@ def initialize(user) !user.generic? && medium.visible_for_user?(user) end - can [:edit, :update, :enrich, :publish, :destroy, :cancel_publication, + can [:edit, :update, :enrich, :feedback, :publish, :destroy, :cancel_publication, :add_item, :add_reference, :add_screenshot, :remove_screenshot, :import_script_items, :import_manuscript, :statistics, :render_medium_tags, :fill_quizzable_area, diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 31511217e..49b7e76e6 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -50,11 +50,53 @@ //= require talks //= require terms //= require tex_preview -//= require thyme -//= require thyme_editor //= require upload //= require users //= require vertices //= require watchlists //= require turbolinks -//= require search_tags \ No newline at end of file + +//= require search_tags + +/* + * THYME RELATED SCRIPTS + * (warning: the order of the scripts is important + * - do not switch to alphabetical order!) + */ +//= require thyme/components/component +//= require thyme/components/add_item_button +//= require thyme/components/add_reference_button +//= require thyme/components/add_screenshot_button +//= require thyme/components/annotation_category_toggle +//= require thyme/components/annotations_toggle +//= require thyme/components/annotation_button +//= require thyme/components/full_screen_button +//= require thyme/components/ia_button +//= require thyme/components/ia_back_button +//= require thyme/components/ia_close_button +//= require thyme/components/mute_button +//= require thyme/components/next_chapter_button +//= require thyme/components/play_button +//= require thyme/components/previous_chapter_button +//= require thyme/components/seek_bar +//= require thyme/components/speed_selector +//= require thyme/components/time_button +//= require thyme/components/volume_bar +//= require thyme/annotations/category_enum +//= require thyme/annotations/category +//= require thyme/annotations/subcategory +//= require thyme/annotations/annotation +//= require thyme/annotations/annotation_area +//= require thyme/annotations/annotation_manager +//= require thyme/attributes +//= require thyme/chapter_manager +//= require thyme/control_bar_hider +//= require thyme/display_manager +//= require thyme/heatmap +//= require thyme/key_shortcuts +//= require thyme/metadata_manager +//= require thyme/resizer +//= require thyme/utility +//= require thyme/thyme_player +//= require thyme/thyme_editor +//= require thyme/thyme_feedback diff --git a/app/assets/javascripts/lectures.coffee b/app/assets/javascripts/lectures.coffee index 20b6cc98c..e8a0f5587 100644 --- a/app/assets/javascripts/lectures.coffee +++ b/app/assets/javascripts/lectures.coffee @@ -203,6 +203,29 @@ $(document).on 'turbolinks:load', -> userModalContent.dataset.filled = 'true' return + # Dynamically render content for entering emergency links. + updateEmergencyLink = (value) -> + if value == "no_link" + $('#direct-link-field').hide() + $('#lecture-link-field').hide() + if value == "lecture_link" + $('#direct-link-field').hide() + $('#lecture-link-field').show() + if value == "direct_link" + $('#lecture-link-field').hide() + $('#direct-link-field').show() + return + + emergencyLinkRadios = document.getElementById('emergency-link-status-radios') + + if (emergencyLinkRadios != null) + $('#emergency-link-status-radios input:radio:checked').each -> + updateEmergencyLink(this.value) + return + emergencyLinkRadios.addEventListener 'click', (evt) -> + if evt.target && event.target.matches("input[type='radio']") + updateEmergencyLink(evt.target.value) + return # on small mobile display, use shortened tag badges and # shortened course titles diff --git a/app/assets/javascripts/thyme.coffee b/app/assets/javascripts/thyme.coffee deleted file mode 100644 index 65f8a7a8d..000000000 --- a/app/assets/javascripts/thyme.coffee +++ /dev/null @@ -1,757 +0,0 @@ -# convert time in seconds to string of the form H:MM:SS -secondsToTime = (seconds) -> - date = new Date(null) - date.setSeconds seconds - return date.toISOString().substr(12, 7) - -# return the start time of the next chapter relative to a given time in seconds -nextChapterStart = (seconds) -> - chapters = document.getElementById('chapters') - times = JSON.parse(chapters.dataset.times) - return if times.length == 0 - i = 0 - while i < times.length - return times[i] if times[i] > seconds - ++i - return - -# return the start time of the previous chapter relative to a givben time in -# seconds -previousChapterStart = (seconds) -> - chapters = document.getElementById('chapters') - times = JSON.parse(chapters.dataset.times) - return if times.length == 0 - i = times.length - 1 - while i > -1 - if times[i] < seconds - return times[i] if seconds - times[i] > 3 - return times[i-1] if i > 0 - --i - return - -showControlBar = -> - $('#video-controlBar').css('visibility', 'visible') - $('#video').css('cursor', '') - return - -hideControlBar = -> - $('#video-controlBar').css('visibility', 'hidden') - $('#video').css('cursor', 'none') - return - -# hide control bar after 3 seconds of inactivity -idleHideControlBar = -> - t = undefined - - resetTimer = -> - clearTimeout t - t = setTimeout hideControlBar, 3000 - return - - window.onload = resetTimer - window.onmousemove = resetTimer - window.onmousedown = resetTimer - window.ontouchstart = resetTimer - window.onclick = resetTimer - return - -# material icons that represent different media types -iconClass = (type) -> - if type == 'video' - return 'video_library' - else if type == 'text' - return 'library_books' - else if type == 'quiz' - return 'games' - else if type == 'info' - return 'info' - return - -# returns the jQuery object of all metadata elements that start after the -# given time in seconds -metadataAfter = (seconds) -> - metaList = document.getElementById('metadata') - times = JSON.parse(metaList.dataset.times) - return $() if times.length == 0 - i = 0 - while i < times.length - if times[i] > seconds - $nextMeta = $('#m-' + $.escapeSelector(times[i])) - return $nextMeta.add($nextMeta.nextAll()) - ++i - return $() - -# returns the jQuery object of all metadata elements that start before the -# given time in seconds -metadataBefore = (seconds) -> - return $('[id^="m-"]').not(metadataAfter(seconds)) - -# for a given time, show all metadata elements that start before this time -# and hide all that start later -metaIntoView = (time) -> - metadataAfter(time).hide() - $before = metadataBefore(time) - $before.show() - previousLength = $before.length - if previousLength > 0 - $before.get(previousLength - 1).scrollIntoView() - return - -# set up everything: read out track data and initialize html elements -setupHypervideo = -> - $chapterList = $('#chapters') - $metaList = $('#metadata') - video = $('#video').get 0 - backButton = document.getElementById('back-button') - return if !video? - document.body.style.backgroundColor = 'black' - chaptersElement = $('#video track[kind="chapters"]').get 0 - metadataElement = $('#video track[kind="metadata"]').get 0 - - # set up back button (transports back to the current chapter) - displayBackButton = -> - backButton.dataset.time = video.currentTime - currentChapter = $('#chapters .current') - if currentChapter.length > 0 - backInfo = currentChapter.data('text').split(':', 1)[0] - if backInfo? && backInfo.length > 20 - backInfo = backButton.dataset.back - else - backInfo = backButton.dataset.backto + backInfo - $(backButton).empty().append(backInfo).show() - renderMathInElement backButton, - delimiters: [ - { - left: '$$' - right: '$$' - display: true - } - { - left: '$' - right: '$' - display: false - } - { - left: '\\(' - right: '\\)' - display: false - } - { - left: '\\[' - right: '\\]' - display: true - } - ] - throwOnError: false - return - - # set up the chapter elements - displayChapters = -> - if chaptersElement.readyState == 2 and - (chaptersTrack = chaptersElement.track) - chaptersTrack.mode = 'hidden' - i = 0 - times = [] - # read out the chapter track cues and generate html elements for chapters, - # run katex on them - while i < chaptersTrack.cues.length - cue = chaptersTrack.cues[i] - chapterName = cue.text - start = cue.startTime - times.push start - $listItem = $("
  • ") - $link = $("", { id: 'c-' + start, text: chapterName }) - $chapterList.append($listItem.append($link)) - chapterElement = $link.get(0) - renderMathInElement chapterElement, - delimiters: [ - { - left: '$$' - right: '$$' - display: true - } - { - left: '$' - right: '$' - display: false - } - { - left: '\\(' - right: '\\)' - display: false - } - { - left: '\\[' - right: '\\]' - display: true - } - ] - throwOnError: false - $link.data('text', chapterName) - # if a chapter element is clicked, transport to chapter start time - $link.on 'click', -> - displayBackButton() - video.currentTime = @id.replace('c-', '') - return - ++i - # store start times as data attribute - $chapterList.get(0).dataset.times = JSON.stringify(times) - $chapterList.show() - # if the chapters cue changes (i.e. a switch between chapters), highlight - # current chapter elment and scroll it into view, remove highlighting from - # old chapter - $(chaptersTrack).on 'cuechange', -> - $('#chapters li a').removeClass 'current' - if @activeCues.length > 0 - activeStart = @activeCues[0].startTime - if chapter = document.getElementById('c-' + activeStart) - $(chapter).addClass 'current' - chapter.scrollIntoView() - return - return - - # set up the metadata elements - displayMetadata = -> - if metadataElement.readyState == 2 and (metaTrack = metadataElement.track) - metaTrack.mode = 'hidden' - i = 0 - times = [] - # read out the metadata track cues and generate html elements for - # metadata, run katex on them - while i < metaTrack.cues.length - cue = metaTrack.cues[i] - meta = JSON.parse cue.text - start = cue.startTime - times.push start - $listItem = $('
  • ', id: 'm-' + start) - $listItem.hide() - $link = $('', - text: meta.reference - class: 'item' - id: 'l-' + start) - $videoIcon = $('', - text: 'video_library' - class: 'material-icons') - $videoRef = $('', - href: meta.video - target: '_blank') - $videoRef.append($videoIcon) - $videoRef.hide() unless meta.video? - $manIcon = $('', - text: 'library_books' - class: 'material-icons') - $manRef = $('', - href: meta.manuscript - target: '_blank') - $manRef.append($manIcon) - $manRef.hide() unless meta.manuscript? - $scriptIcon = $('', - text: 'menu_book' - class: 'material-icons') - $scriptRef = $('', - href: meta.script - target: '_blank') - $scriptRef.append($scriptIcon) - $scriptRef.hide() unless meta.script? - $quizIcon = $('', - text: 'videogame_asset' - class: 'material-icons') - $quizRef = $('', - href: meta.quiz - target: '_blank') - $quizRef.append($quizIcon) - $quizRef.hide() unless meta.quiz? - $extIcon = $('', - text: 'link' - class: 'material-icons') - $extRef = $('', - href: meta.link - target: '_blank') - $extRef.append($extIcon) - $extRef.hide() unless meta.link? - $description = $('
    ', - text: meta.text - class: 'mx-3') - $explanation = $('
    ', - text: meta.explanation - class: 'm-3') - $details = $('
    ') - $details.append($link).append($description).append($explanation) - $icons = $('
    ', - style: 'flex-shrink: 3; display: flex; flex-direction: column;') - $icons.append($videoRef).append($manRef).append($scriptRef).append($quizRef).append($extRef) - $listItem.append($details).append($icons) - $metaList.append($listItem) - $videoRef.on 'click', -> - video.pause() - return - $manRef.on 'click', -> - video.pause() - return - $extRef.on 'click', -> - video.pause() - return - $link.on 'click', -> - displayBackButton() - video.currentTime = this.id.replace('l-','') - return - metaElement = $listItem.get(0) - renderMathInElement metaElement, - delimiters: [ - { - left: '$$' - right: '$$' - display: true - } - { - left: '$' - right: '$' - display: false - } - { - left: '\\(' - right: '\\)' - display: false - } - { - left: '\\[' - right: '\\]' - display: true - } - ] - throwOnError: false - ++i - # store metadata start times as data attribute - $metaList.get(0).dataset.times = JSON.stringify(times) - # if user jumps to a new position in the video, display all metadata - # that start before this time and hide all that start later - $(video).on 'seeked', -> - time = video.currentTime - metaIntoView(time) - return - # if the metadata cue changes, highlight all current media and scroll - # them into view - $(metaTrack).on 'cuechange', -> - j = 0 - time = video.currentTime - $('#metadata li').removeClass 'current' - while j<@activeCues.length - activeStart = @activeCues[j].startTime - if metalink = document.getElementById('m-' + activeStart) - $(metalink).show() - $(metalink).addClass 'current' - ++j - currentLength = $('#metadata .current').length - if currentLength > 0 - $('#metadata .current').get(length - 1).scrollIntoView() - return - return - - # after video metadata have been loaded, display chapters and metadata in the - # interactive area - # Originally (and more appropriately, according to the standards), - # only the 'loadedmetadata' event was used. However, Firefox triggers this event to soon, - # i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded) - # for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp - initialChapters = true - initialMetadata = true - video.addEventListener 'loadedmetadata', -> - if initialChapters and chaptersElement.readyState == 2 - displayChapters() - initialChapters = false - if initialMetadata and metadataElement.readyState == 2 - displayMetadata() - initialMetadata = false - - video.addEventListener 'canplay', -> - if initialChapters and chaptersElement.readyState == 2 - displayChapters() - initialChapters = false - if initialMetadata and metadataElement.readyState == 2 - displayMetadata() - initialMetadata = false - return - -$(document).on 'turbolinks:load', -> - thymeContainer = document.getElementById('thyme-container') - # no need for thyme if no thyme container on the page - return if thymeContainer == null - # Video - video = document.getElementById('video') - thyme = document.getElementById('thyme') - # Buttons - playButton = document.getElementById('play-pause') - muteButton = document.getElementById('mute') - iaButton = document.getElementById('ia-active') - iaClose = document.getElementById('ia-close') - fullScreenButton = document.getElementById('full-screen') - plusTenButton = document.getElementById('plus-ten') - minusTenButton = document.getElementById('minus-ten') - nextChapterButton = document.getElementById('next-chapter') - previousChapterButton = document.getElementById('previous-chapter') - backButton = document.getElementById('back-button') - # Sliders - seekBar = document.getElementById('seek-bar') - volumeBar = document.getElementById('volume-bar') - # Selectors - speedSelector = document.getElementById('speed') - # Time - currentTime = document.getElementById('current-time') - maxTime = document.getElementById('max-time') - # ControlBar - videoControlBar = document.getElementById('video-controlBar') - - # resizes the thyme container to the window dimensions, taking into account - # whether the interactive area is displayed or hidden - resizeContainer = -> - height = $(window).height() - factor = if $('#caption').is(':hidden') then 1 else 1 / 0.82 - width = Math.floor((video.videoWidth * $(window).height() / - video.videoHeight) * factor) - if width > $(window).width() - shrink = $(window).width() / width - height = Math.floor(height * shrink) - width = $(window).width() - top = Math.floor(0.5*($(window).height() - height)) - left = Math.floor(0.5*($(window).width() - width)) - $('#thyme-container').css('height', height + 'px') - $('#thyme-container').css('width', width + 'px') - $('#thyme-container').css('top', top + 'px') - $('#thyme-container').css('left', left + 'px') - return - - # detect IE/edge and inform user that they are not suppported if necessary, - # only use browser player - if document.documentMode || /Edge/.test(navigator.userAgent) - alert($('body').data('badbrowser')) - $('#caption').hide() - $('#video-controlBar').hide() - video.style.width = '100%' - video.controls = true - document.body.style.backgroundColor = 'black' - resizeContainer() - window.onresize = resizeContainer - return - - setupHypervideo() - - # on small mobile display, fall back to standard browser player - mobileDisplay = -> - $('#caption').hide() - $('#video-controlBar').hide() - video.controls = true - video.style.width = '100%' - return - - # on large display, use anything thyme has to offer, disable native player - largeDisplay = -> - video.controls = false - $('#caption').show() - $('#video-controlBar').show() - video.style.width = '82%' - # directly closes the IA again, if the IA-button status is "-" - if iaButton.dataset.status == 'false' - iaButton.innerHTML = 'remove_from_queue' - $('#caption').hide() - video.style.width = '100%' - $('#video-controlBar').css('width', '100%') - $(window).trigger('resize') - return - - # display native control bar if screen is very small - if window.matchMedia("screen and (max-width: 767px)").matches - mobileDisplay() - - if window.matchMedia("screen and (max-device-width: 767px)").matches - mobileDisplay() - - # mediaQuery listener for very small screens - match_verysmall = window.matchMedia("screen and (max-width: 767px)") - match_verysmall.addListener (result) -> - if result.matches - mobileDisplay() - return - - match_verysmalldevice = window.matchMedia("screen and (max-device-width: 767px)") - match_verysmalldevice.addListener (result) -> - if result.matches - mobileDisplay() - return - - # mediaQuery listener for normal screens - match_normal = window.matchMedia("screen and (min-width: 768px)") - match_normal.addListener (result) -> - if result.matches - largeDisplay() - return - - match_normal = window.matchMedia("screen and (min-device-width: 768px)") - match_normal.addListener (result) -> - if result.matches - largeDisplay() - return - - window.onresize = resizeContainer - video.onloadedmetadata = resizeContainer - - idleHideControlBar() - - # if mouse is moved or screen is toiched, show control bar - video.addEventListener 'mouseover', showControlBar, false - video.addEventListener 'mousemove', showControlBar, false - video.addEventListener 'touchstart', showControlBar, false - - # Event listener for the play/pause button - playButton.addEventListener 'click', -> - if video.paused == true - video.play() - else - video.pause() - return - - video.onplay = -> - playButton.innerHTML = 'pause' - - video.onpause = -> - playButton.innerHTML = 'play_arrow' - - # Event listener for the mute button - muteButton.addEventListener 'click', -> - if video.muted == false - video.muted = true - muteButton.innerHTML = 'volume_off' - else - video.muted = false - muteButton.innerHTML = 'volume_up' - return - - # Event handler for the plusTen button - plusTenButton.addEventListener 'click', -> - video.currentTime = Math.min(video.currentTime + 10, video.duration) - return - - # Event handler for the minusTen button - minusTenButton.addEventListener 'click', -> - video.currentTime = Math.max(video.currentTime - 10, 0) - return - - # Event handler for the nextChapter button - nextChapterButton.addEventListener 'click', -> - next = nextChapterStart(video.currentTime) - video.currentTime = nextChapterStart(video.currentTime) if next? - return - - # Event handler for the previousChapter button - previousChapterButton.addEventListener 'click', -> - previous = previousChapterStart(video.currentTime) - video.currentTime = previousChapterStart(video.currentTime) if previous? - return - - # Event handler for speed speed selector - speedSelector.addEventListener 'change', -> - if video.preservesPitch? - video.preservesPitch = true - else if video.mozPreservesPitch? - video.mozPreservesPitch = true - else if video.webkitPreservesPitch? - video.webkitPreservesPitch = true - video.playbackRate = @options[@selectedIndex].value - return - - # Event handler for interactive area activation button - iaButton.addEventListener 'click', -> - if iaButton.dataset.status == 'true' - iaButton.innerHTML = 'remove_from_queue' - iaButton.dataset.status = 'false' - $('#caption').hide() - video.style.width = '100%' - $('#video-controlBar').css('width', '100%') - $(window).trigger('resize') - else - iaButton.innerHTML = 'add_to_queue' - iaButton.dataset.status = 'true' - video.style.width = '82%' - $('#video-controlBar').css('width', '82%') - $('#caption').show() - $(window).trigger('resize') - return - - # Event Handler for Back Button - backButton.addEventListener 'click', -> - video.currentTime = this.dataset.time - $(backButton).hide() - $('#back-reference').hide() - return - - # Event handler for close interactive area button - iaClose.addEventListener 'click', -> - $(iaButton).trigger('click') - return - - # Event listener for the full-screen button - # unfortunately, lots of brwoser specific code - fullScreenButton.addEventListener 'click', -> - if fullScreenButton.dataset.status == 'true' - if document.exitFullscreen - document.exitFullscreen() - else if document.mozCancelFullScreen - document.mozCancelFullScreen() - else if document.webkitExitFullscreen - document.webkitExitFullscreen() - else - if thymeContainer.requestFullscreen - thymeContainer.requestFullscreen() - else if thymeContainer.mozRequestFullScreen - thymeContainer.mozRequestFullScreen() - else if thymeContainer.webkitRequestFullscreen - thymeContainer.webkitRequestFullscreen() - return - - document.onfullscreenchange = -> - if document.fullscreenElement != null - fullScreenButton.innerHTML = 'fullscreen_exit' - fullScreenButton.dataset.status = 'true' - else - fullScreenButton.innerHTML = 'fullscreen' - fullScreenButton.dataset.status = 'false' - # brute force patch: apparently, after exiting fullscreen mode, - # window.onresize is triggered twice(!), the second time with incorrect - # window height data, which results in a video area not quite filling - # the whole window. The next line resizes the container again. - setTimeout(resizeContainer, 20) - return - - document.onwebkitfullscreenchange = -> - if document.webkitFullscreenElement != null - fullScreenButton.innerHTML = 'fullscreen_exit' - fullScreenButton.dataset.status = 'true' - else - fullScreenButton.innerHTML = 'fullscreen' - fullScreenButton.dataset.status = 'false' - setTimeout(resizeContainer, 20) - return - - document.onmozfullscreenchange = -> - if document.mozFullScreenElement != null - fullScreenButton.innerHTML = 'fullscreen_exit' - fullScreenButton.dataset.status = 'true' - else - fullScreenButton.innerHTML = 'fullscreen' - fullScreenButton.dataset.status = 'false' - setTimeout(resizeContainer, 20) - return - - # Event listeners for the seek bar - seekBar.addEventListener 'input', -> - time = video.duration * seekBar.value / 100 - video.currentTime = time - return - - # if mouse is moved over seek bar, display tooltip with current chapter - seekBar.addEventListener 'mousemove', (evt) -> - positionInfo = seekBar.getBoundingClientRect() - width = positionInfo.width; - left = positionInfo.left - measuredSeconds = ((evt.pageX - left)/width) * video.duration - seconds = Math.min(measuredSeconds, video.duration) - seconds = Math.max(seconds, 0) - previous = previousChapterStart(seconds) - info = $('#c-' + $.escapeSelector(previous)).text().split(':')[0] - seekBar.setAttribute('title', info) - return - - # if videomedtadata have been loaded, set up video length, volume bar and - # seek bar - video.addEventListener 'loadedmetadata', -> - maxTime.innerHTML = secondsToTime(video.duration) - volumeBar.value = video.volume - volumeBar.style.backgroundImage = 'linear-gradient(to right,' + - ' #2497E3, #2497E3 ' + video.volume*100 + '%, #ffffff ' + - video.volume*100 + '%, #ffffff)' - if video.dataset.time? - time = video.dataset.time - video.currentTime = time - seekBar.value = video.currentTime / video.duration * 100 - else - seekBar.value = 0 - return - - # Update the seek bar as the video plays - # uses a gradient for seekbar video time visualization - video.addEventListener 'timeupdate', -> - value = 100 / video.duration * video.currentTime - seekBar.value = value - seekBar.style.backgroundImage = 'linear-gradient(to right,' + - ' #2497E3, #2497E3 ' + value + '%, #ffffff ' + value + '%, #ffffff)' - currentTime.innerHTML = secondsToTime(video.currentTime) - return - - # Pause the video when the seek handle is being dragged - seekBar.addEventListener 'mousedown', -> - video.dataset.paused = video.paused - video.pause() - return - - # Play the video when the seek handle is dropped - seekBar.addEventListener 'mouseup', -> - video.play() unless video.dataset.paused == 'true' - return - - # Event listener for the volume bar - volumeBar.addEventListener 'input', -> - value = volumeBar.value - video.volume = value - return - - video.addEventListener 'volumechange', -> - value = video.volume - volumeBar.value = value - volumeBar.style.backgroundImage = 'linear-gradient(to right,' + - ' #2497E3, #2497E3 ' + value*100 + '%, #ffffff ' + value*100 + '%, #ffffff)' - return - - video.addEventListener 'click', -> - if video.paused == true - video.play() - else - video.pause() - showControlBar() - return - - # thyme can be used by keyboard as well - # Arrow up - next chapter - # Arrow down - previous chapter - # Arrow right - plus ten seconds - # Arrow left - minus ten seconds - # f - fullscreen - # Page up - volume up - # Page down - volume down - # m - mute - # i - toggle interactive area - window.addEventListener 'keydown', (evt) -> - key = evt.key - if key == ' ' - if video.paused == true - video.play() - else - video.pause() - else if key == 'ArrowUp' - $(nextChapterButton).trigger('click') - else if key == 'ArrowDown' - $(previousChapterButton).trigger('click') - else if key == 'ArrowRight' - $(plusTenButton).trigger('click') - else if key == 'ArrowLeft' - $(minusTenButton).trigger('click') - else if key == 'f' - $(fullScreenButton).trigger('click') - else if key == 'PageUp' - video.volume = Math.min(video.volume + 0.1, 1) - else if key == 'PageDown' - video.volume = Math.max(video.volume - 0.1, 0) - else if key == 'm' - $(muteButton).trigger('click') - else if key == 'i' - $(iaButton).trigger('click') - return - return diff --git a/app/assets/javascripts/thyme/annotations/annotation.js b/app/assets/javascripts/thyme/annotations/annotation.js new file mode 100644 index 000000000..4b3d4d432 --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/annotation.js @@ -0,0 +1,104 @@ +/** + This class helps to represent an annotation in JavaScript. +*/ +// eslint-disable-next-line no-unused-vars +class Annotation { + constructor(json) { + // We only save attributes that are needed in the thyme related JavaScripts! + this.category = Category.getByName(json.category); + this.color = json.color; + this.comment = json.comment; + this.id = json.id; + this.seconds = thymeUtility.timestampToSeconds(json.timestamp); + this.subcategory = Subcategory.getByName(json.subcategory); + this.belongsToCurrentUser = json.belongs_to_current_user; + } + + /* + * AUXILIARY METHODS + */ + + /* + Create normal marker. + + color = Color of the marker. + strokeColor = Color of the border of the marker. + onClick = A function triggered when one clicks on the marker. + */ + createMarker(color, strokeColor, onClick) { + this.#create(color, false, onClick); + } + + /* + Create big marker with customizable border color. + (Used e.g. for big mistake markers in the feedback player.) + + color = Color of the marker. + strokeColor = Color of the border of the marker. + onClick = A function triggered when one clicks on the marker. + */ + createBigMarker(color, strokeColor, onClick) { + this.#create(color, true, onClick); + } + + /* + An auxiliary method, only used for a better structure of createMarker() and createBigMarker()! + */ + #create(color, isBigMarker, onClick) { + const markerStr = ` + + `; + $("#" + thymeAttributes.markerBarId).append(markerStr); + + const marker = $("#marker-" + this.id); + const size = thymeAttributes.seekBar.element.clientWidth - 15; + const ratio = this.seconds / thymeAttributes.video.duration; + const offset = marker.parent().offset().left + ratio * size + 3; + marker.offset({ left: offset }); + + marker.on("click", function () { + thymeAttributes.disableAnnotationKeyListeners = false; + onClick(); + }); + } + + /* + Returns a string with the correct translation of the category and subcategory of this annotation. + */ + categoryLocale() { + const c = this.category; + const s = this.subcategory; + return s ? c.locale() + " (" + s.locale() + ")" : c.locale(); + } + + /* + * Returns true if the given annotation is the last annotation + * in thymeAttributes.annotations + */ + isFirst() { + return this == thymeAttributes.annotations[0]; + } + + /* + * Returns true if the given annotation is the last annotation + * in thymeAttributes.annotations + */ + isLast() { + return this == thymeAttributes.annotations[thymeAttributes.annotations.length - 1]; + } + + updateOpenAnnotationMarker(oldId, newId) { + if (oldId) { + const oldMarker = $("#marker-" + oldId).children("i"); + oldMarker.removeClass("annotation-marker-shown"); + } + + const newMarker = $("#marker-" + newId).children("i"); + newMarker.addClass("annotation-marker-shown"); + } + + markCurrentAnnotationAsNotShown() { + const marker = $("#marker-" + this.id).children("i"); + marker.removeClass("annotation-marker-shown"); + } +} diff --git a/app/assets/javascripts/thyme/annotations/annotation_area.js b/app/assets/javascripts/thyme/annotations/annotation_area.js new file mode 100644 index 000000000..a1ddc3653 --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/annotation_area.js @@ -0,0 +1,238 @@ +/** + This class helps to represent the annotation area in java script. +*/ +// eslint-disable-next-line no-unused-vars +class AnnotationArea { + static DISABLED_BUTTON_OPACITY = 0.2; + + /* + hasFancyStyle = If true, all buttons are shown, if false, + only previous, goto and next are shown. + + colorFunc = A function for colorizing the annotation area + which takes an annotation as argument and gives + back a color. + + onClose = A function that is executed when closing the + annotation area. + + isValid = A function which takes an annotation as argument + and returns true, if and only if the annotation is + "valid", i.e. should be visualized in the annotation + area. (This is needed to skip unwanted annotations in + the previous/next button listeners.) + */ + constructor(hasFancyStyle, colorFunc, onClose, isValid) { + this.isActive = false; + this.annotation = null; // the current annotation + this.colorFunc = colorFunc; + this.onClose = onClose; + this.isValid = isValid; + this.hasFancyStyle = hasFancyStyle; + + this.caption = $("#annotation-caption"); + this.infoBar = $("#annotation-infobar"); + this.commentField = $("#annotation-comment"); + this.previousButton = $("#annotation-previous-button"); + this.gotoButton = $("#annotation-goto-button"); + this.editButton = $("#annotation-edit-button"); + this.closeButton = $("#annotation-close-button"); + this.nextButton = $("#annotation-next-button"); + this.areaButtonsRegion = $("#annotation-area-buttons"); + + this.localesId = "annotation-locales"; + + if (!hasFancyStyle) { + this.editButton.hide(); + this.closeButton.hide(); + } + } + + /* + Show the annotation area. + */ + show() { + this.caption.show(); + this.isActive = true; + } + + /* + Hide the annotation area. + */ + hide() { + this.caption.hide(); + this.isActive = false; + } + + /* + Update the annotation area with the content of the given annotation. + */ + update(annotation) { + if (!annotation) { + return; + } + const oldId = this.annotation ? this.annotation.id : null; + + this.annotation = annotation; + + // update info and comment field + this.#updateInfoAndCommentField(annotation, this.colorFunc(annotation)); + // update buttons + this.#updatePreviousButton(annotation); + this.#updateNextButton(annotation); + this.#updateGotoButton(annotation); + if (this.hasFancyStyle) { + this.#updateEditButton(annotation); + this.#updateCloseButton(); + } + annotation.updateOpenAnnotationMarker(oldId, annotation.id); + + // render LaTex + const commentId = this.commentField.attr("id"); + thymeUtility.renderLatex(document.getElementById(commentId)); + } + + showAnnotationWithId(id) { + const annotation = AnnotationManager.find(id); + if (annotation) { + thymeAttributes.annotationManager.forceTriggerOnClick(annotation); + } + } + + /* + AUXILIARY METHODS + */ + #updateInfoAndCommentField(annotation, color) { + const head = annotation.categoryLocale(); + const comment = annotation.comment.replaceAll("\n", "
    "); + const headColor = thymeUtility.lightenUp(color, 2); + const backgroundColor = thymeUtility.lightenUp(color, 3); + this.infoBar.empty().append(head); + this.infoBar.css("background-color", headColor); + this.commentField.empty().append(comment); + + // Comment field background gradient + const colorGradientEnd = thymeUtility.lightenUp(color, 2.5); + const gradient = `linear-gradient(to bottom, ${backgroundColor} 50%, ${colorGradientEnd} 100%)`; + this.caption.css("background-image", gradient); + + // Area buttons + this.areaButtonsRegion.css("background-color", headColor); + } + + #updatePreviousButton(annotation) { + const area = this; // need a reference inside the listener scope! + this.previousButton.off("click"); + this.previousButton.on("click", function () { + area.update(area.previousValidAnnotation()); + }); + if (annotation.isFirst()) { + this.previousButton.css("opacity", AnnotationArea.DISABLED_BUTTON_OPACITY); + } + else { + this.previousButton.css("opacity", 1); + } + } + + #updateNextButton(annotation) { + const area = this; // need a reference inside the listener scope! + this.nextButton.off("click"); + this.nextButton.on("click", function () { + area.update(area.nextValidAnnotation()); + }); + if (annotation.isLast()) { + this.nextButton.css("opacity", AnnotationArea.DISABLED_BUTTON_OPACITY); + } + else { + this.nextButton.css("opacity", 1); + } + } + + #updateGotoButton(annotation) { + this.gotoButton.off("click"); + this.gotoButton.on("click", function () { + video.currentTime = annotation.seconds; + }); + } + + #updateEditButton(annotation) { + const localesId = this.localesId; + this.editButton.off("click"); + this.editButton.on("click", function () { + thymeAttributes.video.pause(); + thymeAttributes.lockKeyListeners = true; + $.ajax(Routes.edit_annotation_path(annotation.id), { + type: "GET", + dataType: "script", + data: { + annotation_id: annotation.id, + }, + success: function (permitted) { + if (permitted === "false") { + alert(document.getElementById(localesId).dataset.permission); + } + }, + error: function (e) { + console.log(e); + }, + }); + }); + } + + unmarkCurrentAnnotationAsShown() { + if (!this.annotation) { + return; + } + this.annotation.markCurrentAnnotationAsNotShown(); + } + + #updateCloseButton() { + const area = this; // need a reference inside the listener scope! + this.closeButton.off("click"); + this.closeButton.on("click", function () { + area.unmarkCurrentAnnotationAsShown(); + area.annotation = undefined; + area.hide(); + thymeAttributes.disableAnnotationKeyListeners = true; + if (area.onClose != null) { + area.onClose(); + } + }); + } + + /* + Returns the first annotation which is valid and which comes + before the input annotation on the timeline. + Returns null if no valid annotation before the input annotation + exists. + */ + previousValidAnnotation() { + const currentId = this.annotation.id; + const currentIndex = AnnotationManager.findIndex(currentId); + const annotations = thymeAttributes.annotations; + for (let i = currentIndex - 1; i >= 0; i--) { + if (this.isValid(annotations[i])) { + return annotations[i]; + } + } + return null; + } + + /* + Returns the first annotation which is valid and which comes + after the input annotation on the timeline. + Returns null if no valid annotation after the input annotation + exists. + */ + nextValidAnnotation() { + const currentId = this.annotation.id; + const currentIndex = AnnotationManager.findIndex(currentId); + const annotations = thymeAttributes.annotations; + for (let i = currentIndex + 1; i < annotations.length; i++) { + if (this.isValid(annotations[i])) { + return annotations[i]; + } + } + return null; + } +} diff --git a/app/assets/javascripts/thyme/annotations/annotation_manager.js b/app/assets/javascripts/thyme/annotations/annotation_manager.js new file mode 100644 index 000000000..aed7d7c3e --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/annotation_manager.js @@ -0,0 +1,170 @@ +/** + * This class provides methods that help to manage all annotations in a thyme player. + */ +// eslint-disable-next-line no-unused-vars +class AnnotationManager { + /* + colorFunc = A function which takes an annotation and gives + back a color for the corresponding marker. + + strokeColorFunc = A function which takes an annotation and gives + back a color for the stroke of the corresponding marker. + + sizeFunc = A function which takes an annotation and returns + a boolean that is true if and only if the marker + corresponding to the annotation should be big. + + onClick = A function that takes an annotation as argument and + which is triggered when the corresponding marker is + clicked. + + onUpdate = A function that is triggered when the annotations + have been updated. + + isValid = A function that takes an annotation as argument and + returns a boolean. If and only if it returns true, + the given annotation is viualized by this annotation + manager. + */ + constructor(colorFunc, strokeColorFunc, sizeFunc, onClick, onUpdate, isValid) { + this.colorFunc = colorFunc; + this.strokeColorFunc = strokeColorFunc; + this.sizeFunc = sizeFunc; + this.onClick = onClick; + this.onUpdate = onUpdate; + this.isValid = isValid; + this.isDbCalledForFreshAnnotations = false; + } + + forceTriggerOnClick(annotation) { + this.onClick(annotation); + } + + /* + Updates the markers on the timeline, i.e. the visual represention of the annotations. + This method is e.g. used for rearranging the markers when the window is being resized. + Don't mix up with updateAnnotatons() which sends an AJAX request and checks for changes + in the database. + */ + updateMarkers() { + // In case the annotations have not been loaded yet, do nothing. + // This situation might occur during the initial page load. + if (thymeAttributes.annotations === null) { + if (!this.isDbCalledForFreshAnnotations) { + this.updateAnnotations(); + } + return; + } + + const annotationManager = this; + $("#" + thymeAttributes.markerBarId).empty(); + AnnotationManager.sortAnnotations(); + + for (const a of thymeAttributes.annotations) { + if (this.isValid(a)) { + function onClick() { + annotationManager.onClick(a); + } + if (this.sizeFunc && this.sizeFunc(a)) { + a.createBigMarker(this.colorFunc(a), this.strokeColorFunc(a), onClick); + } + else { + a.createMarker(this.colorFunc(a), this.strokeColorFunc(a), onClick); + } + } + } + // call additional function that is individual for each player + this.onUpdate(); + } + + /* + Sends a AJAX request which returns all the annotations for the given medium. + This method is e.g. used when a new annotation is being created. + Don't mix up with updateMarkers() which just updates the position of the markers! + + onSucess = A function that is triggered when the annotations have been + successfully updated. + onSuccess = A function that is triggered when the annotations have been + successfully updated. + */ + updateAnnotations(onSuccess) { + if (!thymeAttributes.annotationFeatureActive) { + return; + } + + this.isDbCalledForFreshAnnotations = true; // Lock resource + + const manager = this; + $.ajax(Routes.update_annotations_path(), { + type: "GET", + dataType: "json", + data: { + mediumId: thymeAttributes.mediumId, + }, + success: (annotations) => { + // update the annotation field in thymeAttributes + thymeAttributes.annotations = []; + if (!annotations) { + return; + } + for (let a of annotations) { + thymeAttributes.annotations.push(new Annotation(a)); + } + // update visual representation on the seek bar + manager.updateMarkers(); + + if (onSuccess) { + onSuccess(); + } + }, + always: () => { + // Free resource + manager.isDbCalledForFreshAnnotations = false; + }, + }); + } + + /* sorts all annotations according to their timestamp */ + static sortAnnotations() { + if (!thymeAttributes.annotations) { + return; + } + thymeAttributes.annotations.sort(function (ann1, ann2) { + return ann1.seconds - ann2.seconds; + }); + } + + /* + Finds the annotation with the given ID in thymeAttributes.annotations. + Returns null if it doesn't exist. + */ + static find(id) { + const annotations = thymeAttributes.annotations; + if (!annotations) { + return null; + } + for (let a of annotations) { + if (a.id === id) { + return a; + } + } + return null; + } + + /* + Finds the index in the array thymeAttributes.annotations of an annotation + with the given id. Returns undefined if the array doesn't contain this annotation. + */ + static findIndex(id) { + const annotations = thymeAttributes.annotations; + if (!annotations) { + return undefined; + } + for (let i = 0; i < annotations.length; i++) { + if (annotations[i].id === id) { + return i; + } + } + return undefined; + } +} diff --git a/app/assets/javascripts/thyme/annotations/category.js b/app/assets/javascripts/thyme/annotations/category.js new file mode 100644 index 000000000..56d0aed9a --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/category.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line no-unused-vars +class Category extends CategoryEnum { + static _categories = []; + + static NOTE = new Category("note", "#f78f19"); + static CONTENT = new Category("content", "#A333C8"); + static PRESENTATION = new Category("presentation", "#2185D0"); + static MISTAKE = new Category("mistake", "#fc1461"); + + constructor(name, color) { + super(name); + this.color = color; + Category._categories.push(this); + } + + static getByName(name) { + return super.getByName(name, Category._categories); + } + + static all() { + return super.all(Category._categories); + } +} diff --git a/app/assets/javascripts/thyme/annotations/category_enum.js b/app/assets/javascripts/thyme/annotations/category_enum.js new file mode 100644 index 000000000..f687ee424 --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/category_enum.js @@ -0,0 +1,39 @@ +// eslint-disable-next-line no-unused-vars +class CategoryEnum { + constructor(name) { + this.name = name; + } + + /* + * Returns the correct locale for the given category. + * This method will only work if the given thyme player + * has a div-tag with the id "annotation-locales" which + * includes the name of the categories as data sets, e.g. + * data-note="<%= t(...) %>". + */ + locale() { + return document.getElementById("annotation-locales").dataset[this.name]; + } + + /* + * Return the object with the given name in the given array. + * + * Override in subclasses. + */ + static getByName(name, array) { + for (let a of array) { + if (a.name === name) { + return a; + } + } + } + + /* + * Returns an array with all objects of this enum. + * + * Override in subclasses. + */ + static all(array) { + return array.slice(); + } +} diff --git a/app/assets/javascripts/thyme/annotations/subcategory.js b/app/assets/javascripts/thyme/annotations/subcategory.js new file mode 100644 index 000000000..1ab193887 --- /dev/null +++ b/app/assets/javascripts/thyme/annotations/subcategory.js @@ -0,0 +1,21 @@ +// eslint-disable-next-line no-unused-vars +class Subcategory extends CategoryEnum { + static _subcategories = []; // do not manipulate this array outside of this class! + + static DEFINITION = new Subcategory("definition"); + static ARGUMENT = new Subcategory("argument"); + static STRATEGY = new Subcategory("strategy"); + + constructor(name) { + super(name); + Subcategory._subcategories.push(this); + } + + static getByName(name) { + return super.getByName(name, Subcategory._subcategories); + } + + static all() { + return super.all(Subcategory._subcategories); + } +} diff --git a/app/assets/javascripts/thyme/attributes.js b/app/assets/javascripts/thyme/attributes.js new file mode 100644 index 000000000..5fc1415c9 --- /dev/null +++ b/app/assets/javascripts/thyme/attributes.js @@ -0,0 +1,64 @@ +/** + This file wraps up some attributes that are used in the different + versions of the thyme player. + + Most attributes are set to undefined or null and must be + defined when the player is loaded. +*/ +// eslint-disable-next-line no-unused-vars +const thymeAttributes = { + + /* Saves a reference on the annotation area. */ + annotationArea: null, + + /* Use this to check if the annotation feature is activated + (which it is not for users who aren't signed in). */ + annotationFeatureActive: false, + + /* When callig the updateMarkers() method this will be used to save an + array containing all annotations. */ + annotations: null, + + /* Saves a reference on the annotation manager */ + annotationManager: null, + + /* A list with all the chapters of the current video. */ + chapters: null, + + /* Saves a reference on the chapter manager. */ + chapterManager: null, + + /* If the window width (in px) gets below this threshold value, hide the control bar + (default value). */ + hideControlBarThreshold: { + x: 850, + y: 500, + }, + + /* Saves a reference on the interactive area. */ + interactiveArea: null, + + /* A boolean that helps to deactivate all key listeners + for the time the annotation modal opens and the user + has to write text into the command box. */ + lockKeyListeners: false, + + disableAnnotationKeyListeners: false, + + /* Saves the ID of the HTML element to which annotations are appended. */ + markerBarId: undefined, + + /* When loading a player, it should save the medium id in this field for later use + in different files. */ + mediumId: undefined, + + /* Saves a reference on the metadata manager. */ + metadataManager: null, + + /* Saves a reference on the video's seek bar. */ + seekBar: undefined, + + /* Saves a reference on the video itself */ + video: undefined, + +}; diff --git a/app/assets/javascripts/thyme/chapter_manager.js b/app/assets/javascripts/thyme/chapter_manager.js new file mode 100644 index 000000000..72e3f266a --- /dev/null +++ b/app/assets/javascripts/thyme/chapter_manager.js @@ -0,0 +1,121 @@ +/** + This file wraps up most functionality of the thyme player(s) concerning chapters. +*/ +// eslint-disable-next-line no-unused-vars +class ChapterManager { + constructor(chapterListId, iaBackButton) { + this.chapterListId = chapterListId; + this.iaBackButton = iaBackButton; + } + + load() { + let initialChapters = true; + const videoId = thymeAttributes.video.id; + const chaptersElement = $("#" + videoId + ' track[kind="chapters"]').get(0); + const chapterManager = this; + + /* after video metadata have been loaded, display chapters in the interactive area + Originally (and more appropriately, according to the standards), + only the 'loadedmetadata' event was used. However, Firefox triggers this event too soon, + i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded) + for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp */ + video.addEventListener("loadedmetadata", function () { + if (initialChapters && chaptersElement.readyState === 2) { + chapterManager.#displayChapters(); + initialChapters = false; + } + }); + video.addEventListener("canplay", function () { + if (initialChapters && chaptersElement.readyState === 2) { + chapterManager.#displayChapters(); + initialChapters = false; + } + }); + } + + previousChapterStart() { + const currentTime = thymeAttributes.video.currentTime; + /* NOTE: We cannot use times as an attribute (yet) because it's initialized + before the dataset times is loaded into the HTML. */ + const times = JSON.parse(document.getElementById(this.chapterListId).dataset.times); + if (times.length === 0) { + return; + } + for (let i = times.length - 1; i >= 0; i--) { + if (times[i] < currentTime) { + if (currentTime - times[i] > 3) { + return times[i]; + } + else if (i > 0) { + return times[i - 1]; + } + } + } + } + + nextChapterStart() { + const currentTime = thymeAttributes.video.currentTime; + const times = JSON.parse(document.getElementById(this.chapterListId).dataset.times); + if (times.length === 0) { + return; + } + for (let i = 0; i < times.length; i++) { + if (times[i] > currentTime) { + return times[i]; + } + } + } + + #displayChapters() { + const videoId = thymeAttributes.video.id; + const chapterListId = this.chapterListId; + const iaBackButton = this.iaBackButton; + const chapterList = $("#" + chapterListId); + const chaptersElement = $("#" + videoId + ' track[kind="chapters"]').get(0); + + let chaptersTrack; + if (chaptersElement.readyState === 2 && (chaptersTrack = chaptersElement.track)) { + chaptersTrack.mode = "hidden"; + let times = []; + // read out the chapter track cues and generate html elements for chapters, + // run katex on them + for (let i = 0; i < chaptersTrack.cues.length; i++) { + const cue = chaptersTrack.cues[i]; + const chapterName = cue.text; + const start = cue.startTime; + times.push(start); + const $listItem = $("
  • "); + const $link = $("", { + id: "c-" + start, + text: chapterName, + }); + chapterList.append($listItem.append($link)); + const chapterElement = $link.get(0); + thymeUtility.renderLatex(chapterElement); + $link.data("text", chapterName); + // if a chapter element is clicked, transport to chapter start time + $link.on("click", function () { + iaBackButton.update(); + video.currentTime = this.id.replace("c-", ""); + }); + } + // store start times as data attribute + chapterList.get(0).dataset.times = JSON.stringify(times); + chapterList.show(); + // if the chapters cue changes (i.e. a switch between chapters), highlight + // current chapter elment and scroll it into view, remove highlighting from + // old chapter + $(chaptersTrack).on("cuechange", function () { + $("#" + chapterListId + " li a").removeClass("current"); + if (this.activeCues.length > 0) { + const activeStart = this.activeCues[0].startTime; + const chapter = document.getElementById("c-" + activeStart); + if (chapter) { + $(chapter).addClass("current"); + chapter.scrollIntoView(); + } + } + }); + } + } +} diff --git a/app/assets/javascripts/thyme/components/add_item_button.js b/app/assets/javascripts/thyme/components/add_item_button.js new file mode 100644 index 000000000..9a3a0cf79 --- /dev/null +++ b/app/assets/javascripts/thyme/components/add_item_button.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line no-unused-vars +class AddItemButton extends Component { + add() { + const video = thymeAttributes.video; + + // Event listener for addItem button + this.element.addEventListener("click", function () { + video.pause(); + // round time down to three decimal digits + const time = video.currentTime; + const intTime = Math.floor(time); + const roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000; + video.currentTime = roundTime; + $.ajax(Routes.add_item_path(thymeAttributes.mediumId), { + type: "GET", + dataType: "script", + data: { + time: video.currentTime, + }, + }); + }); + } +} diff --git a/app/assets/javascripts/thyme/components/add_reference_button.js b/app/assets/javascripts/thyme/components/add_reference_button.js new file mode 100644 index 000000000..9d2fe7c32 --- /dev/null +++ b/app/assets/javascripts/thyme/components/add_reference_button.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line no-unused-vars +class AddReferenceButton extends Component { + add() { + const video = thymeAttributes.video; + + // Event listener for addItem button + this.element.addEventListener("click", function () { + video.pause(); + // round time down to three decimal digits + const time = video.currentTime; + const intTime = Math.floor(time); + const roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000; + video.currentTime = roundTime; + $.ajax(Routes.add_reference_path(thymeAttributes.mediumId), { + type: "GET", + dataType: "script", + data: { + time: video.currentTime, + }, + }); + }); + } +} diff --git a/app/assets/javascripts/thyme/components/add_screenshot_button.js b/app/assets/javascripts/thyme/components/add_screenshot_button.js new file mode 100644 index 000000000..dcf314100 --- /dev/null +++ b/app/assets/javascripts/thyme/components/add_screenshot_button.js @@ -0,0 +1,34 @@ +// eslint-disable-next-line no-unused-vars +class AddScreenshotButton extends Component { + constructor(element, canvasId) { + super(element); + this.canvas = document.getElementById(canvasId); + } + + add() { + const video = thymeAttributes.video; + const canvas = this.canvas; + + // Event listener for add screenshot button + this.element.addEventListener("click", function () { + video.pause(); + // extract video screenshot from canvas + const context = canvas.getContext("2d"); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + const base64image = canvas.toDataURL("image/png"); + // Get our file + const file = thymeUtility.dataURLtoBlob(base64image); + // Create new form data + const fd = new FormData(); + // Append our Canvas image file to the form data + fd.append("image", file); + // And send it + $.ajax(Routes.add_screenshot_path(thymeAttributes.mediumId), { + type: "POST", + data: fd, + processData: false, + contentType: false, + }); + }); + } +} diff --git a/app/assets/javascripts/thyme/components/annotation_button.js b/app/assets/javascripts/thyme/components/annotation_button.js new file mode 100644 index 000000000..e81664b43 --- /dev/null +++ b/app/assets/javascripts/thyme/components/annotation_button.js @@ -0,0 +1,20 @@ +// eslint-disable-next-line no-unused-vars +class AnnotationButton extends Component { + add() { + const video = thymeAttributes.video; + const element = this.element; + + // Event handler for the annotation button + element.addEventListener("click", function () { + video.pause(); + $.ajax(Routes.new_annotation_path(), { + type: "GET", + dataType: "script", + data: { + total_seconds: video.currentTime, + medium_id: thymeAttributes.mediumId, + }, + }); + }); + } +} diff --git a/app/assets/javascripts/thyme/components/annotation_category_toggle.js b/app/assets/javascripts/thyme/components/annotation_category_toggle.js new file mode 100644 index 000000000..2ee0c7cc9 --- /dev/null +++ b/app/assets/javascripts/thyme/components/annotation_category_toggle.js @@ -0,0 +1,51 @@ +// eslint-disable-next-line no-unused-vars +class AnnotationCategoryToggle extends Component { + /* + element = A reference on the HTML component (via document.getElementByID()). + category = The category which this toggle triggers. + heatmap = The heatmap that will be updated depending on the value of the toggle. + */ + constructor(category, heatmap) { + const id = AnnotationCategoryToggle.categoryToElementId(category); + super(id); + this.category = category; + this.heatmap = heatmap; + } + + static categoryToElementId(category) { + return `annotation-category-${category.name}-switch`; + } + + add() { + const heatmap = this.heatmap; + if (heatmap) { + heatmap.addCategory(this.category); // add category when adding the button + } + + const categoryToggle = this; + + this.element.addEventListener("click", function () { + thymeAttributes.annotationManager.updateAnnotations(); + if (!heatmap) { + return; + } + + if (categoryToggle.isChecked()) { + heatmap.addCategory(this.category); + } + else { + heatmap.removeCategory(this.category); + } + heatmap.draw(); + }); + } + + isChecked() { + return this.element.checked; + } + + static isChecked(category) { + const id = AnnotationCategoryToggle.categoryToElementId(category); + return document.getElementById(id).checked; + } +} diff --git a/app/assets/javascripts/thyme/components/annotations_toggle.js b/app/assets/javascripts/thyme/components/annotations_toggle.js new file mode 100644 index 000000000..0e179fdaa --- /dev/null +++ b/app/assets/javascripts/thyme/components/annotations_toggle.js @@ -0,0 +1,68 @@ +// eslint-disable-next-line no-unused-vars +class AnnotationsToggle extends Component { + constructor(element) { + super(element); + this.id = element; + this.check = document.getElementById(this.id + "-check"); + this.$check = $("#" + this.id + "-check"); + this.div = $("#" + this.id); + this.flag = false; + } + + add() { + if (this.flag || !thymeAttributes.annotationFeatureActive) { + return; + } + + this.flag = true; // <- only run the following part of the code once + const toggle = this; + + /* User is teacher/editor for the given medium and visible_for_teacher ist activated? + -> add toggle annotations button */ + $.ajax(Routes.check_annotation_visibility_path(thymeAttributes.mediumId), { + type: "GET", + dataType: "json", + success: function (isPermitted) { + if (!isPermitted) { + return; + } + for (const annotation of thymeAttributes.annotations) { + // Only show toggle if there is at least one foreign annotation + if (!annotation.belongsToCurrentUser) { + toggle.show(); + toggle.element.addEventListener("click", function () { + thymeAttributes.annotationManager.updateAnnotations(); + }); + // When loading the player, the toggle is set to "true" by default, + // so we have to trigger updateAnnotations() manually once. + thymeAttributes.annotationManager.updateAnnotations(); + } + } + }, + }); + } + + installListener() { + this.element.addEventListener("click", function () { + thymeAttributes.annotationManager.updateAnnotations(); + }); + } + + /* + Returns true if the toggle's value is true and false otherwise. + */ + getValue() { + return this.$check.is(":checked"); + } + + /* + Auxiliary method + */ + show() { + $("#volume-controls").css("left", "66%"); + $("#speed-control").css("left", "77%"); + $("#annotation-button").css("left", "86%"); + thymeAttributes.hideControlBarThreshold.x = 960; + this.div.show(); + } +} diff --git a/app/assets/javascripts/thyme/components/component.js b/app/assets/javascripts/thyme/components/component.js new file mode 100644 index 000000000..a0677949c --- /dev/null +++ b/app/assets/javascripts/thyme/components/component.js @@ -0,0 +1,17 @@ +/** + The basic component class from which every thyme related components (slider, selector, etc.) + should be a subclass. +*/ +// eslint-disable-next-line no-unused-vars +class Component { + /* + element = The id of the HTML element associated to this button. + */ + constructor(element) { + this.element = document.getElementById(element); + } + + /* This method should add the button functionality to the given player. + Override it in the given subclass! */ + add() { } +} diff --git a/app/assets/javascripts/thyme/components/full_screen_button.js b/app/assets/javascripts/thyme/components/full_screen_button.js new file mode 100644 index 000000000..d8991456d --- /dev/null +++ b/app/assets/javascripts/thyme/components/full_screen_button.js @@ -0,0 +1,69 @@ +// eslint-disable-next-line no-unused-vars +class FullScreenButton extends Component { + constructor(element, container) { + super(element); + this.container = container; + } + + add() { + const element = this.element; + const container = this.container; + const button = this; + + // Event listener for the full-screen button + // (unfortunately, lots of browser specific code). + element.addEventListener("click", function () { + if (element.dataset.status === "true") { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } + else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + } + else { + if (container.requestFullscreen) { + container.requestFullscreen(); + } + else if (container.mozRequestFullScreen) { + container.mozRequestFullScreen(); + } + else if (container.webkitRequestFullscreen) { + container.webkitRequestFullscreen(); + } + } + }); + + document.onfullscreenchange = function () { + button.#fullscreenChange(); + }; + + document.onwebkitfullscreenchange = function () { + button.#fullscreenChange(); + }; + + document.onmozfullscreenchange = function () { + button.#fullscreenChange(); + }; + } + + #fullscreenChange() { + if (document.fullscreenElement) { + // User enters fullscreen mode + this.element.innerHTML = "fullscreen_exit"; + this.element.dataset.status = "true"; + /* Set height to 100vh in fullscreen mode as it otherwise + is too large. */ + $(thymeAttributes.video).css("height", "100vh"); + } + else { + // User exists fullscreen mode + this.element.innerHTML = "fullscreen"; + this.element.dataset.status = "false"; + $(thymeAttributes.video).css("height", "100%"); + } + } +} diff --git a/app/assets/javascripts/thyme/components/ia_back_button.js b/app/assets/javascripts/thyme/components/ia_back_button.js new file mode 100644 index 000000000..44ef372b8 --- /dev/null +++ b/app/assets/javascripts/thyme/components/ia_back_button.js @@ -0,0 +1,39 @@ +/** + * The Interactive Area Back Button saves a reference on the + * current time. If one clicks on a chapter field in the + * interactive area (which sets the current time to the start + * of the chapter), one has the possibility to go back by + * clicking this button. + */ +// eslint-disable-next-line no-unused-vars +class IaBackButton extends Component { + constructor(element, chapterListId) { + super(element); + this.chapterListId = chapterListId; + } + + add() { + // Event Handler for Back Button + this.element.addEventListener("click", function () { + video.currentTime = this.dataset.time; + $(this).hide(); + }); + } + + update() { + // set up back button (transports back to the current chapter) + this.element.dataset.time = video.currentTime; + const currentChapter = $("#" + this.chapterListId + " .current"); + if (currentChapter.length > 0) { + let backInfo = currentChapter.data("text").split(":", 1)[0]; + if (backInfo && backInfo.length > 20) { + backInfo = this.element.dataset.back; + } + else { + backInfo = this.element.dataset.backto + backInfo; + } + $(this.element).empty().append(backInfo).show(); + thymeUtility.renderLatex(this.element); + } + } +} diff --git a/app/assets/javascripts/thyme/components/ia_button.js b/app/assets/javascripts/thyme/components/ia_button.js new file mode 100644 index 000000000..a848e8d4c --- /dev/null +++ b/app/assets/javascripts/thyme/components/ia_button.js @@ -0,0 +1,76 @@ +/** + * The Interactive Area Button can show/hide specific elements of the + * thyme player (normally interactive and annotation area) and + * adjust the video position/size accordingly. + */ +// eslint-disable-next-line no-unused-vars +class IaButton extends Component { + /* + toHide = An array consisting of all the components that + should be hidden/shown when this button is clicked. + These components must provide a show() and hide() + method, but they havn't to be a JQuery reference + on a HTML element. + + toShrink = An array consisting of JQuery references of all + the components that should grow/shrink when this + button is clicked. + + shrink = The percentage telling how much the elements of toShrink + should shrink when the components of toHide are shown. + */ + constructor(element, toHide, toShrink, shrink) { + super(element); + this.toHide = toHide; + this.toShrink = toShrink; + this.shrink = shrink; + } + + add() { + const element = this.element; + const button = this; + + element.addEventListener("click", function () { + if (element.dataset.status === "true") { + button.plus(); + } + else { + button.minus(); + } + }); + } + + /* + Sets the button to its plus value, i.e. shows all + toHide elements and shrinks all toShrink elements. + */ + plus() { + this.#aux("false", "remove_from_queue", false, "100%"); + thymeAttributes.annotationArea.unmarkCurrentAnnotationAsShown(); + } + + /* + Sets the button to its minus value, i.e. hides all + toHide elements and enlarges all toShrink elements. + */ + minus() { + this.#aux("true", "add_to_queue", true, this.shrink); + } + + getStatus() { + return this.element.dataset.status === "true"; + } + + #aux(status, innerHTML, sh, size) { + this.element.dataset.status = status; + this.element.innerHTML = innerHTML; + for (let e of this.toHide) { + sh ? e.show() : e.hide(); + } + for (let e of this.toShrink) { + e.css("width", size); + } + $(window).trigger("resize"); + thymeAttributes.annotationManager.updateMarkers(); + } +} diff --git a/app/assets/javascripts/thyme/components/ia_close_button.js b/app/assets/javascripts/thyme/components/ia_close_button.js new file mode 100644 index 000000000..b2ae8e3cb --- /dev/null +++ b/app/assets/javascripts/thyme/components/ia_close_button.js @@ -0,0 +1,18 @@ +/** + * The Interactive Area button gives a shortcut + * for the minus event of an IaButton. + */ +// eslint-disable-next-line no-unused-vars +class IaCloseButton extends Component { + constructor(element, iaButton) { + super(element); + this.iaButton = iaButton; + } + + add() { + const iaButton = this.iaButton; + this.element.addEventListener("click", function () { + iaButton.plus(); + }); + } +} diff --git a/app/assets/javascripts/thyme/components/mute_button.js b/app/assets/javascripts/thyme/components/mute_button.js new file mode 100644 index 000000000..5a662b21a --- /dev/null +++ b/app/assets/javascripts/thyme/components/mute_button.js @@ -0,0 +1,18 @@ +// eslint-disable-next-line no-unused-vars +class MuteButton extends Component { + add() { + const video = thymeAttributes.video; + const element = this.element; + + element.addEventListener("click", function () { + if (video.muted) { + video.muted = true; + element.innerHTML = "volume_off"; + } + else { + video.muted = false; + element.innerHTML = "volume_up"; + } + }); + } +} diff --git a/app/assets/javascripts/thyme/components/next_chapter_button.js b/app/assets/javascripts/thyme/components/next_chapter_button.js new file mode 100644 index 000000000..011ae8127 --- /dev/null +++ b/app/assets/javascripts/thyme/components/next_chapter_button.js @@ -0,0 +1,15 @@ +// eslint-disable-next-line no-unused-vars +class NextChapterButton extends Component { + add() { + const video = thymeAttributes.video; + const element = this.element; + + // Event handler for the nextChapter button + element.addEventListener("click", function () { + const next = thymeAttributes.chapterManager.nextChapterStart(); + if (next) { + video.currentTime = thymeAttributes.chapterManager.nextChapterStart(); + } + }); + } +} diff --git a/app/assets/javascripts/thyme/components/play_button.js b/app/assets/javascripts/thyme/components/play_button.js new file mode 100644 index 000000000..8e8f1c6d7 --- /dev/null +++ b/app/assets/javascripts/thyme/components/play_button.js @@ -0,0 +1,24 @@ +// eslint-disable-next-line no-unused-vars +class PlayButton extends Component { + add() { + const video = thymeAttributes.video; + const element = this.element; + + element.addEventListener("click", function () { + if (video.paused) { + video.play(); + } + else { + video.pause(); + } + }); + + video.onplay = function () { + element.innerHTML = "pause"; + }; + + video.onpause = function () { + element.innerHTML = "play_arrow"; + }; + } +} diff --git a/app/assets/javascripts/thyme/components/previous_chapter_button.js b/app/assets/javascripts/thyme/components/previous_chapter_button.js new file mode 100644 index 000000000..3a2d277a0 --- /dev/null +++ b/app/assets/javascripts/thyme/components/previous_chapter_button.js @@ -0,0 +1,15 @@ +// eslint-disable-next-line no-unused-vars +class PreviousChapterButton extends Component { + add() { + const video = thymeAttributes.video; + const element = this.element; + + // Event handler for the previousChapter button + element.addEventListener("click", function () { + const previous = thymeAttributes.chapterManager.previousChapterStart(); + if (previous) { + video.currentTime = thymeAttributes.chapterManager.previousChapterStart(); + } + }); + } +} diff --git a/app/assets/javascripts/thyme/components/seek_bar.js b/app/assets/javascripts/thyme/components/seek_bar.js new file mode 100644 index 000000000..805d632b1 --- /dev/null +++ b/app/assets/javascripts/thyme/components/seek_bar.js @@ -0,0 +1,70 @@ +// eslint-disable-next-line no-unused-vars +class SeekBar extends Component { + constructor(element) { + super(element); + thymeAttributes.seekBar = this; // save a reference for this seek bar + } + + add() { + const video = thymeAttributes.video; + const element = this.element; + + // Event listeners for the seek bar + element.addEventListener("input", function () { + const time = video.duration * element.value / 100; + video.currentTime = time; + }); + + // if videomedtadata have been loaded, set up seek bar + video.addEventListener("loadedmetadata", function () { + if (video.dataset.time) { + element.value = video.currentTime / video.duration * 100; + } + else { + element.value = 0; + } + }); + + // Update the seek bar as the video plays. + // Uses a gradient for seekbar video time visualization. + video.addEventListener("timeupdate", function () { + const value = 100 / video.duration * video.currentTime; + element.value = value; + element.style.backgroundImage = "linear-gradient(to right," + + " #2497E3, #2497E3 " + + value + + "%, #ffffff " + + value + + "%, #ffffff)"; + const currentTime = document.getElementById("current-time"); + currentTime.innerHTML = thymeUtility.secondsToTime(video.currentTime); + }); + + // Pause the video when the seek handle is being dragged + element.addEventListener("mousedown", function () { + video.dataset.paused = video.paused; + video.pause(); + }); + + // Play the video when the seek handle is dropped + element.addEventListener("mouseup", function () { + if (video.dataset.paused !== "true") { + video.play(); + } + }); + } + + /* + If mouse is moved over seek bar, display tooltip with current chapter + (only use this if the given thyme player provides chapters!). + */ + addChapterTooltips() { + const element = this.element; + + element.addEventListener("mousemove", function (_evt) { + const previous = thymeAttributes.chapterManager.previousChapterStart(); + const info = $("#c-" + $.escapeSelector(previous)).text().split(":")[0]; + element.setAttribute("title", info); + }); + } +} diff --git a/app/assets/javascripts/thyme/components/speed_selector.js b/app/assets/javascripts/thyme/components/speed_selector.js new file mode 100644 index 000000000..f427b7d0c --- /dev/null +++ b/app/assets/javascripts/thyme/components/speed_selector.js @@ -0,0 +1,26 @@ +// eslint-disable-next-line no-unused-vars +class SpeedSelector extends Component { + constructor(element) { + super(element); + } + + /* This method should add the button functionality to the given player. + Override it in the given subclass! */ + add() { + const video = thymeAttributes.video; + const element = this.element; + + element.addEventListener("click", function () { + if (video.preservesPitch) { + video.preservesPitch = true; + } + else if (video.mozPreservesPitch) { + video.mozPreservesPitch = true; + } + else if (video.webkitPreservesPitch) { + video.webkitPreservesPitch = true; + } + video.playbackRate = this.options[this.selectedIndex].value; + }); + } +} diff --git a/app/assets/javascripts/thyme/components/time_button.js b/app/assets/javascripts/thyme/components/time_button.js new file mode 100644 index 000000000..c5c26af7a --- /dev/null +++ b/app/assets/javascripts/thyme/components/time_button.js @@ -0,0 +1,24 @@ +// eslint-disable-next-line no-unused-vars +class TimeButton extends Component { + /* + time = The time to add in seconds. + */ + constructor(element, time) { + super(element); + this.time = time; + } + + add() { + const video = thymeAttributes.video; + const time = this.time; + + this.element.addEventListener("click", function () { + if (time >= 0) { + video.currentTime = Math.min(video.currentTime + time, video.duration); + } + else { + video.currentTime = Math.max(video.currentTime + time, 0); + } + }); + } +} diff --git a/app/assets/javascripts/thyme/components/volume_bar.js b/app/assets/javascripts/thyme/components/volume_bar.js new file mode 100644 index 000000000..b1db66fc2 --- /dev/null +++ b/app/assets/javascripts/thyme/components/volume_bar.js @@ -0,0 +1,33 @@ +// eslint-disable-next-line no-unused-vars +class VolumeBar extends Component { + add() { + const video = thymeAttributes.video; + const element = this.element; + + // Event listener for the volume bar + element.addEventListener("input", function () { + video.volume = element.value; + }); + + video.addEventListener("loadedmetadata", function () { + element.value = video.volume; + element.style.backgroundImage = "linear-gradient(to right," + + " #2497E3, #2497E3 " + + video.volume * 100 + + "%, #ffffff " + + video.volume * 100 + + "%, #ffffff)"; + }); + + video.addEventListener("volumechange", function () { + const value = video.volume; + element.value = value; + element.style.backgroundImage = "linear-gradient(to right," + + " #2497E3, #2497E3 " + + value * 100 + + "%, #ffffff " + + value * 100 + + "%, #ffffff)"; + }); + } +} diff --git a/app/assets/javascripts/thyme/control_bar_hider.js b/app/assets/javascripts/thyme/control_bar_hider.js new file mode 100644 index 000000000..dc98346f3 --- /dev/null +++ b/app/assets/javascripts/thyme/control_bar_hider.js @@ -0,0 +1,74 @@ +/** + * This class contains the functionality for (auto-)hiding the control bar. + */ +// eslint-disable-next-line no-unused-vars +class ControlBarHider { + /* + controlBarId = The ID of the control bar. + delay = The delay after which the control bar is automatically hidden. + */ + constructor(controlBarId, delay) { + this.controlBarId = controlBarId; + this.delay = delay; + this.hideBlocker = false; // helper attribute + } + + /* + Installs the control bar hider, i.e. after calling this method it will + start working. + */ + install() { + const controlBarHider = this; + const controlBar = document.getElementById(this.controlBarId); + const video = thymeAttributes.video; + + // show control bar when mouse is moved, etc. + function show() { + controlBarHider.showControlBar(); // <- need this extra function for reference + } + /* NOTE: Why do we need the mouseover listener? To trigger it, the mouse + has to be moved, i.e. the second event listener is triggered. */ + video.addEventListener("mouseover", show); + video.addEventListener("mousemove", show); + video.addEventListener("touchstart", show); + video.addEventListener("click", show); + + // block hiding if curser is over the control bar + controlBar.addEventListener("mouseover", function () { + controlBarHider.hideBlocker = true; + }); + controlBar.addEventListener("mouseleave", function () { + controlBarHider.hideBlocker = false; + }); + + // auto hide control bar + let t = void 0; + function resetTimer() { + clearTimeout(t); + t = setTimeout(function () { + if (controlBarHider.hideBlocker) { + return; + } + controlBarHider.hideControlBar(); + }, controlBarHider.delay); + } + window.onload = resetTimer; + window.onmousemove = resetTimer; + window.onmousedown = resetTimer; + window.ontouchstart = resetTimer; + window.onclick = resetTimer; + } + + /* + AUXILIARY METHODS + */ + showControlBar() { + $("#" + this.controlBarId).css("visibility", "visible"); + $(thymeAttributes.video).css("cursor", ""); + } + + hideControlBar() { + $("#" + this.controlBarId).css("visibility", "hidden"); + $(thymeAttributes.video).css("cursor", "none"); + } +} diff --git a/app/assets/javascripts/thyme/display_manager.js b/app/assets/javascripts/thyme/display_manager.js new file mode 100644 index 000000000..d28eca7de --- /dev/null +++ b/app/assets/javascripts/thyme/display_manager.js @@ -0,0 +1,60 @@ +/** + * A DisplayManager helps to switch between the full thyme player and + * the native HTML player shown on small devices. + */ +// eslint-disable-next-line no-unused-vars +class DisplayManager { + constructor(elements, onEnlarge) { + /* + elements = An array containing JQuery references on the HTML elements + that should be hidden, when the display is too small. + + onEnlarge = A reference to a function that is called when the display + changes from small to large. Use this for player specific behavior. + */ + this.elements = elements; + this.onEnlarge = onEnlarge; + } + + // on small display, fall back to standard browser player + adaptToSmallDisplay() { + for (let e of this.elements) { + e.hide(); + } + thymeAttributes.video.style.width = "100%"; + thymeAttributes.video.controls = true; + } + + // on large display, use anything thyme has to offer, disable native player + adaptToLargeDisplay() { + thymeAttributes.video.controls = false; + for (let e of this.elements) { + e.show(); + } + this.onEnlarge(); + } + + // Check screen size and trigger the right method + updateControlBarType() { + const manager = this; + + const matchSmallMediaQuery = window.matchMedia(` + screen and ( + (max-width: ${thymeAttributes.hideControlBarThreshold.x}px) + or (max-height: ${thymeAttributes.hideControlBarThreshold.y}px) + ) + `); + + function handleSizeChange(event) { + if (event.matches) { + manager.adaptToSmallDisplay(); + } + else { + manager.adaptToLargeDisplay(); + } + } + + matchSmallMediaQuery.addListener(handleSizeChange); + handleSizeChange(matchSmallMediaQuery); // initial call + } +} diff --git a/app/assets/javascripts/thyme/heatmap.js b/app/assets/javascripts/thyme/heatmap.js new file mode 100644 index 000000000..463669ffb --- /dev/null +++ b/app/assets/javascripts/thyme/heatmap.js @@ -0,0 +1,122 @@ +/** + * Objects of this class represent heatmaps. It provides the function draw() which + * draws the heatmap to the thyme player. + */ +// eslint-disable-next-line no-unused-vars +class Heatmap { + static RADIUS = 10; // this number adjusts the radius of the peaks of the heatmap + static MAX_HEIGHT = 0.25; // this number adjusts the maximum heights of the heatmap peaks + + /* + * id = The ID of the HTML element to which the heatmap will be appended. + */ + constructor(id) { + this.heatmap = $("#" + id); + this.categories = []; + } + + draw() { + if (!thymeAttributes.annotations) { + return; + } + this.heatmap.empty(); + + /* + variable definitions + */ + // We assume a slightly bigger width to be able to display sine waves + // also at the beginning and the end of the timeline. + const stickOutWidthOneSided = Heatmap.RADIUS; + const thresh = 20; // a small additional width to avoid the heatmap to be cut off + const seekBarWidth = thymeAttributes.seekBar.element.clientWidth; + const width = seekBarWidth + 2 * stickOutWidthOneSided + thresh; + const stretch = seekBarWidth / (seekBarWidth + 4 * stickOutWidthOneSided + thresh); + + const maxHeight = video.clientHeight * Heatmap.MAX_HEIGHT; + this.heatmap.css("top", -maxHeight - 11); // vertical offset + + const numDivisons = width + 4 * Heatmap.RADIUS + 1; + /* An array for each pixel on the timeline. The indices of this array should be thought + of the x-axis of the heatmap's graph, while its entries should be thought of its + values on the y-axis. */ + let pixels = new Array(numDivisons).fill(0); + /* amplitude should be calculated with respect to all annotations + (even those which are not shown). Otherwise the peaks increase + when turning off certain annotations because the graph has to be + normed. Therefore we need this additional "pixelsAll" array. */ + let pixelsAll = new Array(numDivisons).fill(0); + /* for any visible annotation, this array contains its color (needed for the calculation + of the heatmap color) */ + let colors = []; + + /* + data calculation + */ + for (const a of thymeAttributes.annotations) { + const valid = this.#isValidCategory(a.category) + && AnnotationCategoryToggle.isChecked(a.category); + + if (valid) { + colors.push(a.category.color); + } + const time = a.seconds; + const position = Math.round(stretch * width * (time / video.duration)); + for (let x = position - Heatmap.RADIUS; x <= position + Heatmap.RADIUS; x++) { + let y = Heatmap.#sinX(x, position, Heatmap.RADIUS); + pixelsAll[x + Heatmap.RADIUS] += y; + if (valid) { + pixels[x + Heatmap.RADIUS] += y; + } + } + } + const maxValue = Math.max(...pixelsAll); + const amplitude = maxValue != 0 ? maxHeight * (1 / maxValue) : 0; + + /* + Construct heatmap SVG + */ + let pointsStr = `0,${maxHeight} `; + for (let x = 0; x < pixels.length; x++) { + pointsStr += `${x},${maxHeight - amplitude * pixels[x]} `; + } + pointsStr += `${width},${maxHeight}`; + + const heatmapStr = ` + + `; + this.heatmap.append(heatmapStr); + } + + addCategory(category) { + if (this.categories.includes(category)) { + return; + } + this.categories.push(category); + } + + removeCategory(category) { + this.categories = this.categories.filter(c => c !== category); + } + + /* + AUXILIARY METHODS + */ + + #isValidCategory(category) { + return this.categories.includes(category); + } + + /* A modified sine function for building nice peaks around the marker positions. + + x = insert value + position = the position of the maximum value + */ + static #sinX(x, position) { + return (1 + Math.sin(Math.PI / Heatmap.RADIUS * (x - position) + Math.PI / 2)) / 2; + } +} diff --git a/app/assets/javascripts/thyme/key_shortcuts.js b/app/assets/javascripts/thyme/key_shortcuts.js new file mode 100644 index 000000000..c30b3fb45 --- /dev/null +++ b/app/assets/javascripts/thyme/key_shortcuts.js @@ -0,0 +1,135 @@ +/** + All key shortcuts should be bundled here. +*/ +// eslint-disable-next-line no-unused-vars +const thymeKeyShortcuts = { + /* + SHORTCUT LIST: + Arrow right - plus ten seconds + Arrow left - minus ten seconds + f - fullscreen + Page up - volume up + Page down - volume down + m - mute + */ + addGeneralShortcuts: function () { + const video = document.getElementById("video"); + + window.addEventListener("keydown", function (evt) { + if (thymeAttributes.lockKeyListeners) { + return; + } + const key = evt.key; + if (key === " ") { + if (video.paused) { + video.play(); + } + else { + video.pause(); + } + } + else if (key === "ArrowRight") { + $("#plus-ten").trigger("click"); + } + else if (key === "ArrowLeft") { + $("#minus-ten").trigger("click"); + } + else if (key === "f") { + $("#full-screen").trigger("click"); + } + else if (key === "m") { + $("#mute").trigger("click"); + } + else if (key === "PageUp") { + video.volume = Math.min(video.volume + 0.1, 1); + } + else if (key === "PageDown") { + video.volume = Math.max(video.volume - 0.1, 0); + } + }); + }, + + /* + Thyme player specific + + SHORTCUT LIST: + Arrow Up - next chapter + Arrow Down - previous chapter + i - toggle interactive area + */ + addPlayerShortcuts() { + window.addEventListener("keydown", function (evt) { + if (thymeAttributes.lockKeyListeners) { + return; + } + const key = evt.key; + if (key === "i") { + $("#ia-active").trigger("click"); + } + else if (key === "ArrowUp") { + $("#next-chapter").trigger("click"); + } + else if (key === "ArrowDown") { + $("#previous-chapter").trigger("click"); + } + + // annotation-related shortcuts + if (thymeAttributes.disableAnnotationKeyListeners) { + return; + } + else if (key === "a") { + $("#annotation-previous-button").trigger("click"); + } + else if (key === "s") { + $("#annotation-goto-button").trigger("click"); + } + else if (key === "d") { + $("#annotation-next-button").trigger("click"); + } + }); + }, + + /* + Thyme feedback specific + + SHORTCUT LIST: + q - toggle mistake annotations + w - toggle presentation annotations + e - toggle content annotations + r - toggle note annotations + */ + addFeedbackShortcuts() { + window.addEventListener("keydown", function (evt) { + if (thymeAttributes.lockKeyListeners || thymeAttributes.disableAnnotationKeyListeners) { + return; + } + const key = evt.key; + if (key === "q") { + $("#annotation-category-mistake-switch").trigger("click"); + } + else if (key === "w") { + $("#annotation-category-content-switch").trigger("click"); + } + else if (key === "e") { + $("#annotation-category-presentation-switch").trigger("click"); + } + else if (key === "r") { + $("#annotation-category-note-switch").trigger("click"); + } + else if (key === "a") { + $("#annotation-previous-button").trigger("click"); + } + else if (key === "s") { + $("#annotation-goto-button").trigger("click"); + } + else if (key === "d") { + $("#annotation-next-button").trigger("click"); + } + }); + }, + + /* + Thyme editor specific + */ + // Add editor specific keyboard shortcuts here. +}; diff --git a/app/assets/javascripts/thyme/metadata_manager.js b/app/assets/javascripts/thyme/metadata_manager.js new file mode 100644 index 000000000..760e58bb4 --- /dev/null +++ b/app/assets/javascripts/thyme/metadata_manager.js @@ -0,0 +1,219 @@ +/** + This file wraps up most functionality of the thyme player(s) concerning metadata. +*/ +// eslint-disable-next-line no-unused-vars +class MetadataManager { + constructor(metadataListId) { + this.metadataListId = metadataListId; + } + + load() { + let initialMetadata = true; + const videoId = thymeAttributes.video.id; + const metadataElement = $("#" + videoId + ' track[kind="metadata"]').get(0); + const metadataManager = this; + + /* after video metadata have been loaded, display chapters in the interactive area + Originally (and more appropriately, according to the standards), + only the 'loadedmetadata' event was used. However, Firefox triggers this event too soon, + i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded) + for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp */ + video.addEventListener("loadedmetadata", function () { + if (initialMetadata && metadataElement.readyState === 2) { + metadataManager.#displayMetadata(); + initialMetadata = false; + } + }); + video.addEventListener("canplay", function () { + if (initialMetadata && metadataElement.readyState === 2) { + metadataManager.#displayMetadata(); + initialMetadata = false; + } + }); + } + + /* returns the jQuery object of all metadata elements that start before the + given time in seconds */ + #metadataBefore(seconds) { + return $('[id^="m-"]').not(this.#metadataAfter(seconds)); + } + + /* returns the jQuery object of all metadata elements that start after the + given time in seconds */ + #metadataAfter(seconds) { + const metaList = document.getElementById(this.metadataListId); + const times = JSON.parse(metaList.dataset.times); + if (times.length === 0) { + return $(); + } + for (let i = 0; i < times.length; i++) { + if (times[i] > seconds) { + const $nextMeta = $("#m-" + $.escapeSelector(times[i])); + return $nextMeta.add($nextMeta.nextAll()); + } + } + return $(); + } + + /* for a given time, show all metadata elements that start before this time + and hide all that start later */ + metaIntoView(time) { + this.#metadataAfter(time).hide(); + const $before = this.#metadataBefore(time); + $before.show(); + const previousLength = $before.length; + if (previousLength > 0) { + $before.get(previousLength - 1).scrollIntoView(); + } + } + + // set up the metadata elements + #displayMetadata() { + const video = thymeAttributes.video; + const metadataManager = this; + const metadataListId = this.metadataListId; + const $metaList = $("#" + metadataListId); + const metadataElement = $("#" + video.id + ' track[kind="metadata"]').get(0); + + let metaTrack; + if (metadataElement.readyState === 2 && (metaTrack = metadataElement.track)) { + metaTrack.mode = "hidden"; + let times = []; + // read out the metadata track cues and generate html elements for + // metadata, run katex on them + for (let i = 0; i < metaTrack.cues.length; i++) { + const cue = metaTrack.cues[i]; + const meta = JSON.parse(cue.text); + const start = cue.startTime; + times.push(start); + const $listItem = $("
  • ", { + id: "m-" + start, + }); + $listItem.hide(); + const $link = $("", { + text: meta.reference, + class: "item", + id: "l-" + start, + }); + const $videoIcon = $("", { + text: "video_library", + class: "material-icons", + }); + const $videoRef = $("", { + href: meta.video, + target: "_blank", + }); + $videoRef.append($videoIcon); + if (!meta.video) { + $videoRef.hide(); + } + const $manIcon = $("", { + text: "library_books", + class: "material-icons", + }); + const $manRef = $("", { + href: meta.manuscript, + target: "_blank", + }); + $manRef.append($manIcon); + if (!meta.manuscript) { + $manRef.hide(); + } + const $scriptIcon = $("", { + text: "menu_book", + class: "material-icons", + }); + const $scriptRef = $("", { + href: meta.script, + target: "_blank", + }); + $scriptRef.append($scriptIcon); + if (!meta.script) { + $scriptRef.hide(); + } + const $quizIcon = $("", { + text: "videogame_asset", + class: "material-icons", + }); + const $quizRef = $("", { + href: meta.quiz, + target: "_blank", + }); + $quizRef.append($quizIcon); + if (!meta.quiz) { + $quizRef.hide(); + } + const $extIcon = $("", { + text: "link", + class: "material-icons", + }); + const $extRef = $("", { + href: meta.link, + target: "_blank", + }); + $extRef.append($extIcon); + if (!meta.link) { + $extRef.hide(); + } + const $description = $("
    ", { + text: meta.text, + class: "mx-3", + }); + const $explanation = $("
    ", { + text: meta.explanation, + class: "m-3", + }); + const $details = $("
    "); + $details.append($link).append($description).append($explanation); + let $icons = $("
    ", { + style: "flex-shrink: 3; display: flex; flex-direction: column;", + }); + $icons.append($videoRef).append($manRef).append($scriptRef).append($quizRef).append($extRef); + $listItem.append($details).append($icons); + $metaList.append($listItem); + $videoRef.on("click", function () { + video.pause(); + }); + $manRef.on("click", function () { + video.pause(); + }); + $extRef.on("click", function () { + video.pause(); + }); + $link.on("click", function () { + // displayBackButton(); + video.currentTime = this.id.replace("l-", ""); + }); + let metaElement = $listItem.get(0); + thymeUtility.renderLatex(metaElement); + } + // store metadata start times as data attribute + $metaList.get(0).dataset.times = JSON.stringify(times); + // if user jumps to a new position in the video, display all metadata + // that start before this time and hide all that start later + $(video).on("seeked", function () { + const time = video.currentTime; + metadataManager.metaIntoView(time); + }); + // if the metadata cue changes, highlight all current media and scroll + // them into view + $(metaTrack).on("cuechange", function () { + let j = 0; + $("#" + metadataListId + " li").removeClass("current"); + while (j < this.activeCues.length) { + const activeStart = this.activeCues[j].startTime; + let metalink = document.getElementById("m-" + activeStart); + if (metalink) { + $(metalink).show(); + $(metalink).addClass("current"); + } + ++j; + } + const currentLength = $("#" + metadataListId + " .current").length; + if (currentLength > 0) { + $("#" + metadataListId + " .current").get(length - 1).scrollIntoView(); + } + }); + } + } +} diff --git a/app/assets/javascripts/thyme/resizer.js b/app/assets/javascripts/thyme/resizer.js new file mode 100644 index 000000000..7c51e3291 --- /dev/null +++ b/app/assets/javascripts/thyme/resizer.js @@ -0,0 +1,32 @@ +/** + Use the method here to resize thyme players. +*/ +// eslint-disable-next-line no-unused-vars +const Resizer = { + resizeContainer: function (container, factor, offset) { + // see https://stackoverflow.com/a/73425736/ + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + const video = document.getElementById("video"); + const $container = $(container); + + let height = windowHeight; + const vWidth = video.videoWidth; + const vHeight = video.videoHeight; + let width = Math.floor((vWidth * windowHeight / vHeight) * factor) - offset; + if (width > windowWidth) { + const shrink = windowWidth / width; + height = Math.floor(height * shrink); + width = windowWidth; + } + + const top = Math.floor(0.5 * (windowHeight - height)); + const left = Math.floor(0.5 * (windowWidth - width)); + + $container.css("height", height + "px"); + $container.css("width", width + "px"); + $container.css("top", top + "px"); + $container.css("left", left + "px"); + }, +}; diff --git a/app/assets/javascripts/thyme/thyme_editor.js b/app/assets/javascripts/thyme/thyme_editor.js new file mode 100644 index 000000000..a86f1c371 --- /dev/null +++ b/app/assets/javascripts/thyme/thyme_editor.js @@ -0,0 +1,36 @@ +$(document).on("turbolinks:load", function () { + /* + VIDEO INITIALIZATION + */ + // exit script if the current page has no thyme player + const thymeEdit = document.getElementById("thyme-edit"); + if (!thymeEdit) { + return; + } + // initialize attributes + const video = document.getElementById("video-edit"); + thymeAttributes.video = video; + thymeAttributes.mediumId = thymeEdit.dataset.medium; + + /* + COMPONENTS + */ + (new PlayButton("play-pause")).add(); + (new MuteButton("mute")).add(); + + (new TimeButton("plus-ten", 10)).add(); + (new TimeButton("plus-five", 5)).add(); + (new TimeButton("plus-one", 1)).add(); + (new TimeButton("minus-ten", -10)).add(); + (new TimeButton("minus-five", -5)).add(); + (new TimeButton("minus-one", -1)).add(); + + (new SeekBar("seek-bar")).add(); + (new VolumeBar("volume-bar")).add(); + + (new AddItemButton("add-item")).add(); + (new AddReferenceButton("add-reference")).add(); + (new AddScreenshotButton("add-screenshot", "snapshot")).add(); + + thymeUtility.setUpMaxTime("max-time"); +}); diff --git a/app/assets/javascripts/thyme/thyme_feedback.js b/app/assets/javascripts/thyme/thyme_feedback.js new file mode 100644 index 000000000..1749b3be0 --- /dev/null +++ b/app/assets/javascripts/thyme/thyme_feedback.js @@ -0,0 +1,109 @@ +$(document).on("turbolinks:load", function () { + /* + VIDEO INITIALIZATION + */ + // exit script if the current page has no thyme player + const thymeContainer = document.getElementById("thyme-feedback-container"); + if (!thymeContainer) { + return; + } + + // background color + document.body.style.backgroundColor = "black"; + + // initialize attributes + const video = document.getElementById("video"); + thymeAttributes.video = video; + thymeAttributes.mediumId = document.getElementById("thyme-feedback").dataset.medium; + thymeAttributes.markerBarId = "feedback-markers"; + + /* + COMPONENTS + */ + // Buttons + (new MuteButton("mute")).add(); + (new PlayButton("play-pause")).add(); + (new TimeButton("plus-ten", 10)).add(); + (new TimeButton("minus-ten", -10)).add(); + // Sliders + (new VolumeBar("volume-bar")).add(); + seekBar = new SeekBar("seek-bar"); + seekBar.add(); + + // heatmap + const heatmap = new Heatmap("heatmap"); + + // category toggles + const allCategories = Category.all(); + const annotationCategoryToggles = new Array(allCategories.length); + let category; + + for (let i = 0; i < allCategories.length; i++) { + category = allCategories[i]; + annotationCategoryToggles[i] = new AnnotationCategoryToggle(category, heatmap); + annotationCategoryToggles[i].add(); + } + + /* + ANNOTATION FUNCTIONALITY + */ + function colorFunc(annotation) { + return annotation.category.color; + } + + function isValid(annotation) { + for (let toggle of annotationCategoryToggles) { + if (annotation.category === toggle.category && toggle.isChecked()) { + return true; + } + } + return false; + } + + const annotationArea = new AnnotationArea(false, colorFunc, null, isValid); + thymeAttributes.annotationArea = annotationArea; + + function strokeColorFunc(annotation) { + return annotation.category === Category.MISTAKE ? "darkred" : "black"; + } + + function sizeFunc(annotation) { + return annotation.category === Category.MISTAKE; + } + + function onClick(annotation) { + annotationArea.update(annotation); + } + + function onUpdate() { + heatmap.draw(); + } + + const annotationManager = new AnnotationManager(colorFunc, strokeColorFunc, sizeFunc, + onClick, onUpdate, isValid); + thymeAttributes.annotationManager = annotationManager; + thymeAttributes.annotationFeatureActive = true; + + /* + KEYBOARD SHORTCUTS + */ + thymeKeyShortcuts.addGeneralShortcuts(); + thymeKeyShortcuts.addFeedbackShortcuts(); + + /* + MISC + */ + thymeUtility.playOnClick(); + thymeUtility.setUpMaxTime("max-time"); + + // resizes the thyme container to the window dimensions + function resizeContainer() { + Resizer.resizeContainer(thymeContainer, 1 / 0.82, 0); + annotationManager.updateMarkers(); + } + + window.onresize = resizeContainer; + video.onloadedmetadata = resizeContainer; + + $("#video").width("82%"); +}); diff --git a/app/assets/javascripts/thyme/thyme_player.js b/app/assets/javascripts/thyme/thyme_player.js new file mode 100644 index 000000000..c8afaf225 --- /dev/null +++ b/app/assets/javascripts/thyme/thyme_player.js @@ -0,0 +1,185 @@ +$(document).on("turbolinks:load", function () { + /* + VIDEO INITIALIZATION + */ + // exit script if the current page has no thyme player + const thymeContainer = document.getElementById("thyme-container"); + if (!thymeContainer) { + return; + } + + // background color + document.body.style.backgroundColor = "black"; + + // initialize attributes + const thyme = document.getElementById("thyme"); + const video = document.getElementById("video"); + thymeAttributes.video = video; + thymeAttributes.mediumId = thyme.dataset.medium; + thymeAttributes.markerBarId = "markers"; + + /* + COMPONENTS + */ + // annotation components + const annotationFeatureActive = (document.getElementById("annotation-button") != null); + thymeAttributes.annotationFeatureActive = annotationFeatureActive; + if (annotationFeatureActive) { + (new AnnotationButton("annotation-button")).add(); + } + const annotationsToggle = new AnnotationsToggle("annotations-toggle"); + + // regular components + (new FullScreenButton("full-screen", thymeContainer)).add(); + (new MuteButton("mute")).add(); + (new NextChapterButton("next-chapter")).add(); + (new PlayButton("play-pause")).add(); + (new PreviousChapterButton("previous-chapter")).add(); + (new SpeedSelector("speed")).add(); + (new TimeButton("plus-ten", 10)).add(); + (new TimeButton("minus-ten", -10)).add(); + // initialize iaButton here to have the reference but call add() later + // when we can define toHide (second argument which is set to null here) + const iaButton = new IaButton("ia-active", null, [$(video), $("#video-controlBar")], "82%"); + (new VolumeBar("volume-bar")).add(); + seekBar = new SeekBar("seek-bar"); + seekBar.add(); + seekBar.addChapterTooltips(); + + /* + ANNOTATION FUNCTIONALITY + */ + // annotation manager and area + function colorFunc(annotation) { + return annotation.color; + } + + function onClose() { + iaButton.minus(); + } + + function isValid(annotation) { + return (!annotationsToggle.getValue() && !annotation.belongsToCurrentUser ? false : true); + } + + const annotationArea = new AnnotationArea(true, colorFunc, onClose, isValid); + thymeAttributes.annotationArea = annotationArea; + + function strokeColorFunc(_annotation) { + return "black"; + } + + function sizeFunc(_annotation) { + return false; + } + + function onClick(annotation) { + iaButton.minus(); + annotationArea.update(annotation); + annotationArea.show(); + $("#caption").hide(); + } + + function onUpdate() { + /* update might change the annotation which is currently shown in the + annotation area -> find the updated annotation in the annotation array + and update the area. */ + if (annotationArea.annotation) { + const id = annotationArea.annotation.id; + annotationArea.update(AnnotationManager.find(id)); + } + annotationsToggle.add(); + } + + const annotationManager = new AnnotationManager(colorFunc, strokeColorFunc, sizeFunc, + onClick, onUpdate, isValid); + thymeAttributes.annotationManager = annotationManager; + + // Update annotations after deleting an annotation + const ANNOTATION_DELETE_SELECTOR = "#annotation-delete-button"; + $(document).on("click", ANNOTATION_DELETE_SELECTOR, function () { + const deleteMsg = $(ANNOTATION_DELETE_SELECTOR).data("sureToDelete"); + const reallyDelete = confirm(deleteMsg); + if (!reallyDelete) { + return; + } + + const annotationId = Number(document.getElementById("annotation_id").textContent); + $.ajax(Routes.annotation_path(annotationId), { + type: "DELETE", + dataType: "json", + data: { + annotation_id: annotationId, + }, + success: function () { + annotationManager.updateAnnotations(); + // close and open again = show normal IA + iaButton.minus(); + iaButton.plus(); + }, + }); + }); + + /* + CHAPTERS & METADATA MANAGER + */ + const iaBackButton = new IaBackButton("back-button", "chapters"); + iaBackButton.add(); + const chapterManager = new ChapterManager("chapters", iaBackButton); + const metadataManager = new MetadataManager("metadata"); + thymeAttributes.chapterManager = chapterManager; + thymeAttributes.metadataManager = metadataManager; + chapterManager.load(); + metadataManager.load(); + + /* + INTERACTIVE AREA + */ + iaButton.toHide = [$("#caption"), annotationArea]; + iaButton.add(); + (new IaCloseButton("ia-close", iaButton)).add(); + + /* + RESIZE + */ + // Manage large and small display + function onEnlarge() { + iaButton.plus(); + } + + const elements = [$("#caption"), $("#annotation-caption"), $("#video-controlBar")]; + const displayManager = new DisplayManager(elements, onEnlarge); + + // resizes the thyme container to the window dimensions, taking into account + // whether the interactive area is displayed or hidden + function resizeContainer() { + const factor = $("#caption").is(":hidden") && $("#annotation-caption").is(":hidden") ? 1 : 1 / 0.82; + Resizer.resizeContainer(thymeContainer, factor, 0); + annotationManager.updateMarkers(); + } + + window.onresize = resizeContainer; + video.onloadedmetadata = resizeContainer; + + /* + KEYBOARD SHORTCUTS + */ + thymeKeyShortcuts.addGeneralShortcuts(); + thymeKeyShortcuts.addPlayerShortcuts(); + + /* + MISC + */ + const controlBarHider = new ControlBarHider("video-controlBar", 3000); + controlBarHider.install(); + displayManager.updateControlBarType(); + thymeUtility.playOnClick(); + thymeUtility.setUpMaxTime("max-time"); + + if (document.documentMode) { + alert($("body").data("badbrowser")); + displayManager.adaptToSmallDisplay(); + resizeContainer(); + return; + } +}); diff --git a/app/assets/javascripts/thyme/utility.js b/app/assets/javascripts/thyme/utility.js new file mode 100644 index 000000000..dd20d5e7a --- /dev/null +++ b/app/assets/javascripts/thyme/utility.js @@ -0,0 +1,143 @@ +/** + This file contains some auxiliary functions used by the different thyme player types. +*/ +const thymeUtility = { + + /* + Mixes all colors in the array "colors" (write colors as hexadecimal, e.g. "#1fe67d"). + */ + mixColors: function (colors) { + let red = 0; + let green = 0; + let blue = 0; + for (let color of colors) { + red += Number("0x" + color.substr(5, 2)); + green += Number("0x" + color.substr(3, 2)); + blue += Number("0x" + color.substr(1, 2)); + } + const n = colors.length; + red = Math.max(0, Math.min(255, Math.round(red / n))); + green = Math.max(0, Math.min(255, Math.round(green / n))); + blue = Math.max(0, Math.min(255, Math.round(blue / n))); + return "#" + thymeUtility.toHexaDecimal(blue) + + thymeUtility.toHexaDecimal(green) + + thymeUtility.toHexaDecimal(red); + }, + + /* + Convert given dataURL to Blob, used for converting screenshot canvas to png. + */ + dataURLtoBlob: function (dataURL) { + // Decode the dataURL + const binary = atob(dataURL.split(",")[1]); + // Create 8-bit unsigned array + let array = []; + for (let i = 0; i < binary.length; i++) { + array.push(binary.charCodeAt(i)); + } + // Return our Blob object + return new Blob([new Uint8Array(array)], { + type: "image/png", + }); + }, + + /* + Lightens up a given color (given in a string in hexadecimal + representation "#xxyyzz") such that e.g. black becomes dark grey. + The higher the value of "factor" the brighter the colors become. + */ + lightenUp: function (color, factor) { + const red = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(5, 2))) / factor); + const green = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(3, 2))) / factor); + const blue = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(1, 2))) / factor); + return "#" + thymeUtility.toHexaDecimal(blue) + + thymeUtility.toHexaDecimal(green) + + thymeUtility.toHexaDecimal(red); + }, + + /* + Installs a listener which lets the video play/pause when clicked. + */ + playOnClick: function () { + const video = thymeAttributes.video; + video.addEventListener("click", function () { + if (video.paused) { + video.play(); + } + else { + video.pause(); + } + }); + }, + + /* + Renders latex in a given HTML element. + */ + renderLatex: function (element) { + renderMathInElement(element, { + delimiters: [ + { + left: "$$", + right: "$$", + display: true, + }, { + left: "$", + right: "$", + display: false, + }, { + left: "\\(", + right: "\\)", + display: false, + }, { + left: "\\[", + right: "\\]", + display: true, + }, + ], + throwOnError: false, + }); + }, + + /* + Convert time in seconds to string of the form H:MM:SS. + */ + secondsToTime: function (seconds) { + let date = new Date(null); + date.setSeconds(seconds); + return date.toISOString().substr(12, 7); + }, + + /* + Sets up the label on the right side of the seek bar which displays + the maximum time of the video. + (In order to make this work, we have to wait for the video's metadata + to be loaded.) + */ + setUpMaxTime: function (maxTimeId) { + const video = thymeAttributes.video; + video.addEventListener("loadedmetadata", function () { + const maxTime = document.getElementById(maxTimeId); + maxTime.innerHTML = thymeUtility.secondsToTime(video.duration); + if (video.dataset.time) { + const time = video.dataset.time; + video.currentTime = time; + } + }); + }, + + /* + Converts a json timestamp into a double value containing the absolute value of seconds. + */ + timestampToSeconds: function (timestamp) { + return 3600 * timestamp.hours + 60 * timestamp.minutes + timestamp.seconds + 0.001 * timestamp.milliseconds; + }, + + /* + Converts a given integer between 0 and 255 into a hexadecimal, s.t. e.g. "15" becomes "0f" + (instead of just "f") -> needed for correct format. + */ + toHexaDecimal: function (integer) { + return integer.toString(16).padStart(2, "0"); + }, + +}; diff --git a/app/assets/javascripts/thyme_editor.coffee b/app/assets/javascripts/thyme_editor.coffee deleted file mode 100644 index dce0182cf..000000000 --- a/app/assets/javascripts/thyme_editor.coffee +++ /dev/null @@ -1,208 +0,0 @@ -# convert time in seconds to string of the form H:MM:SS -secondsToTime = (seconds) -> - date = new Date(null) - date.setSeconds seconds - return date.toISOString().substr(12, 7) - -# convert given dataURL to Blob, used for converting screenshot canvas to png -dataURLtoBlob = (dataURL) -> - # Decode the dataURL - binary = atob(dataURL.split(',')[1]) - # Create 8-bit unsigned array - array = [] - i = 0 - while i < binary.length - array.push binary.charCodeAt(i) - i++ - # Return our Blob object - new Blob([ new Uint8Array(array) ], type: 'image/png') - -$(document).on 'turbolinks:load', -> - thymeEdit = document.getElementById('thyme-edit') - return if thymeEdit == null - mediumId = thymeEdit.dataset.medium - # Video - video = document.getElementById('video-edit') - # Buttons - playButton = document.getElementById('play-pause') - muteButton = document.getElementById('mute') - plusTenButton = document.getElementById('plus-ten') - plusFiveButton = document.getElementById('plus-five') - plusOneButton = document.getElementById('plus-one') - minusOneButton = document.getElementById('minus-one') - minusFiveButton = document.getElementById('minus-five') - minusTenButton = document.getElementById('minus-ten') - addItemButton = document.getElementById('add-item') - addReferenceButton = document.getElementById('add-reference') - addScreenshotButton = document.getElementById('add-screenshot') - # Sliders - seekBar = document.getElementById('seek-bar') - volumeBar = document.getElementById('volume-bar') - # Time - currentTime = document.getElementById('current-time') - maxTime = document.getElementById('max-time') - # ControlBar - videoControlBar = document.getElementById('video-controlBar-edit') - # Screenshot Canvas - canvas = document.getElementById('snapshot') - - # Event listener for the play/pause button - playButton.addEventListener 'click', -> - if video.paused == true - video.play() - else - video.pause() - return - - video.onplay = -> - playButton.innerHTML = 'pause' - - video.onpause = -> - playButton.innerHTML = 'play_arrow' - - # Event listener for the mute button - muteButton.addEventListener 'click', -> - if video.muted == false - video.muted = true - muteButton.innerHTML = 'volume_off' - else - video.muted = false - muteButton.innerHTML = 'volume_up' - return - - # Event handler for the plusTen button - plusTenButton.addEventListener 'click', -> - video.currentTime = Math.min(video.currentTime + 10, video.duration) - return - - # Event handler for the plusFive button - plusFiveButton.addEventListener 'click', -> - video.currentTime = Math.min(video.currentTime + 5, video.duration) - return - - # Event handler for the plusOne button - plusOneButton.addEventListener 'click', -> - video.currentTime = Math.min(video.currentTime + 1, video.duration) - return - - # Event handler for the minusOne button - minusOneButton.addEventListener 'click', -> - video.currentTime = Math.max(video.currentTime - 1, 0) - return - - # Event handler for the minusFive button - minusFiveButton.addEventListener 'click', -> - video.currentTime = Math.max(video.currentTime - 5, 0) - return - - # Event handler for the minusTen button - minusTenButton.addEventListener 'click', -> - video.currentTime = Math.max(video.currentTime - 10, 0) - return - - # Event listener for the seek bar - seekBar.addEventListener 'input', -> - time = video.duration * seekBar.value / 100 - video.currentTime = time - return - - # Event listener for addItem button - addItemButton.addEventListener 'click', -> - video.pause() - # round time down to three decimal digits - time = video.currentTime - intTime = Math.floor(time) - roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000 - video.currentTime = roundTime - $.ajax Routes.add_item_path(mediumId), - type: 'GET' - dataType: 'script' - data: { - time: video.currentTime - } - return - - # Event listener for addItem button - addReferenceButton.addEventListener 'click', -> - video.pause() - # round time down to three decimal digits - time = video.currentTime - intTime = Math.floor(time) - roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000 - video.currentTime = roundTime - $.ajax Routes.add_reference_path(mediumId), - type: 'GET' - dataType: 'script' - data: { - time: video.currentTime - } - return - - # Event listener for add screenshot button - addScreenshotButton.addEventListener 'click', -> - video.pause() - # extract video screenshot from canvas - context = canvas.getContext('2d') - context.drawImage(video, 0, 0, canvas.width, canvas.height) - base64image = canvas.toDataURL('image/png') - # Get our file - file = dataURLtoBlob(base64image) - # Create new form data - fd = new FormData - # Append our Canvas image file to the form data - fd.append 'image', file - # And send it - $.ajax Routes.add_screenshot_path(mediumId), - type: 'POST' - data: fd - processData: false - contentType: false - return - - # after video metadata have been loaded, set up video length, volume bar and - # seek bar - video.addEventListener 'loadedmetadata', -> - maxTime.innerHTML = secondsToTime(video.duration) - volumeBar.value = video.volume - volumeBar.style.backgroundImage = 'linear-gradient(to right,' + - ' #2497E3, #2497E3 ' + video.volume*100 + '%, #ffffff ' + - video.volume*100 + '%, #ffffff)' - seekBar.value = 0 - canvas.width = Math.floor($(video).width()) - canvas.height = Math.floor($(video).height()) - return - - # Update the seek bar as the video plays - video.addEventListener 'timeupdate', -> - value = 100 / video.duration * video.currentTime - seekBar.value = value - seekBar.style.backgroundImage = 'linear-gradient(to right,' + - ' #2497E3, #2497E3 ' + value + '%, #ffffff ' + value + '%, #ffffff)' - currentTime.innerHTML = secondsToTime(video.currentTime) - return - - # Pause the video when the seek handle is being dragged - seekBar.addEventListener 'mousedown', -> - video.dataset.paused = video.paused - video.pause() - return - - # Play the video when the seek handle is dropped - seekBar.addEventListener 'mouseup', -> - video.play() unless video.dataset.paused == 'true' - return - - # Event listener for the volume bar - volumeBar.addEventListener 'change', -> - value = volumeBar.value - video.volume = value - return - - video.addEventListener 'volumechange', -> - value = video.volume - volumeBar.value = value - volumeBar.style.backgroundImage = 'linear-gradient(to right,' + - ' #2497E3, #2497E3 ' + value*100 + '%, #ffffff ' + value*100 + '%, #ffffff)' - return - - return diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss new file mode 100644 index 000000000..02b3fc257 --- /dev/null +++ b/app/assets/stylesheets/annotations.scss @@ -0,0 +1,213 @@ +/* buttons */ +#annotation-button { + position: absolute; + left: 51%; + font-size: 1.3rem; + padding: 2px 8px; + + i { + &::before { + color: transparent; + background-clip: text; + background-image: radial-gradient(at bottom right, rgb(30, 82, 141) 0%, rgb(237, 152, 189) 100%); + } + } + + transition: filter 60ms ease-in-out; + + &:hover { + filter: drop-shadow(2px 3px 2px rgba(97, 114, 138, 0.3)); + } +} + +#annotations-toggle { + position: absolute; + display: flex; + left: 89%; + + input:checked { + background-color: #2196F3; // TODO: outsource common video control colors + } +} + +/* annotation modal */ +#annotation-modal { + .modal-header { + transition: background-color 200ms linear; + } + + .modal-body { + display: flex; + } + + .modal-footer { + margin-top: 40px; + padding-bottom: 0; + padding-right: 0; + } + + .annotation-dialog-normal { + max-width: 560px; + } + + .annotation-dialog-expanded { + max-width: 730px; + } + + .annotation-content-normal { + width: 100%; + } + + .annotation-content-expanded { + width: 70%; + } + + #annotation-preview-section { + width: 30%; + } + + #annotation-modal-preview { + word-wrap: break-word; + overflow-y: auto; + } + + #annotation_comment { + height: 200px; + max-height: 300px; + } + + #annotation_category_text { + width: 200px; + } + + // adapted from https://stackoverflow.com/a/49065029/ + section { + display: flex; + flex-direction: column; + // border: thin solid rgb(176, 176, 176); + } + + // this column adapts to the other column with respect to its height + .column-adaptable { + flex-basis: 0; + flex-grow: 1; + } + + .annotation-preview { + border-right: thin solid rgb(176, 176, 176); + margin-right: 8px; + padding-right: 8px; + } + + .annotation-content-spacing { + padding-left: 8px; + } +} + +#emergency-link { + text-align: center; +} + +.annotation-marker { + position: relative; + top: -12px; + width: 0; + height: 0; + display: flex; + cursor: pointer; + + & i { + position: relative; + transition: filter 80ms ease-in-out; + filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.1)); + font-size: 1.0rem; + + &.annotation-marker-shown { + filter: drop-shadow(0px 0px 2px #fffd00) !important; + z-index: 100; // for markers on the same point in time + } + + &:hover { + filter: drop-shadow(0px 0px 2px #fffd00); + } + } +} + +#annotation-caption { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +#annotation-infobar { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + + padding: 0.6em; + box-shadow: 0px 2px 10px -2px rgba(0, 0, 0, 0.05); + + color: #4b4b4b; + font-size: 0.85rem; + font-weight: bold; + letter-spacing: 1px; +} + +#annotation-comment { + height: 100%; + overflow: overlay; + padding: 0.5em; + + color: #4b4b4b; + font-size: 1rem; +} + +#annotation-area-buttons { + display: flex; + justify-content: center; + padding: 0.4em 4.2em; + + box-shadow: 0px -2px 10px -2px rgba(0, 0, 0, 0.05); + + & i { + font-size: 1.1rem; + } +} + +#annotation-color-picker { + input[type="radio"] { + display: none; + + &:checked+label { + span { + transform: scale(1.25); + border: 2px solid #0000008a; + } + } + } + + text-align: center; + + label { + display: inline-block; + width: 25px; + height: 25px; + margin-right: 2px; + cursor: pointer; + + &:hover { + span { + transform: scale(1.25); + } + } + + span { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + transition: transform .1s ease-in-out; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 66e6312e3..e8b97ca3e 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -42,6 +42,7 @@ $container-max-widths: ( /* override the !default vars with the values we set above */ +@import "annotations"; @import "bootstrap"; @import "rails_bootstrap_forms"; @import "chapters"; @@ -57,6 +58,7 @@ $container-max-widths: ( @import "tags"; @import "talks"; @import "thyme"; +@import "thyme_feedback"; @import "users"; @import "submissions"; @import "vertices"; diff --git a/app/assets/stylesheets/thyme.scss b/app/assets/stylesheets/thyme.scss index 6fbed3b35..97d6ab777 100644 --- a/app/assets/stylesheets/thyme.scss +++ b/app/assets/stylesheets/thyme.scss @@ -1,47 +1,45 @@ - -#thyme-container { - position: absolute; - top: 0; - width: 100%; - height: 100%; - font-family: 'Roboto'; +.thyme-container-layout { + position: absolute; + top: 0; + width: 100%; + height: 100%; + font-family: 'Roboto'; } #editor-container { - height:100vh; + height: 100vh; width: 100vw; } -#thyme { - min-width: 100%; - min-height: 100%; - width: auto; - height: auto; - background: black; +.thyme-generic { + min-width: 100%; + min-height: 100%; + width: auto; + height: auto; + background: black; } #hypervideo-container { - font-size: 0; position: relative; background: white; margin: 0; } #video { - width: 82%; - height: auto; - display: inline-block; + display: block; + background: black; } #hypervideo-container figcaption { position: absolute; - right: 0; top: 0; + right: 0; + top: 0; background: white; width: 18%; font-size: .8rem; color: #666; height: 100%; - outline: 0; + outline: 0; } #hypervideo-container figcaption ol { @@ -51,127 +49,176 @@ padding: 0; } -#video-container { - position: relative; -} - #video-controlBar { - width: 82%; - position: absolute; - bottom: 0; - left: 0; - right: 0; - padding: 5px; - width: 82%; - height: auto; - background: lightgray; - -webkit-user-select: none; - -moz-user-select: none; + width: 82%; + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 5px; + width: 82%; + height: auto; + background: linear-gradient(120deg, hsl(214, 19%, 84%) 0%, hsl(207, 18%, 82%) 100%); user-select: none; } #video-controls { - position: relative; - display: inline-block; - height: 30px; - width: 100%; + display: flex; + align-items: center; + flex-wrap: wrap; + height: 2.5em; + padding: inherit; } #timeline { - position: absolute; - display: flex; - width: 50%; + position: absolute; + display: flex; + width: 50%; + padding-left: inherit; } #current-time { - font-size: 12px; - padding: 4px; + font-size: 12px; + padding: 4px; } #seek-bar { - flex-grow: 5; - margin-top: 7px; + flex-grow: 5; + margin-top: 7px; } #max-time { - font-size: 12px; - padding: 4px; + font-size: 12px; + padding: 4px; } -#special-buttons { - position: absolute; - display: flex; - left: 52%; +.special-buttons-layout { + position: absolute; + display: flex; + left: 54%; } -.clickable -{ +.clickable { cursor: pointer; } #previous-chapter { - margin-left: 10px; + margin-left: 10px; } -#volume-controls { - position: absolute; - display: flex; - width: 10%; - left: 68%; +.volume-controls-layout { + position: absolute; + display: flex; + width: 10%; + left: 68%; } #speed-control { - position: absolute; - padding: 3px; - font-size: 12px; - left: 81%; + position: absolute; + padding: 3px; + font-size: 12px; + left: 79%; } #volume-bar { - flex-grow: 2; - min-width: 50%; - margin-top: 7px; - margin-left: 4px; + flex-grow: 2; + min-width: 50%; + margin-top: 7px; + margin-left: 4px; +} + +/* The switch - the box around the slider */ +.switch { + position: relative; + display: inline-block; + width: 30px; + height: 17px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 13px; + width: 13px; + left: 2px; + bottom: 2px; + background-color: white; + transition: .4s; +} + +input:focus+.slider { + box-shadow: 0 0 1px #2196F3; +} + +input:checked+.slider:before { + transform: translateX(13px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 17px; +} + +.slider.round:before { + border-radius: 50%; } #size-buttons { - position: absolute; - right: 0%; + display: flex; + position: absolute; + right: 0%; + padding-right: inherit; } input[type=range] { - -webkit-appearance: none; - -moz-appearance: none; - border-radius: 6px; - height: 10px; - background-image: linear-gradient( - to right, + appearance: none; + border-radius: 6px; + height: 10px; + background-image: linear-gradient(to right, #2497E3, #2497E3 0%, #ffffff 0%, - #ffffff - ); + #ffffff); } input[type="range"]::-moz-range-track { - border: none; - background: none; - outline: none; + border: none; + background: none; + outline: none; } input[type=range]:focus { - outline: none; - border: none; + outline: none; + border: none; } input[type=range]::-webkit-slider-thumb { - -webkit-appearance: none !important; - background-color: #2497E3; - height: 16px; - width: 16px; - border-radius: 50%; - border: 1px solid black; + -webkit-appearance: none !important; + background-color: #2497E3; + height: 16px; + width: 16px; + border-radius: 50%; + border: 1px solid black; } input[type=range]::-moz-range-thumb { @@ -183,7 +230,7 @@ input[type=range]::-moz-range-thumb { } input[type=range]::-moz-focus-outer { - border: 0; + border: 0; } figure { @@ -210,38 +257,20 @@ figure { height: calc(0.5*(100% - 6em - 4em)); border: 1px solid darkgray; overflow-y: scroll; - color: #484848; + color: #484848; } #metadata .current { color: black; - -webkit-animation: yellow-fade 10s ease-in; - -moz-animation: yellow-fade 10s ease-in; - -o-animation: yellow-fade 10s ease-in; - animation: yellow-fade 10s ease-in; + animation: yellow-fade 10s ease-in; + animation: yellow-fade 10s ease-in; } - -@-webkit-keyframes yellow-fade { - from { - background: gold; - } - to { - background: #fff; - } -} -@-moz-keyframes yellow-fade { - from { - background: gold; - } - to { - background: #fff; - } -} @keyframes yellow-fade { from { background: gold; } + to { background: #fff; } @@ -249,28 +278,29 @@ figure { #metadata li a i { - font-size: 1.5em; + font-size: 1.5em; } #metadata li { - display: flex; - justify-content: space-between; + display: flex; + justify-content: space-between; position: relative; border-bottom: 1px solid darkgrey; - text-decoration: none; - padding: 5px; + text-decoration: none; + padding: 5px; } #metadata li a { - color: inherit; + color: inherit; display: block; text-decoration: none; padding: 3px; } -.item:hover, .item:focus{ - background: #f0f0f0; - cursor: pointer; +.item:hover, +.item:focus { + background: #f0f0f0; + cursor: pointer; } @@ -309,9 +339,9 @@ figure { background: #e6e6e6; border-left: 1px solid darkgray; border-right: 1px solid darkgray; - display: flex; - justify-content: center; - align-items: center; + display: flex; + justify-content: center; + align-items: center; } @@ -325,11 +355,9 @@ figure { .replay { position: absolute; top: 0; - right:0; + right: 0; padding: 3px; cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; user-select: none; } @@ -337,7 +365,6 @@ figure { background: lightgrey; } -:-webkit-full-screen::-webkit-backdrop - { - background-color: black; - } +:full-screen::backdrop { + background-color: black; +} \ No newline at end of file diff --git a/app/assets/stylesheets/thyme_feedback.scss b/app/assets/stylesheets/thyme_feedback.scss new file mode 100644 index 000000000..1bd1cb6ec --- /dev/null +++ b/app/assets/stylesheets/thyme_feedback.scss @@ -0,0 +1,56 @@ +#thyme-feedback { + #timeline { + width: 55%; + } +} + +#annotation-switches { + display: flex; + position: absolute; + left: 57%; + + .form-switch .form-check-input { + border: none; + + &:focus { + box-shadow: 0 0 0 0.25rem #ffffff4f; + background-image: url("data:image/svg+xml,"); + } + + &:checked { + background-image: url("data:image/svg+xml,"); + } + } + + // reflected from category.js + #annotation-category-note-switch:checked { + background-color: #f78f19; + } + + #annotation-category-content-switch:checked { + background-color: #A333C8; + } + + #annotation-category-presentation-switch:checked { + background-color: #2185D0; + } + + #annotation-category-mistake-switch:checked { + background-color: #fc1461; + } +} + +#feedback-special-buttons { + left: 73%; +} + +#feedback-volume-controls { + left: 79%; +} + +#heatmap { + position: relative; + width: 0; + height: 0; + pointer-events: none; +} \ No newline at end of file diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb new file mode 100644 index 000000000..3b65c3a02 --- /dev/null +++ b/app/controllers/annotations_controller.rb @@ -0,0 +1,211 @@ +class AnnotationsController < ApplicationController + authorize_resource + + def show + @annotation = Annotation.find(params[:id]) + end + + def new + @annotation = Annotation.new(category: :note, color: Annotation.colors[1]) + + @total_seconds = params[:total_seconds] + @medium_id = params[:medium_id] + @posted = false + @is_new_annotation = true + + render :edit + end + + def edit + @annotation = Annotation.find(params[:annotation_id]) + + # only allow editing, if the current user created the annotation + if @annotation.user_id != current_user.id + render json: false + return + end + + @total_seconds = @annotation.timestamp.total_seconds + @medium_id = @annotation.medium_id + @posted = !@annotation.public_comment_id.nil? + + # if this annotation has an associated commontator comment, + # we have to call the "comment_optional" method in order to get + # the text. + @annotation.comment = @annotation.comment_optional + + @is_new_annotation = false + end + + def create + @annotation = Annotation.new(annotation_params) + + @annotation.user_id = current_user.id + @total_seconds = annotation_auxiliary_params[:total_seconds] + @annotation.timestamp = TimeStamp.new(total_seconds: @total_seconds) + + return unless create_and_update_shared(@annotation) + + @annotation.save + render :update + end + + def update + @annotation = Annotation.find(params[:id]) + @annotation.assign_attributes(annotation_params) + + return unless create_and_update_shared(@annotation) + + @annotation.save + end + + def destroy + annotation = Annotation.find(params[:annotation_id]) + + # only the owner of the annotation is allowed to delete it + return unless annotation.user == current_user + + # delete associated commontator comment + unless annotation.public_comment_id.nil? + commontator_comment = Commontator::Comment.find_by(id: annotation.public_comment_id) + commontator_comment.update(deleted_at: DateTime.now) + end + + annotation.destroy + + render json: [] + end + + def update_annotations + medium = Medium.find_by(id: params[:mediumId]) + + # Get the right annotations + annotations = if medium.annotations_visible?(current_user) + Annotation.where(medium: medium, + visible_for_teacher: true).or( + Annotation.where(medium: medium, + user: current_user) + ) + else + Annotation.where(medium: medium, + user: current_user) + end + + # If annotation is associated to a comment, + # the field "comment" is empty -> get it from the commontator comment + annotations.each do |a| + a.comment = a.comment_optional + end + + # Convert to JSON (for easier hash operations) + annotations = annotations.as_json + + # Filter attributes and add boolean "belongs_to_current_user". + annotations.each do |a| + a["belongs_to_current_user"] = (current_user.id == a["user_id"]) + a.slice!("belongs_to_current_user", "category", "color", "comment", + "id", "subcategory", "timestamp") + end + + render json: annotations + end + + def num_nearby_posted_mistake_annotations + # the time (!) radius in which annotation are considered as "nearby" + radius = 60 + timestamp = params[:timestamp].to_i + annotations = Annotation.where(medium: params[:mediumId], category: "mistake").commented + counter = annotations.to_a.count { |annotation| annotation.nearby?(timestamp, radius) } + render json: counter + end + + def current_ability + @current_ability ||= AnnotationAbility.new(current_user) + end + + private + + def annotation_params + params.require(:annotation).permit( + :category, :color, :comment, :medium_id, :subcategory, :visible_for_teacher + ) + end + + def annotation_auxiliary_params + params.require(:annotation).permit( + :total_seconds, :post_as_comment + ) + end + + # TODO: Frontend should not pass color hex strings, instead pass the respective + # color keys, e.g. 14, see annotation.rb color_map for lookup. + def valid_color?(color) + Annotation.colors.value?(color) + # if you want to allow any color (not just the given selection + # in Annotation.colors), use the following regex check: + # color&.match?(/\A#([0-9]|[A-F]){6}\z/) + end + + def valid_time?(annotation) + time = annotation.timestamp.total_seconds + time >= 0 and time <= annotation.medium.video["duration"] + end + + # checks that the subcategory is non-nil if the category is "content" and + # resets the subcategory to "nil" if the selected category isn't "content" + def subcategory_nil(annotation) + return if (annotation.category_for_database == Annotation.categories[:content]) && + annotation.subcategory.nil? + + if annotation.category_for_database != Annotation.categories[:content] + annotation.subcategory = nil + end + true + end + + # common code for the create and update method + def create_and_update_shared(annotation) + valid_color?(annotation.color) && + valid_time?(annotation) && + subcategory_nil(annotation) && + commontator_comment(annotation) + end + + # Run all the Commontator::Comment related code here. + def commontator_comment(annotation) + public_comment_id = annotation.public_comment_id + + # return true (success) if checkbox "post_as_comment" is not checked + # and if there is no comment to update + return true if annotation_auxiliary_params[:post_as_comment] != "1" && + public_comment_id.nil? + + body = annotation_params[:comment] + + if public_comment_id.nil? # comment doesn't exist yet -> create one + medium = annotation.medium + comment = Commontator::Comment.new( + thread: medium.commontator_thread, + creator: current_user, + body: body, + annotation: annotation + ) + else # comment already exists -> update it + comment = Commontator::Comment.find_by(id: public_comment_id) + comment.assign_attributes(editor: current_user, + body: body) + end + + # if the same comment already exists, the db will trigger a rollback + # -> print error message in that case + if !comment.save && comment.errors.of_kind?(:body, :taken) + render :duplicate_comment + return + end + + # delete comment as it is already saved in the commontator comment model + annotation.comment = nil + + annotation.public_comment_id = comment.id + end +end diff --git a/app/controllers/lectures_controller.rb b/app/controllers/lectures_controller.rb index a7196ea79..ff1e44734 100644 --- a/app/controllers/lectures_controller.rb +++ b/app/controllers/lectures_controller.rb @@ -53,6 +53,7 @@ def new # info to the lecture @lecture.course = Course.find_by(id: params[:course]) I18n.locale = @lecture.course.locale + @lecture.annotations_status = 0 end def edit @@ -61,6 +62,15 @@ def edit Time.zone.parse(ENV.fetch("RAILS_CACHE_ID", nil))].max) eager_load_stuff end + + # emergency link -> prefill form + status = @lecture.emergency_link_status_for_database + link = @lecture.emergency_link + if status == Lecture.emergency_link_statuses[:lecture_link] + @linked_lecture = Lecture.find_by(id: link.tr("^[0-9]", "")) + elsif status == Lecture.emergency_link_statuses[:direct_link] + @direct_link = link + end end def create @@ -90,6 +100,8 @@ def create end def update + return unless @lecture.valid_annotations_status? + editor_ids = lecture_params[:editor_ids] unless editor_ids.nil? # removes the empty String "" in the NEW array of editor ids @@ -109,6 +121,19 @@ def update end end + # emergency link update + status = params[:lecture][:emergency_link_status] # string + status = Lecture.emergency_link_statuses[status] + if status == Lecture.emergency_link_statuses[:lecture_link] + params[:lecture][:emergency_link] = params[:lecture][:lecture_link] + elsif status == Lecture.emergency_link_statuses[:direct_link] + link = params[:lecture][:direct_link] + # Prepend "https://" to link if not present to make it an absolute URL + # instead of a relative one. E.g. "example.com" -> "https://example.com". + link = "https://#{link}" unless link.start_with?("http") + params[:lecture][:emergency_link] = link + end + @lecture.update(lecture_params) if structure_params.present? structure_ids = structure_params.select { |_k, v| v.to_i == 1 }.keys @@ -232,6 +257,12 @@ def search_examples def close_comments @lecture.close_comments!(current_user) + # disable annotation button + @lecture.update(annotations_status: 0) + @lecture.media.update(annotations_status: -1) + @lecture.lessons.each do |lesson| + lesson.media.update(annotations_status: -1) + end redirect_to edit_lecture_path(@lecture) end @@ -313,7 +344,9 @@ def lecture_params :organizational_concept, :muesli, :organizational_on_top, :disable_teacher_display, :content_mode, :passphrase, :sort, :comments_disabled, - :submission_max_team_size, :submission_grace_period] + :submission_max_team_size, :submission_grace_period, + :annotations_status, :emergency_link, + :emergency_link_status] if action_name == "update" && current_user.can_update_personell?(@lecture) allowed_params.push(:teacher_id, { editor_ids: [] }) end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index ffefdc701..2e8bf90fd 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -59,6 +59,9 @@ def edit def create @medium = Medium.new(medium_params) + + return unless @medium.valid_annotations_status? + @medium.locale = @medium.teachable&.locale @medium.editors = [current_user] @medium.tags = @medium.teachable.tags if @medium.teachable.instance_of?(::Lesson) @@ -106,6 +109,8 @@ def update @errors = @medium.errors return unless @errors.empty? + return unless @medium.valid_annotations_status? + # make sure the medium is touched # (it will not be touched automatically in some cases (e.g. if you only # update the associated tags), causing trouble for caching) @@ -509,6 +514,19 @@ def fill_reassign_modal @no_rights = params[:rights] == "none" end + def check_annotation_visibility + medium = Medium.find_by(id: params[:id]) + isPermitted = medium.annotations_visible?(current_user) # rubocop:todo Naming/VariableName + render json: isPermitted # rubocop:todo Naming/VariableName + end + + # Renders the feedback player. Do not confuse with the feedback button + # which has nothing to do with the thyme player(s). + def feedback + I18n.locale = @medium.locale_with_inheritance + render layout: "feedback" + end + private def medium_params @@ -519,6 +537,7 @@ def medium_params :teachable_type, :teachable_id, :released, :text, :locale, :content, :boost, + :annotations_status, editor_ids: [], tag_ids: [], linked_medium_ids: []) diff --git a/app/models/annotation.rb b/app/models/annotation.rb new file mode 100644 index 000000000..b15b36052 --- /dev/null +++ b/app/models/annotation.rb @@ -0,0 +1,49 @@ +class Annotation < ApplicationRecord + belongs_to :medium + belongs_to :user + belongs_to :public_comment, class_name: "Commontator::Comment", + optional: true + + scope :commented, -> { where.not(public_comment_id: nil) } + + # the timestamp for the annotation position is serialized as text in the db + serialize :timestamp, TimeStamp + + enum category: { note: 0, content: 1, mistake: 2, presentation: 3 } + enum subcategory: { definition: 0, argument: 1, strategy: 2 } + + # If the annotation has an associated commontator comment, its comment will + # be saved in the commontator comment. While calling annotation.comment returns + # nil in this case, this method pulls out the actual comment from the commontator + # comment. + def comment_optional + return comment if public_comment_id.nil? + + Commontator::Comment.find_by(id: public_comment_id).body + end + + def nearby?(other_timestamp, radius) + (timestamp.total_seconds - other_timestamp).abs < radius + end + + def self.colors + # Colors must have 6 digits and be capitalized (!) + { + 1 => "#DB2828", + 2 => "#F2711C", + 3 => "#FBBD08", + 4 => "#B5CC18", + 5 => "#21BA45", + 6 => "#00B5AD", + 7 => "#2185D0", + 8 => "#6435C9", + 9 => "#A333C8", + 10 => "#E03997", + 11 => "#D05D41", + 12 => "#924129", + 13 => "#444444", + 14 => "#999999", + 15 => "#EEEEEE" + } + end +end diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 8d5c1d353..097bb4b52 100644 --- a/app/models/lecture.rb +++ b/app/models/lecture.rb @@ -67,6 +67,10 @@ class Lecture < ApplicationRecord # in the erdbeere database serialize :structure_ids, Array + # if the annotation button is enabled, one can add different types of links + # that e.g. bring students to the helpdesk + enum emergency_link_status: { no_link: 0, lecture_link: 1, direct_link: 2 } + # we do not allow that a teacher gives a certain lecture in a given term # of the same sort twice # rubocop:todo Rails/UniqueValidationWithoutIndex @@ -840,6 +844,10 @@ def stale? older_than?(1.year) end + def valid_annotations_status? + [-1, 1].include?(annotations_status) + end + private # used for after save callback diff --git a/app/models/medium.rb b/app/models/medium.rb index 645371915..540e4e593 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -1087,6 +1087,26 @@ def subscribed_users Lecture.find_by(id: teachable.lecture_id).user_ids end + # Returns either the annotations status (1 = activated, 0 = deactivated) + # of this medium or the annotations status of the associated lecture + # if "inherit from lecture" was selected (i.e. if the annotations status of + # this medium is -1). + def get_annotations_status # rubocop:todo Naming/AccessorMethodName + return lecture.annotations_status if annotations_status == -1 + + annotations_status + end + + def annotations_visible?(user) + is_teacher = edited_with_inheritance_by?(user) + is_activated = (get_annotations_status == 1) + is_teacher && is_activated + end + + def valid_annotations_status? + [-1, 0, 1].include?(annotations_status) + end + private # media of type kaviar associated to a lesson and script do not require diff --git a/app/views/annotations/_annotation_area.html.erb b/app/views/annotations/_annotation_area.html.erb new file mode 100644 index 000000000..ab0cfa8e8 --- /dev/null +++ b/app/views/annotations/_annotation_area.html.erb @@ -0,0 +1,30 @@ +
    +
    + +
    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/app/views/annotations/_annotation_locales.html.erb b/app/views/annotations/_annotation_locales.html.erb new file mode 100644 index 000000000..e7661d764 --- /dev/null +++ b/app/views/annotations/_annotation_locales.html.erb @@ -0,0 +1,19 @@ +<% I18n.locale = current_user.locale %> + +
    +
    diff --git a/app/views/annotations/_annotation_modal.html.erb b/app/views/annotations/_annotation_modal.html.erb new file mode 100644 index 000000000..7c5038921 --- /dev/null +++ b/app/views/annotations/_annotation_modal.html.erb @@ -0,0 +1,39 @@ + diff --git a/app/views/annotations/_form.html.erb b/app/views/annotations/_form.html.erb new file mode 100644 index 000000000..62cdf13f8 --- /dev/null +++ b/app/views/annotations/_form.html.erb @@ -0,0 +1,157 @@ +
    + <%= form_with model: @annotation do |f| %> +

    + <%= t('admin.annotation.time') %> +   + <%= TimeStamp.new(total_seconds: @total_seconds).hms_colon_string %> +

    + + + <%= f.hidden_field :medium_id, value: @medium_id %> + <%= f.hidden_field :total_seconds, value: @total_seconds %> + + + + <%= t('admin.annotation.comment') %> + <%= f.text_area :comment, class: 'form-control' %> + + +
    + + +
    + + +
    + <% colors = Annotation.colors %> + <% for i in 1..15 do %> + <%= f.radio_button :color, + colors[i], + id: "annotation_color#{i}" %> + <%= f.label :color, + "annotation_color#{i}", + for: "annotation_color#{i}" do %> + + <% end %> + <% end %> +
    + + + <%= t('admin.annotation.category') %> + <%= helpdesk(t('admin.annotation.category_tooltip'), false) %> +
    +
    + <%= f.radio_button :category, + :note, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.note'), + value: :note, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :category, + :content, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.content'), + value: :content, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :category, + :mistake, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.mistake'), + value: :mistake, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :category, + :presentation, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.presentation'), + value: :presentation, + class: 'form-check-label' %> +
    +
    + +
    + +
    + + + <% annotations_status = Medium.find_by(id: @medium_id).get_annotations_status %> + <% if annotations_status == 1 %> +
    + <%= f.check_box :visible_for_teacher, class: "form-check-input" %> + <%= f.label :visible_for_teacher, + t('admin.annotation.visible_for_teacher'), + class: "form-check-label" %> + <%= helpdesk(t('admin.annotation.visible_for_teacher_helpdesk'), false) %> +
    + <% else %> + <%= f.hidden_field :visible_for_teacher, value: false %> + <% end %> + + +
    + <%= f.check_box :post_as_comment, class: "form-check-input" %> + <%= f.label :post_as_comment, + t('admin.annotation.post_as_comment'), + class: "form-check-label" %> + <%= helpdesk(t('admin.annotation.post_as_comment_helpdesk'), false) %> +
    + + + + + <% end %> +
    diff --git a/app/views/annotations/_form_content.html.erb b/app/views/annotations/_form_content.html.erb new file mode 100644 index 000000000..3ea42b6f7 --- /dev/null +++ b/app/views/annotations/_form_content.html.erb @@ -0,0 +1,44 @@ +<%= t('admin.annotation.whats_the_problem') %> + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    diff --git a/app/views/annotations/_form_content_further_help.html.erb b/app/views/annotations/_form_content_further_help.html.erb new file mode 100644 index 000000000..f3921f2a3 --- /dev/null +++ b/app/views/annotations/_form_content_further_help.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/annotations/duplicate_comment.js.erb b/app/views/annotations/duplicate_comment.js.erb new file mode 100644 index 000000000..51a33fe4b --- /dev/null +++ b/app/views/annotations/duplicate_comment.js.erb @@ -0,0 +1,4 @@ +var $warningElement = $("#annotation-comment-warning"); +var message = document.getElementById("annotation-locales").dataset.warningDoublePosted; +$warningElement.html(message); +$warningElement.show(); diff --git a/app/views/annotations/edit.js.erb b/app/views/annotations/edit.js.erb new file mode 100644 index 000000000..5c8db9ef2 --- /dev/null +++ b/app/views/annotations/edit.js.erb @@ -0,0 +1,305 @@ +// When the modal opens, all key listeners must be +// deactivated until the modal gets closed again +thymeAttributes.lockKeyListeners = true; +$("#annotation-modal").on("hidden.bs.modal", function () { + thymeAttributes.lockKeyListeners = false; +}); + +$("#annotation-modal-content").empty() + .append("<%= j render partial: "annotations/form"%>"); +$("#annotation-modal").modal("show"); + +var submitButton = document.getElementById("annotation-modal-submit-button"); +var $postAsComment = $("#annotation_post_as_comment"); +var posted = <%= @posted %>; +var isNewAnnotation = <%= @is_new_annotation %>; + +$postAsComment.on("change", function () { + // Don't show warnings if the annotation was already posted + if (posted) { + return; + } + + if (this.checked) { + const $warningElement = $("#annotation-comment-warning"); + const message = constructWarningMessage(); + $warningElement.html(message); + $warningElement.show(); + } + else { + $("#annotation-comment-warning").hide(); + } +}); + +function constructWarningMessage() { + let message = $("#annotation-locales").data("warningPublishing"); + + const mistakeRatio = $("#annotation_category_mistake"); + if (!mistakeRatio.is(":checked")) { + return message; + } + + // Mistake specific warnings + $.ajax(Routes.num_nearby_posted_mistake_annotations_path(), { + type: "GET", + dataType: "json", + async: false, + data: { + mediumId: thyme.dataset.medium, + timestamp: video.currentTime, + }, + success: function (count) { + const locale = $("#annotation-locales"); + message += "
    "; + if (!count) { + message += $("#annotation-locales").data("warningMistake"); + } + else if (count == 1) { + message += locale.data("warningOneCloseAnnotation"); + } + else { + message += locale.data("warningMultipleCloseAnnotations1") + + count + locale.data("warningMultipleCloseAnnotations2"); + } + return message; + }, + error: function (err) { + console.error("Error while fetching nearby annotations"); + console.error(err); + return message; + }, + }); + + return message; +} + +/* + * CATEGORY + */ + +var categoryRadios = document.getElementById("category-radios"); + +categoryRadios.addEventListener("click", function (evt) { + if (evt.target && event.target.matches("input[type='radio']")) { + switch (evt.target.value) { + case Category.NOTE.name: + note(); + break; + case Category.CONTENT.name: + content(); + break; + case Category.MISTAKE.name: + mistake(); + break; + case Category.PRESENTATION.name: + presentation(); + break; + } + } +}); + +function note() { + $("#annotation-category-specific").empty(); + submitButton.disabled = false; + visibleForTeacher(false); + postComment(false); +} + +function content() { + $("#annotation-category-specific").empty() + .append("<%= j render partial: "annotations/form_content"%>"); + // disable submit button until the content category is selected + submitButton.disabled = true; + visibleForTeacher(true); + postComment(false); + var contentCategoryRadios = document.getElementById("content-category-radios"); + contentCategoryRadios.addEventListener("click", function (evt) { + if (evt.target && event.target.matches("input[type='radio']")) { + submitButton.disabled = false; + + // Show further help + // (right now, the same help is display for all the different categories) + switch (evt.target.value) { + case Subcategory.DEFINITION.name: + showFurtherHelp(); + break; + case Subcategory.ARGUMENT.name: + showFurtherHelp(); + break; + case Subcategory.STRATEGY.name: + showFurtherHelp(); + break; + } + } + }); + + function showFurtherHelp() { + $("#content-specific").empty() + .append("<%= j render partial: "annotations/form_content_further_help"%>"); + } +} + +function mistake() { + $("#annotation-category-specific").empty(); + submitButton.disabled = false; + visibleForTeacher(true); + postComment(true); +} + +function presentation() { + $("#annotation-category-specific").empty(); + submitButton.disabled = false; + visibleForTeacher(true); + postComment(false); +} + +function updatePreview() { + const text = $("#annotation_comment").val(); + $("#annotation-modal-preview").empty(); + $("#annotation-modal-preview").append(text.replaceAll("\n", "
    ")); + renderMathInElement(document.getElementById("annotation-modal-preview"), { + delimiters: [ + { + left: "$$", + right: "$$", + display: true, + }, { + left: "$", + right: "$", + display: false, + }, { + left: "\\(", + right: "\\)", + display: false, + }, { + left: "\\[", + right: "\\]", + display: true, + }, + ], + throwOnError: false, + }); +} + +/* + * Color + */ +function initModalBackgroundAnnotationColor() { + // Init event handler + $("#annotation-color-picker").on("click", "input[type='radio']", function (event) { + setModalColor(event.target.value); + }); + + // New annotation + if (isNewAnnotation) { + const randomNumber = Math.floor(Math.random() * 15) + 1; + const colorRadio = $(`#annotation_color${randomNumber}`); + colorRadio.click(); + return; + } + + // Edit annotation + const $selectedColor = $("#annotation-color-picker input[type=radio]:checked"); + if ($selectedColor.length) { + $selectedColor.click(); + } +} + +function setModalColor(hexColorString) { + const color = thymeUtility.lightenUp(hexColorString, 2); + $(".modal-header").css("background-color", color); +} + +initModalBackgroundAnnotationColor(); + +/* + * Auxiliary methods + */ +function visibleForTeacher(isVisible) { + $("#annotation_visible_for_teacher").prop("checked", isVisible).trigger("change"); +} + +function postComment(isVisible) { + isVisible = posted ? true : isVisible; + $("#annotation_post_as_comment").prop("checked", isVisible).trigger("change"); +} + +function previewCSS(shouldShowPreview) { + if (shouldShowPreview) { + updatePreview(); + $("#annotation-preview-section").show(); + + $("#annotation-modal-dialog").removeClass("annotation-dialog-normal"); + $("#annotation-modal-dialog").addClass("annotation-dialog-expanded"); + + $("#annotation-content-section").addClass("annotation-content-spacing"); + $("#annotation-content-section").removeClass("annotation-content-normal"); + $("#annotation-content-section").addClass("annotation-content-expanded"); + } + else { + $("#annotation-preview-section").hide(); + + $("#annotation-modal-dialog").removeClass("annotation-dialog-expanded"); + $("#annotation-modal-dialog").addClass("annotation-dialog-normal"); + + $("#annotation-content-section").removeClass("annotation-content-spacing"); + $("#annotation-content-section").removeClass("annotation-content-expanded"); + $("#annotation-content-section").addClass("annotation-content-normal"); + } +} + +// Change modal title depending on the method that opens the modal +var editSpan = $("#modal-title-edit-annotation"); +var createSpan = $("#modal-title-create-annotation"); + +<% if action_name == 'new' %> +createSpan.show(); +editSpan.hide(); +<% elsif action_name == 'edit' %> +createSpan.hide(); +editSpan.show(); +<% end %> + +/* If this script is rendered by the edit method of the annotation controller: + Select correct subcategory (this is not automatically done by the rails form + as the content is dynamically rendered). */ +var contentRadio = $("#annotation_category_content"); +if (contentRadio.is(":checked")) { + content(); + submitButton.disabled = false; + var subcategory = document.getElementById("annotation_subcategory").textContent.replace(/[^a-z]/g, ""); + switch (subcategory) { + case Subcategory.DEFINITION.name: + document.getElementById("content-category-definition").checked = true; + break; + case Subcategory.ARGUMENT.name: + document.getElementById("content-category-argument").checked = true; + break; + case Subcategory.STRATEGY.name: + document.getElementById("content-category-strategy").checked = true; + break; + } +} + +// render preview +var annotationComment = document.getElementById("annotation_comment"); +annotationComment.addEventListener("input", function () { + updatePreview(); +}); +previewCSS(false); // Initialize modal without preview + +// preview toggle listener +var previewToggle = document.getElementById("preview-toggle"); +previewToggle.addEventListener("change", function () { + const shouldShowPreview = $("#preview-toggle-check").is(":checked"); + previewCSS(shouldShowPreview); +}); + +// disable post comment checkbox if annotation was already posted +if (posted) { + postComment(true); + $postAsComment.get(0).disabled = true; +} + +initBootstrapPopovers(); +previewCSS(true); diff --git a/app/views/annotations/update.js.erb b/app/views/annotations/update.js.erb new file mode 100644 index 000000000..10e43f354 --- /dev/null +++ b/app/views/annotations/update.js.erb @@ -0,0 +1,11 @@ +// Update annotations after submitting the annotations form +thymeAttributes.annotationManager.updateAnnotations(() => { + // After creation/update of an annotation, + // show the annotation in the annotation area. + <% if @annotation %> + var newAnnotationId = <%= @annotation.id %>; + thymeAttributes.annotationArea.showAnnotationWithId(newAnnotationId); + <% end %> +}); + +$("#annotation-modal").modal("hide"); diff --git a/app/views/commontator/comments/_body.html.erb b/app/views/commontator/comments/_body.html.erb index 6ec78b6ee..f6e3df238 100644 --- a/app/views/commontator/comments/_body.html.erb +++ b/app/views/commontator/comments/_body.html.erb @@ -4,3 +4,14 @@ %> <%= commontator_simple_format comment.body %> + + +<% annotation = comment.annotation %> +<% unless annotation.nil? %> + <% medium = annotation.medium %> + <% timestamp = annotation.timestamp %> +
    + Timestamp: + + <%= timestamp.hms_colon_string %> +<% end %> diff --git a/app/views/layouts/feedback.html.erb b/app/views/layouts/feedback.html.erb new file mode 100644 index 000000000..9b8a9bf9e --- /dev/null +++ b/app/views/layouts/feedback.html.erb @@ -0,0 +1,9 @@ + + + + <%= render partial: 'layouts/head' %> + + + <%= yield %> + + diff --git a/app/views/lectures/edit/_comments.html.erb b/app/views/lectures/edit/_comments.html.erb index af5a62fc0..cdc6345c5 100644 --- a/app/views/lectures/edit/_comments.html.erb +++ b/app/views/lectures/edit/_comments.html.erb @@ -57,8 +57,47 @@
    -
    - <% end %> + +
    + <%= t('admin.lecture.enable_annotation_button') %> + <%= helpdesk(t('admin.lecture.enable_annotation_button_helpdesk'), + false) %> +
    +
    + <%= f.radio_button :annotations_status, + 1, + class: 'form-check-input' %> + <%= f.label :annotations_status, + t('basics.yes_lc'), + value: 1, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :annotations_status, + -1, + class: 'form-check-input' %> + <%= f.label :annotations_status, + t('basics.no_lc'), + value: -1, + class: 'form-check-label' %> +
    +
    +
    +
    +
    +
    + <%= t('warnings.unsaved_changes') %> + <%= f.submit t('buttons.save_and_exit'), + class: "btn btn-sm btn-primary" %> + +
    +
    +
    + <% end %> +
    - \ No newline at end of file + diff --git a/app/views/lectures/edit/_preferences.html.erb b/app/views/lectures/edit/_preferences.html.erb index 9fb9adfcd..4ace30803 100644 --- a/app/views/lectures/edit/_preferences.html.erb +++ b/app/views/lectures/edit/_preferences.html.erb @@ -158,6 +158,55 @@ <% end %> + + <% if lecture.annotations_status == 1 %> +
    + <%= t('admin.lecture.emergency_link') %> + +
    + + + <% end %>
    diff --git a/app/views/media/_basics.html.erb b/app/views/media/_basics.html.erb index 075b1a76b..2e6cdd877 100644 --- a/app/views/media/_basics.html.erb +++ b/app/views/media/_basics.html.erb @@ -173,6 +173,45 @@ class: 'form-control' %>
    <% end %> + + <% if medium.video.present? %> +
    + <%= t('admin.lecture.enable_annotation_button') %> + <%= helpdesk(t('admin.lecture.enable_annotation_button_helpdesk') + + t('admin.lecture.enable_annotation_button_inherit_helpdesk'), + false) %> +
    +
    + <%= f.radio_button :annotations_status, + '0', + class: 'form-check-input' %> + <%= f.label :annotations_status, + t('admin.annotation.inherit_from_lecture'), + value: '0', + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :annotations_status, + '1', + class: 'form-check-input' %> + <%= f.label :annotations_status, + t('basics.yes_lc'), + value: '1', + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :annotations_status, + '-1', + class: 'form-check-input' %> + <%= f.label :annotations_status, + t('basics.no_lc'), + value: '-1', + class: 'form-check-label' %> +
    +
    +
    + <% end %> + <%= f.hidden_field :teachable_id, value: medium.teachable_id %> <%= f.hidden_field :teachable_type, value: medium.teachable_type %>
    diff --git a/app/views/media/_video.html.erb b/app/views/media/_video.html.erb index 7912702d0..ce6ae4e20 100644 --- a/app/views/media/_video.html.erb +++ b/app/views/media/_video.html.erb @@ -43,6 +43,9 @@ <%= link_to t('basics.thyme_editor'), enrich_medium_path(medium), class: 'btn btn-sm btn-outline-secondary' %> + <%= link_to 'Feedback', + feedback_medium_path(medium), + class: 'btn btn-sm btn-outline-secondary' %> <%= link_to t('basics.thyme'), play_medium_path(medium), class: 'btn btn-outline-secondary btn-sm', @@ -53,4 +56,4 @@ <%= render partial: 'media/upload_video', locals: { medium: medium } %> <%= f.hidden_field :detach_video, value: false %> -
    \ No newline at end of file +
    diff --git a/app/views/media/comments/_comments.html.erb b/app/views/media/comments/_comments.html.erb index 54f70ab6f..e1d385eac 100644 --- a/app/views/media/comments/_comments.html.erb +++ b/app/views/media/comments/_comments.html.erb @@ -18,4 +18,4 @@ style="max-height: 30vh; overflow-y: scroll;"> <%= commontator_thread(medium) %>
    - \ No newline at end of file + diff --git a/app/views/media/enrich.html.erb b/app/views/media/enrich.html.erb index 1ec644eb3..99a5007b8 100644 --- a/app/views/media/enrich.html.erb +++ b/app/views/media/enrich.html.erb @@ -23,7 +23,7 @@ 0:00:00 -
    +
    replay_10 diff --git a/app/views/media/feedback.html.erb b/app/views/media/feedback.html.erb new file mode 100644 index 000000000..5bc8367df --- /dev/null +++ b/app/views/media/feedback.html.erb @@ -0,0 +1,88 @@ +<% content_for :title, "THymE - \"#{@medium.caption}\"" %> +
    +
    +
    + + +
    + + + +
    +
    + +
    + + play_arrow + + + 0:00:00 + + + + + + + + + + 0:00:00 + +
    + + +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    + +
    + + replay_10 + + + forward_10 + +
    + +
    + + volume_up + + +
    + +
    +
    +
    + + + <%= render partial: "annotations/annotation_area" %> +
    +
    +
    + +<%= render partial: "annotations/annotation_locales" %> diff --git a/app/views/media/play.html.erb b/app/views/media/play.html.erb index 476ad8e21..a77620609 100644 --- a/app/views/media/play.html.erb +++ b/app/views/media/play.html.erb @@ -1,6 +1,6 @@ <% content_for :title, "THymE - \"#{@medium.caption}\"" %> -
    -
    +
    +
    -
    +
    replay_10 @@ -41,7 +44,7 @@ skip_next
    -
    +
    volume_up @@ -58,6 +61,21 @@
    + + + <% if user_signed_in? %> + + + + <% end %> + + + +
    @@ -71,6 +89,7 @@
    + <%= render partial: "annotations/annotation_area" %>
    + +<% if user_signed_in? %> + <%= render partial: "annotations/annotation_modal" %> + <%= render partial: "annotations/annotation_locales" %> +<% end %> \ No newline at end of file diff --git a/app/views/media/show_comments.html.erb b/app/views/media/show_comments.html.erb index 013b4c680..239e6edc7 100644 --- a/app/views/media/show_comments.html.erb +++ b/app/views/media/show_comments.html.erb @@ -13,4 +13,4 @@
    <%= commontator_thread(@medium) %> -
    \ No newline at end of file +
    diff --git a/config/application.rb b/config/application.rb index b5e4e0f1e..85776e7ff 100644 --- a/config/application.rb +++ b/config/application.rb @@ -11,6 +11,19 @@ class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults(7.0) config.autoloader = :zeitwerk + + # Autoload lib extensions path (ignore all other subdirectories of lib/) + lib_path = Rails.root.join("lib") + config.autoload_paths << lib_path + config.eager_load_paths << lib_path + Rails.autoloaders.main.ignore( + lib_path.join("assets"), + lib_path.join("collectors"), + lib_path.join("core_ext"), + lib_path.join("scrapers"), + lib_path.join("tasks") + ) + config.i18n.default_locale = :de config.i18n.fallbacks = [:en] config.i18n.available_locales = [:de, :en] diff --git a/config/initializers/commontator.rb b/config/initializers/commontator.rb index 78a775e9d..bc76ad318 100644 --- a/config/initializers/commontator.rb +++ b/config/initializers/commontator.rb @@ -314,3 +314,17 @@ # Default: false config.mentions_enabled = false end + +Rails.application.config.to_prepare do + # Load the Commontator extensions only if we are not in the assets:precompile + # step where no db connection is available. See the production Dockerfile. + db_adapter = ENV.fetch("DB_ADAPTER", nil) + if db_adapter == "nulldb" + Rails.logger.info("DB_ADAPTER env var is #{db_adapter}. Skipping Commontator extensions.") + next + end + + if ActiveRecord::Base.connection.table_exists?(:thredded_topics) + Commontator::Comment.include(Extensions::Commontator::Comment) + end +end diff --git a/config/locales/de.yml b/config/locales/de.yml index 13ae67031..75f7e2cc6 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -322,6 +322,90 @@ de: title: 'Titel' teacher: 'DozentIn' editors: 'EditorInnen' + annotation: + annotations: 'Annotationen' + annotation_modal_head_create_annotation: 'Annotation anlegen' + annotation_modal_head_edit_annotation: 'Annotation bearbeiten' + inherit_from_lecture: 'von Vorlesung erben' + time: 'Zeit:' + comment: 'Kommentar' + color: 'Farbe' + category: 'Kategorie' + category_tooltip: > + Verwende "Notiz" für persönliche Notizen, "Inhalt" wenn Du den Inhalt + des Videos an dieser Stelle nicht verstehst, "Fehler" wenn Du einen + Fehler entdeckt hast, und "Darstellung" wenn Dir die Darstellung im + Video Probleme bereitet, z.B. falls Du die Schrift nicht lesen kannst + oder der Ton unverständlich ist. + note: 'Notiz' + note_tooltip: > + Wähle diese Kategorie aus, wenn Du Deine Annotation als + persönliche Notiz anlegen möchtest. + content: 'Inhalt' + content_tooltip: > + Wähle diese Kategorie aus, wenn Du den Inhalt des Videos an dieser + Stelle nicht verstehst. + mistake: 'Fehler' + mistake_tooltip: > + Wähle diese Kategorie aus, wenn Du der Meinung bist, an dieser + Stelle im Video wurde ein inhaltlicher Fehler gemacht. + presentation: 'Darstellung' + presentation_tooltip: > + Wähle diese Kategorie aus, wenn Dir die Darstellung im Video + an dieser Stelle Probleme bereitet, z.B. falls Du die Schrift + nicht lesen kannst oder falls der Ton unverständlich ist. + whats_the_problem: > + Wo liegt das Problem? + definition: 'Definition' + definition_tooltip: > + Wähle diese Unterkategorie aus, wenn Du Probleme mit einer Definition hast. + argument: 'Argument' + argument_tooltip: > + Wähle diese Unterkategorie aus, wenn Du Probleme mit einem Argument + (z.B. in einem Beweis) hast. + strategy: 'Beweisstrategie' + strategy_tooltip: > + Wähle diese Unterkategorie aus, wenn Du Probleme mit der allgemeinen + (Beweis-)Strategie hast. + previous_annotation: 'vorherige Annotation' + annotation_position: 'zur Annotation springen' + edit_annotation: 'Annotation editieren' + close_annotation_area: 'Feld schließen' + next_annotation: 'nächste Annotation' + further_help: 'Du brauchst weitere Hilfe? Dann klicke auf:' + warning_message: + publishing: > + Jeder, der diese Vorlesung abonniert hat, wird Deinen Kommentar + lesen können, sobald Du auf "Speichern" klickst. + Falls Du das nicht möchtest, entferne bitte wieder den Haken + bei "Als Kommentar veröffentlichen?". + mistake: > + Bevor Du diesen Kommentar abschickst, überprüfe außerdem bitte, + ob bereits ein Kommentar zu dem von Dir gefundenen Fehler existiert. + one_close_annotation: > + Beachte: in der Nähe Deiner Annotation gibt es bereits eine veröffentlichte + Fehler-Annotation. + multiple_close_annotations_1: "Beachte: in der Nähe Deiner Annotation gibt es bereits " + multiple_close_annotations_2: " veröffentlichte Fehler-Annotationen." + permission: "Dir fehlen die Rechte, um diese Annotation zu bearbeiten." + double_posted: > + Es wurde bereits eine Annotation mit dem gleichen Inhalt zu diesem Medium + veröffentlicht. + sure_to_delete: > + Diese Annotation wirklich löschen? + visible_for_teacher: 'Für DozentIn sichtbar?' + visible_for_teacher_helpdesk: > + Falls diese Checkbox ausgewählt ist, kann Dein(e) Dozent(in) diese Annotation + in ihrem/seinem Thyme-Player (ohne Deinen Namen) sehen. + post_as_comment: 'Als Kommentar veröffentlichen?' + post_as_comment_helpdesk: > + Falls diese Checkbox ausgewählt ist, wird diese Annotation automatisch + als Kommentar zu diesem Video veröffentlicht. + toggle: + note: "Notiz-Annotationen ein-/ausblenden" + content: "Inhalt-Annotationen ein-/ausblenden" + mistake: "Fehler-Annotationen ein-/ausblenden" + presentation: "Darstellung-Annotationen ein-/ausblenden" announcement: help: > Hier kannst Du den Text eingeben. Du kannst LaTeX benutzen @@ -371,6 +455,16 @@ de: no_imported_media: 'Es sind keine importierten Medien vorhanden.' comments_disabled: > Kommentare für neu veröffentlichte Medien sind standardmäßig deaktiviert + enable_annotation_button: 'Dürfen Studierende Annotationen für Sie sichtbar machen?' + enable_annotation_button_helpdesk: > + Wenn Sie "ja" anwählen, dann haben Studierende die Möglichkeit, + ihre Annotationen für den/die Dozent*in bzw. alle Editor*innen + der Vorlesung sichtbar machen. Sie können diese dann entweder + im normalen Thyme-Player oder im Thyme-Feedback-Player einsehen + und dadurch Rückmeldung zu Ihren Vorlesungsvideos erhalten. + enable_annotation_button_inherit_helpdesk: > + Ist die Option "von Vorlesung erben" ausgewählt, dann wird die + globale Option aus den Vorlesungseinstellungen (Kommentare) übernommen. new_notifications: 'neue Benachrichtigungen' new_media: 'neue Medien' new_posts: 'neue Forenbeiträge' @@ -620,6 +714,12 @@ de: script_based: > unter Verwendung eines Veranstaltungsskriptes, das mit dem MaMpf LaTeX-Paket erstellt wurde + emergency_link: 'Link für den Emergency-Button' + emergency_link_no_link: 'kein Link' + emergency_link_lecture_link: 'Vorlesungslink' + emergency_link_direct_link: 'direkter Link' + enter_emergency_link: 'Emergency-Link eingeben:' + select_helpdesk: 'Helpdesk auswählen:' no_chapters: 'Es sind noch keine Kapitel vorhanden.' no_talks: Es sind noch keine Vorträge vorhanden. orphaned_lessons: 'Verwaiste Sitzungen' diff --git a/config/locales/en.yml b/config/locales/en.yml index 0f738f912..b7a7ca624 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -323,6 +323,87 @@ en: title: 'Title' teacher: 'Teacher' editors: 'Editors' + annotation: + annotations: 'Annotations' + annotation_modal_head_create_annotation: 'Create annotation' + annotation_modal_head_edit_annotation: 'Edit annotation' + inherit_from_lecture: 'inherit from lecture' + time: 'Time:' + comment: 'Comment' + color: 'Color' + category: 'Category' + category_tooltip: > + Use "note" for personal notes, "content" if you don't understand the + content of the video at this position, "mistake" if you've spot a mistake, + and "presentation" if you have problems with the presentation, + e.g. you cannot read the handwriting or cannot understand the audio. + note: 'note' + note_tooltip: > + Select this category if you want to create your annotation + as a personal note. + content: 'content' + content_tooltip: > + Select this category if you don't understand the content + of the video at this position. + mistake: 'mistake' + mistake_tooltip: > + Select this category if you think there has been made a mistake + in the content of the video at this position. + presentation: 'presentation' + presentation_tooltip: > + Select this category if you have problems with the presentation + in the video at this position, e.g. if you cannot read the + handwriting or if you cannot understand the audio. + whats_the_problem: > + What's the problem? + definition: 'definition' + definition_tooltip: > + Select this subcategory if you have problems with a definition. + argument: 'argument' + argument_tooltip: > + Select this subcategory if you have problems with an argument + (e.g. in a proof). + strategy: 'proof strategy' + strategy_tooltip: > + Select this subcategory if you have problems with the general + (proof) strategy. + previous_annotation: 'previous annotation' + annotation_position: 'go to annotation' + edit_annotation: 'edit annotation' + close_annotation_area: 'close area' + next_annotation: 'next annotation' + further_help: 'You need further help? Then click on:' + warning_message: + publishing: > + Everyone who subscribed this lecture will be able to read + your comment once you click "Save". + If this is not what you intended to do, + please unselect "Post as comment?". + mistake: > + Before submitting, please also check, if someone already posted + a comment concerning this mistake. + one_close_annotation: > + Note there is already a mistake annotation around this timestamp. + multiple_close_annotations_1: "Note there are already " + multiple_close_annotations_2: " mistake annotations close to yours." + permission: "You don't have the permission to edit this annotation." + double_posted: > + An annotation with the same content has already been published. + sure_to_delete: > + Really delete this annotation? + visible_for_teacher: 'Visible for teacher?' + visible_for_teacher_helpdesk: > + If this checkbox is selected your teacher can see this annotation + (without your name!) in their own thyme player. + post_as_comment: 'Post as comment?' + post_as_comment_helpdesk: > + If this checkbox is selected, this annotation will be published as + comment for this video. + toggle: + note: "toggle note annotations" + content: "toggle content annotations" + mistake: "toggle mistake annotations" + presentation: "toggle presentation annotations" announcement: help: > Here you can enter a text. You may use LaTeX (by putting the @@ -372,6 +453,16 @@ en: close_comments: 'Close all threads for related media' comments_disabled: > Comments for newly published media are disabled by default + enable_annotation_button: 'Are students allowed to make annotations visible for you?' + enable_annotation_button_helpdesk: > + If you select "yes", students have the possibility to make + their annotations visible for the teacher resp. all editors + of the lecture. You can then see them either in the normal + thyme player or in the thyme feedback player to get feedback + for your lecture videos. + enable_annotation_button_inherit_helpdesk: > + If the option "inherit from lecture" is selected, the global + option of the lecture preferences (comments) is taken. new_notifications: 'new notifications' new_media: 'new media' new_posts: 'new forum posts' @@ -590,6 +681,12 @@ en: content_mode: 'Content determination' video_based: 'media based' script_based: 'using a manuscript generated by the MaMpf LaTeX package' + emergency_link: 'Link for the emergency button' + emergency_link_no_link: 'no link' + emergency_link_lecture_link: 'lecture link' + emergency_link_direct_link: 'direct link' + enter_emergency_link: 'Enter emergency link:' + select_helpdesk: 'Select helpdesk:' no_chapters: 'There are no chapters yet.' no_talks: 'There are no talks yet.' orphaned_lessons: 'Orphaned Sessions' diff --git a/config/routes.rb b/config/routes.rb index 470168970..cb0b1c763 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,18 @@ to: "administration#classification", as: "classification" + # annotation routes + + get "annotations/update_annotations", + to: "annotations#update_annotations", + as: "update_annotations" + + get "annotations/num_nearby_posted_mistake_annotations", + to: "annotations#num_nearby_posted_mistake_annotations", + as: "num_nearby_posted_mistake_annotations" + + resources :annotations, only: [:new, :create, :edit, :update, :destroy] + # announcements routes post "announcements/:id/propagate", @@ -283,6 +295,10 @@ to: "media#inspect", as: "inspect_medium" + get "media/:id/feedback", + to: "media#feedback", + as: "feedback_medium" + get "media/:id/enrich", to: "media#enrich", as: "enrich_medium" @@ -393,6 +409,10 @@ to: "media#fill_reassign_modal", as: "fill_reassign_modal" + get "media/:id/check_annotation_visibility", + to: "media#check_annotation_visibility", + as: "check_annotation_visibility" + resources :media # notifications controller diff --git a/db/migrate/20240329230000_create_annotations.rb b/db/migrate/20240329230000_create_annotations.rb new file mode 100644 index 000000000..af851eb71 --- /dev/null +++ b/db/migrate/20240329230000_create_annotations.rb @@ -0,0 +1,17 @@ +class CreateAnnotations < ActiveRecord::Migration[7.0] + def change + create_table :annotations do |t| + t.references :medium, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.text :timestamp, null: false + t.text :comment + t.string :color, null: false + t.integer :category, null: false + t.integer :subcategory + t.boolean :visible_for_teacher, default: false, null: false + t.integer :public_comment_id + + t.timestamps + end + end +end diff --git a/db/migrate/20240329230010_add_annotation_related_fields.rb b/db/migrate/20240329230010_add_annotation_related_fields.rb new file mode 100644 index 000000000..ac846bdc0 --- /dev/null +++ b/db/migrate/20240329230010_add_annotation_related_fields.rb @@ -0,0 +1,15 @@ +class AddAnnotationRelatedFields < ActiveRecord::Migration[7.0] + def change + # Annotations status + # Media inherits annotation status from lecture by default + add_column :media, :annotations_status, :integer, default: -1, null: false + # Lecture: activate "share annotation with lecturer" feature by default + add_column :lectures, :annotations_status, :integer, default: 1, null: false + + # Emergency Link + change_table :lectures, bulk: true do |t| + t.integer :emergency_link_status, default: 0, null: false + t.text :emergency_link + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d17bf4c82..70e865353 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,27 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_03_19_130000) do +ActiveRecord::Schema[7.0].define(version: 2024_03_29_230010) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" + create_table "annotations", force: :cascade do |t| + t.bigint "medium_id", null: false + t.bigint "user_id", null: false + t.text "timestamp", null: false + t.text "comment" + t.string "color", null: false + t.integer "category", null: false + t.integer "subcategory" + t.boolean "visible_for_teacher", default: false, null: false + t.integer "public_comment_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["medium_id"], name: "index_annotations_on_medium_id" + t.index ["user_id"], name: "index_annotations_on_user_id" + end + create_table "announcements", force: :cascade do |t| t.bigint "lecture_id" t.bigint "announcer_id" @@ -282,6 +298,9 @@ t.integer "submission_max_team_size" t.integer "submission_grace_period", default: 15 t.boolean "legacy_seminar", default: false + t.integer "annotations_status", default: 1, null: false + t.integer "emergency_link_status", default: 0, null: false + t.text "emergency_link" t.index ["teacher_id"], name: "index_lectures_on_teacher_id" t.index ["term_id"], name: "index_lectures_on_term_id" end @@ -361,6 +380,7 @@ t.text "publisher" t.datetime "file_last_edited", precision: nil t.text "external_link_description" + t.integer "annotations_status", default: -1, null: false t.index ["quizzable_type", "quizzable_id"], name: "index_media_on_quizzable_type_and_quizzable_id" t.index ["teachable_type", "teachable_id"], name: "index_media_on_teachable_type_and_teachable_id" end @@ -413,7 +433,7 @@ t.index ["subject_id"], name: "index_programs_on_subject_id" end - create_table "quiz_certificates", id: :uuid, default: -> { "public.gen_random_uuid()" }, force: :cascade do |t| + create_table "quiz_certificates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.bigint "quiz_id", null: false t.bigint "user_id" t.text "code" @@ -499,7 +519,7 @@ t.datetime "updated_at", null: false end - create_table "submissions", id: :uuid, default: -> { "public.gen_random_uuid()" }, force: :cascade do |t| + create_table "submissions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.bigint "tutorial_id", null: false t.bigint "assignment_id", null: false t.text "token" @@ -913,6 +933,8 @@ t.index ["watchlist_entry_id"], name: "index_watchlists_on_watchlist_entry_id" end + add_foreign_key "annotations", "media" + add_foreign_key "annotations", "users" add_foreign_key "announcements", "lectures" add_foreign_key "announcements", "users", column: "announcer_id" add_foreign_key "assignments", "lectures" diff --git a/eslint.config.mjs b/eslint.config.mjs index 38122f670..0d2ada414 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -33,6 +33,59 @@ const customGlobals = { // Common global methods initBootstrapPopovers: "readable", + + // Thyme & Annotation tool globals + // TODO: This is a "hack" right now to get rid of "xy is not defined" error + // messages in ESLint. + // In an ideal world, we would use the new ES6 module syntax, but that is a + // bigger undertaking as we have to get rid of rails webpacker and use + // webpack itself or even better try to use the new import maps. + // See the links in this issue: https://github.com/MaMpf-HD/mampf/issues/454 + thyme: "readable", + video: "readable", + thymeAttributes: "readable", + thymeKeyShortcuts: "readable", + thymeUtility: "readable", + Resizer: "readable", + + ControlBarHider: "readable", + + ChapterManager: "readable", + DisplayManager: "readable", + MetadataManager: "readable", + + Component: "readable", + Category: "readable", + CategoryEnum: "readable", + Subcategory: "readable", + VolumeBar: "readable", + TimeButton: "readable", + MuteButton: "readable", + PlayButton: "readable", + SeekBar: "readable", + FullScreenButton: "readable", + NextChapterButton: "readable", + PreviousChapterButton: "readable", + SpeedSelector: "readable", + AddItemButton: "readable", + AddReferenceButton: "readable", + AddScreenshotButton: "readable", + IaBackButton: "readable", + IaButton: "readable", + IaCloseButton: "readable", + + seekBar: "writable", + + Annotation: "readable", + AnnotationManager: "readable", + AnnotationArea: "readable", + AnnotationsToggle: "readable", + AnnotationCategoryToggle: "readable", + AnnotationButton: "readable", + Heatmap: "readable", + + // KaTeX + renderMathInElement: "readable", }; // We don't have cypress linting yet, as the Cypress ESLint plugin diff --git a/lib/extensions/commontator/comment.rb b/lib/extensions/commontator/comment.rb new file mode 100644 index 000000000..3da558ce4 --- /dev/null +++ b/lib/extensions/commontator/comment.rb @@ -0,0 +1,15 @@ +module Extensions + module Commontator + module Comment + extend ActiveSupport::Concern + + included do + has_one :annotation, foreign_key: :public_comment_id + + def medium + thread.commontable + end + end + end + end +end