From 0a707116953d3663554685f7486536053dcf57d1 Mon Sep 17 00:00:00 2001 From: Mantas Masalskis Date: Wed, 19 Feb 2020 18:07:52 +0100 Subject: [PATCH] Fixes #2713 - Knowledge Base does't support animated GIFs and embedded Video (Youtube/Vimeo) content. --- .../_rich_text_tool_button.coffee | 55 ++------------- .../_rich_text_tool_button_link.coffee | 46 ++++++++++++ .../_rich_text_tool_popup.coffee | 31 ++++---- .../embed_video_button.coffee | 63 +++++++++++++++++ .../insert_image_button.coffee | 18 +++++ .../link_answer_button.coffee | 2 +- .../richtext_additions/link_button.coffee | 2 +- .../richtext_additions/popup_answer.coffee | 9 ++- .../richtext_additions/popup_image.coffee | 58 +++++++++++++++ .../richtext_additions/popup_link.coffee | 9 ++- .../richtext_additions/popup_video.coffee | 70 +++++++++++++++++++ .../knowledge_base/reader_controller.coffee | 36 +++++++++- .../app/models/knowledge_base_answer.coffee | 2 + app/assets/stylesheets/knowledge_base.scss | 15 ++++ app/assets/stylesheets/zammad.scss | 16 +++++ .../knowledge_base_rich_text_helper.rb | 26 +++++++ app/models/concerns/has_rich_text.rb | 15 ++++ .../public/answers/show.html.erb | 2 +- spec/factories/knowledge_base/answer.rb | 11 ++- .../knowledge_base/answer/translation.rb | 4 ++ .../answer/translation/content.rb | 4 ++ spec/support/knowledge_base_contexts.rb | 4 ++ .../knowledge_base/locale/answer/edit_spec.rb | 29 +++++++- .../knowledge_base_public/answer_spec.rb | 19 +++++ 24 files changed, 474 insertions(+), 72 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button_link.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/embed_video_button.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/insert_image_button.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_image.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_video.coffee create mode 100644 spec/system/knowledge_base_public/answer_spec.rb diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee index 4e3bb9bdc..00672392d 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee @@ -5,10 +5,13 @@ class App.UiElement.richtext.additions.RichTextToolButton @klass: -> # Needs implementation. Return constructor of RichTextToolPopup subclass. + @pickExisting: (sel, textEditor) -> + # needs implementation + @initializeAttributes: {} @instantiateContent: (event, selection, delegate) -> - attrs = @initializeAttributes + attrs = $.extend(true, {}, @initializeAttributes) attrs['event'] = event attrs['selection'] = selection @@ -35,46 +38,6 @@ class App.UiElement.richtext.additions.RichTextToolButton hash - @pickLinkInSingleContainer: (elem, containerToLookUpTo) -> - if elem.nodeName == 'A' - elem - else if innerLink = $(elem).find('a')[0] - innerLink - else if containerToLookUpTo and closestLink = $(elem).closest('a', containerToLookUpTo)[0] - closestLink - else - null - - @pickLinkAt: (elem, container, direction, boundary = null) -> - for parent in App.UiElement.richtext.buildParentsListWithSelf(elem, container) - if parent.nodeName is 'A' - return parent - - for elem in App.UiElement.richtext.allDirectionalSiblings(parent, direction, boundary) - if link = @pickLinkInSingleContainer(elem) - return link - - null - - @pickLink: (sel, textEditor) -> - range = sel.getRangeAt(0) - - if range.startContainer == range.endContainer - return @pickLinkInSingleContainer(range.startContainer, textEditor) - - if link = @pickLinkAt(range.startContainer, range.commonAncestorContainer, 1, range.endContainer) - return link - - if startParent = App.UiElement.richtext.buildParentsList(range.startContainer, range.commonAncestorContainer).pop() - for elem in App.UiElement.richtext.allDirectionalSiblings(startParent, 1, range.endContainer) - if link = @pickLinkInSingleContainer(elem) - return link - - if link = @pickLinkAt(range.endContainer, range.commonAncestorContainer, -1) - return link - - return null - # close other buttons' popovers @closeOtherPopovers: (event) -> $(event.currentTarget) @@ -88,15 +51,10 @@ class App.UiElement.richtext.additions.RichTextToolButton @selectionSnapshot: (sel) -> textEditor = $(event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') - if sel.isCollapsed and selectedLink = $(sel.anchorNode).closest('a')[0] + if selected = @pickExisting(sel, textEditor) { type: 'existing' - dom: $(selectedLink) - } - else if !sel.isCollapsed and selectedLink = @pickLink(sel, textEditor) - { - type: 'existing' - dom: $(selectedLink) + dom: $(selected) } else if sel.type is 'Range' and $(sel.anchorNode).closest('[contenteditable]', textEditor)[0] range = sel.getRangeAt(0) @@ -117,6 +75,7 @@ class App.UiElement.richtext.additions.RichTextToolButton dom: textEditor } + # on clicking button above rich text area @onClick: (event, delegate) -> event.stopPropagation() event.preventDefault() diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button_link.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button_link.coffee new file mode 100644 index 000000000..f3b3cc4d4 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button_link.coffee @@ -0,0 +1,46 @@ +class App.UiElement.richtext.additions.RichTextToolButtonLink extends App.UiElement.richtext.additions.RichTextToolButton + @pickLinkInSingleContainer: (elem, containerToLookUpTo) -> + if elem.nodeName == 'A' + elem + else if innerLink = $(elem).find('a')[0] + innerLink + else if containerToLookUpTo and closestLink = $(elem).closest('a', containerToLookUpTo)[0] + closestLink + else + null + + @pickLinkAt: (elem, container, direction, boundary = null) -> + for parent in App.UiElement.richtext.buildParentsListWithSelf(elem, container) + if parent.nodeName is 'A' + return parent + + for elem in App.UiElement.richtext.allDirectionalSiblings(parent, direction, boundary) + if link = @pickLinkInSingleContainer(elem) + return link + + null + + @pickExisting: (sel, textEditor) -> + if sel.isCollapsed and link = $(sel.anchorNode).closest('a')[0] + return link + + if sel.isCollapsed + return null + + range = sel.getRangeAt(0) + + if range.startContainer == range.endContainer + return @pickLinkInSingleContainer(range.startContainer, textEditor) + + if link = @pickLinkAt(range.startContainer, range.commonAncestorContainer, 1, range.endContainer) + return link + + if startParent = App.UiElement.richtext.buildParentsList(range.startContainer, range.commonAncestorContainer).pop() + for elem in App.UiElement.richtext.allDirectionalSiblings(startParent, 1, range.endContainer) + if link = @pickLinkInSingleContainer(elem) + return link + + if link = @pickLinkAt(range.endContainer, range.commonAncestorContainer, -1) + return link + + return null diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee index c7fb0fe3c..5f993f8bd 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee @@ -1,7 +1,11 @@ class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerForm events: 'submit form': 'onSubmit' - 'click .js-unlink': 'onUnlink' + 'click .js-clear': 'onClear' + + labelNew: 'Link' + labelExisting: 'Update' + labelClear: 'Remove' formParams: (params) -> # needs implementation @@ -9,13 +13,13 @@ class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerF constructor: (params) -> if params.selection.type is 'existing' url = params.selection.dom.attr('href') - label = 'Update' + label = @labelExisting additional = [{ - className: 'btn btn--danger js-unlink' - text: 'Remove' + className: 'btn btn--danger js-clear' + text: @labelClear }] else - label = 'Link' + label = @labelNew defaultParams = params: @formParams(params) @@ -39,16 +43,17 @@ class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerF getAjaxAttributes: (field, attributes) -> @delegate?.getAjaxAttributes?(field, attributes) - onUnlink: (e) -> + onClear: (e) => e.preventDefault() e.stopPropagation() - switch @selection.type - when 'existing' - $(@selection.dom).contents().unwrap() + @clear() $(@event.currentTarget).popover('hide') + clear: -> + # needs implementation + @wrapElement: (wrapper, selection) -> topLevelOriginals = App.UiElement.richtext.buildParentsList(selection.range.startContainer, selection.range.commonAncestorContainer).reverse() @@ -114,15 +119,15 @@ class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerF wrapper.insertAfter(topLevelOriginalStart) - wrapLink: -> + apply: (callback) -> # needs implementation + callback() onSubmit: (e) -> e.preventDefault() - @wrapLink() - - $(@event.currentTarget).popover('destroy') + @apply => + $(@event.currentTarget).popover('destroy') didInitialize: -> switch @selection.type diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/embed_video_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/embed_video_button.coffee new file mode 100644 index 000000000..74e0a4886 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/embed_video_button.coffee @@ -0,0 +1,63 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.richtext.toolButtons.embed_video extends App.UiElement.richtext.additions.RichTextToolButton + @icon: 'cloud' + @text: 'Video' + @klass: -> App.UiElement.richtext.additions.RichTextToolPopupVideo + @initializeAttributes: + model: + configure_attributes: [ + { + name: 'link' + display: 'Link' + tag: 'input' + placeholder: 'Youtube or Vimeo address' + } + ] + + @pickExisting: (sel, textEditor) -> + startNode = null + startOffset = null + + endNode = null + endOffset = null + + return if !textEditor[0].contains(sel.anchorNode) + + walker = document.createTreeWalker(textEditor[0]) + + walker.currentNode = sel.anchorNode + + while !startNode and (walker.currentNode.nodeName == '#text' || walker.currentNode.nodeName == 'SPAN') and walker.currentNode + if walker.currentNode instanceof Text + offset = walker.currentNode.textContent.indexOf '(' + if offset? and offset > -1 + startNode = walker.currentNode + startOffset = offset + + walker.previousNode() + + walker.currentNode = sel.anchorNode # back to start + + while !endNode and (walker.currentNode.nodeName == '#text' || walker.currentNode.nodeName == 'SPAN') and walker.currentNode + if walker.currentNode instanceof Text + offset = walker.currentNode.textContent.indexOf ')' + if offset? and offset > -1 and (walker.currentNode != sel.anchorNode || offset > startOffset) + endNode = walker.currentNode + endOffset = offset + 1 + + walker.nextNode() + + if startNode and endNode + range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + + copy = range.cloneContents() + + wrapper = document.createElement('span') + wrapper.append(copy) + + range.deleteContents() + range.insertNode(wrapper) + + wrapper diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/insert_image_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/insert_image_button.coffee new file mode 100644 index 000000000..a13f3a710 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/insert_image_button.coffee @@ -0,0 +1,18 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.richtext.toolButtons.insert_image extends App.UiElement.richtext.additions.RichTextToolButton + @icon: 'web' + @text: 'Image' + @klass: -> App.UiElement.richtext.additions.RichTextToolPopupImage + @initializeAttributes: + model: + configure_attributes: [ + { + name: 'link' + display: 'Image' + tag: 'input' + type: 'file' + } + ] + + @pickExisting: (sel, textEditor) -> + selectedImage = textEditor.find('img.objectResizingEditorActive')[0] diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee index 9cd0b4b1a..15234713f 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee @@ -1,5 +1,5 @@ # coffeelint: disable=camel_case_classes -class App.UiElement.richtext.toolButtons.link_answer extends App.UiElement.richtext.additions.RichTextToolButton +class App.UiElement.richtext.toolButtons.link_answer extends App.UiElement.richtext.additions.RichTextToolButtonLink @icon: 'knowledge-base-answer' @text: 'Link Answer' @klass: -> App.UiElement.richtext.additions.RichTextToolPopupAnswer diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee index 283ca3e5a..f0f3aff09 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee @@ -1,5 +1,5 @@ # coffeelint: disable=camel_case_classes -class App.UiElement.richtext.toolButtons.link extends App.UiElement.richtext.additions.RichTextToolButton +class App.UiElement.richtext.toolButtons.link extends App.UiElement.richtext.additions.RichTextToolButtonLink @icon: 'chain' @text: 'Weblink' @klass: -> App.UiElement.richtext.additions.RichTextToolPopupLink diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee index 776d4096b..775ba50a8 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee @@ -18,7 +18,7 @@ class App.UiElement.richtext.additions.RichTextToolPopupAnswer extends App.UiEle dom - wrapLink: -> + apply: (callback) -> id = @el.find('input').val() object = App.KnowledgeBaseAnswerTranslation.find(id) textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') @@ -41,3 +41,10 @@ class App.UiElement.richtext.additions.RichTextToolPopupAnswer extends App.UiEle @applyOnto(newElem, object) placeholder.wrap(newElem) placeholder.contents() + + callback() + + clear: -> + switch @selection.type + when 'existing' + $(@selection.dom).contents().unwrap() diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_image.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_image.coffee new file mode 100644 index 000000000..90be3df2e --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_image.coffee @@ -0,0 +1,58 @@ +class App.UiElement.richtext.additions.RichTextToolPopupImage extends App.UiElement.richtext.additions.RichTextToolPopup + labelNew: 'Insert' + labelExisting: 'Replace' + + apply: (callback) -> + @el.find('btn--create').attr('disabled', true) + + file = @el.find('input')[0].files[0] + + reader = new FileReader() + + reader.addEventListener('load', => + @insertImage(reader.result) + callback() + , false) + + reader.readAsDataURL(file) + + applyOnto: (dom, base64) -> + dom.attr('src', base64) + + insertImage: (base64) -> + textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + + switch @selection.type + when 'existing' + @applyOnto(@selection.dom, base64) + when 'append' + newElem = $('')[0] + newElem.src = base64 + newElem.style = 'width: 1000px; max-width: 100%;' + @selection.dom.append(newElem) + when 'caret' + newElem = $('') + newElem.attr('src', base64) + newElem.attr('style', 'width: 1000px; max-width: 100%;') + + surroundingDom = @selection.dom[0] + + if surroundingDom instanceof Text + @selection.dom[0].splitText(@selection.offset) + + newElem.insertAfter(@selection.dom) + when 'range' + newElem = $('') + newElem.attr('src', base64) + newElem.attr('style', 'width: 1000px; max-width: 100%;') + + placeholder = textEditor.find('span.highlight-emulator') + + placeholder.empty() + placeholder.append(newElem) + + clear: -> + switch @selection.type + when 'existing' + @selection.dom.closest('.enableObjectResizingShim').remove() + @selection.dom.remove() # just in case shim was lost while the dialog was open diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee index 0b6a59d2d..a73581623 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee @@ -26,7 +26,7 @@ class App.UiElement.richtext.additions.RichTextToolPopupLink extends App.UiEleme else input - wrapLink: -> + apply: (callback) -> input = @el.find('input').val() url = @ensureProtocol(input) @@ -50,3 +50,10 @@ class App.UiElement.richtext.additions.RichTextToolPopupLink extends App.UiEleme @applyOnto(newElem, url) placeholder.wrap(newElem) placeholder.contents() + + callback() + + clear: -> + switch @selection.type + when 'existing' + $(@selection.dom).contents().unwrap() diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_video.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_video.coffee new file mode 100644 index 000000000..a9677be24 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_video.coffee @@ -0,0 +1,70 @@ +class App.UiElement.richtext.additions.RichTextToolPopupVideo extends App.UiElement.richtext.additions.RichTextToolPopup + labelNew: 'Insert' + labelExisting: 'Replace' + + @regexps: { + youtube: [ + /youtube.com\/watch\?v=([\w]+)/ + /youtu.be\/([\w]+)/ + ], + vimeo: [ + /vimeo.com\/([\w]+)/ + ] + } + + @detectProviderAndId: (input) => + return if !input + + output = null + + for provider, regexps of @regexps + for regexp in regexps + if result = input.match(regexp) + return [provider, result[1]] + + @urlToMarkup: (input) -> + parsed = @detectProviderAndId(input) + + return if !parsed + + "( widget: video, provider: #{parsed[0]}, id: #{parsed[1]} )" + + apply: (callback) -> + input = @el.find('input').val() + markup = @constructor.urlToMarkup(input) + + if !markup + new App.ControllerErrorModal( + message: 'Invalid video URL' + ) + + return + + @insertVideo(markup) + callback() + + insertVideo: (markup) -> + textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + + switch @selection.type + when 'existing' + @selection.dom.text(markup) + when 'append' + newElem = document.createTextNode(markup) + @selection.dom.append(newElem) + when 'caret' + newElem = document.createTextNode(markup) + + surroundingDom = @selection.dom[0] + + if surroundingDom instanceof Text + @selection.dom[0].splitText(@selection.offset) + + $(newElem).insertAfter(@selection.dom) + when 'range' + newElem = document.createTextNode(markup) + + placeholder = textEditor.find('span.highlight-emulator') + + placeholder.empty() + placeholder.append(newElem) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee index 8ee553b16..8fd8ec7df 100644 --- a/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee +++ b/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee @@ -82,9 +82,16 @@ class App.KnowledgeBaseReaderController extends App.Controller @renderPopovers() renderBody: (translation) -> - body = $($.parseHTML(translation.content().body)) + body = translation.content().body + body = @prepareLinks(body) + body = @prepareVideos(body) - for linkDom in body.find('a').andSelf('a').toArray() + @answerBody.html(body) + + prepareLinks: (input) -> + input = $($.parseHTML(input)) + + for linkDom in input.find('a').andSelf('a').toArray() switch $(linkDom).attr('data-target-type') when 'knowledge-base-answer' if object = App.KnowledgeBaseAnswerTranslation.find $(linkDom).attr('data-target-id') @@ -92,7 +99,30 @@ class App.KnowledgeBaseReaderController extends App.Controller else $(linkDom).attr 'href', '#' - @answerBody.html(body) + $('').append(input).html() + + prepareVideos: (input) -> + input.replace /\(([\s]*)widget:([\s]*)video[\W]([\s\S])+?\)/g, (match) -> + settings = match + .slice(1, -1) + .split(',') + .map (pair) -> pair.split(':').map (elem) -> elem.trim() + .reduce (memo, elem) -> + memo[elem[0]] = elem[1] + return memo + , {} + + # coffeelint: disable=indentation + url = switch settings.provider + when 'youtube' + "http://www.youtube.com/embed/#{settings.id}" + when 'vimeo' + "https://player.vimeo.com/video/#{settings.id}" + # coffeelint: enable=indentation + + return match unless url + + "
" renderAttachments: (attachments) -> @answerAttachments.html App.view('generic/attachments')( diff --git a/app/assets/javascripts/app/models/knowledge_base_answer.coffee b/app/assets/javascripts/app/models/knowledge_base_answer.coffee index 6f71e854d..ba67007d4 100644 --- a/app/assets/javascripts/app/models/knowledge_base_answer.coffee +++ b/app/assets/javascripts/app/models/knowledge_base_answer.coffee @@ -64,6 +64,8 @@ class App.KnowledgeBaseAnswer extends App.Model buttons: [ 'link' 'link_answer' + 'insert_image' + 'embed_video' ] display: 'Content' tag: 'richtext' diff --git a/app/assets/stylesheets/knowledge_base.scss b/app/assets/stylesheets/knowledge_base.scss index aa81412a1..36b43ac6d 100644 --- a/app/assets/stylesheets/knowledge_base.scss +++ b/app/assets/stylesheets/knowledge_base.scss @@ -1233,3 +1233,18 @@ b { @extend %clickable; } } + +.videoWrapper { + position: relative; + padding-bottom: 56.25%; /* 16:9 */ + padding-top: 25px; + height: 0; +} + +.videoWrapper iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 394ba381e..35579edfe 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -11622,3 +11622,19 @@ span.is-disabled { .highlight-emulator { background-color: highlight; } + + +.videoWrapper { + position: relative; + padding-bottom: 56.25%; /* 16:9 */ + padding-top: 25px; + height: 0; +} + +.videoWrapper iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/app/helpers/knowledge_base_rich_text_helper.rb b/app/helpers/knowledge_base_rich_text_helper.rb index 2589f3efe..cb945ee36 100644 --- a/app/helpers/knowledge_base_rich_text_helper.rb +++ b/app/helpers/knowledge_base_rich_text_helper.rb @@ -1,4 +1,8 @@ module KnowledgeBaseRichTextHelper + def prepare_rich_text(input) + prepare_rich_text_videos(prepare_rich_text_links(input)) + end + def prepare_rich_text_links(input) scrubber = Loofah::Scrubber.new do |node| next if node.name != 'a' @@ -22,4 +26,26 @@ module KnowledgeBaseRichTextHelper parsed end + + def prepare_rich_text_videos(input) + input.gsub(/\(([\s]*)widget:([\s]*)video[\W]([\s\S])+?\)/) do |match| + settings = match + .slice(1...-1) + .split(',') + .map { |pair| pair.split(':').map(&:strip) } + .to_h + .symbolize_keys + + url = case settings[:provider] + when 'youtube' + "http://www.youtube.com/embed/#{settings[:id]}" + when 'vimeo' + "https://player.vimeo.com/video/#{settings[:id]}" + end + + return match unless url + + "
" + end + end end diff --git a/app/models/concerns/has_rich_text.rb b/app/models/concerns/has_rich_text.rb index a66a9dc57..b9d6b0afb 100644 --- a/app/models/concerns/has_rich_text.rb +++ b/app/models/concerns/has_rich_text.rb @@ -50,6 +50,21 @@ Checks if file is used inline parsed = Loofah.scrub_fragment(raw, scrubber).to_s parsed = HtmlSanitizer.strict(parsed) + scrubber_cleaner = Loofah::Scrubber.new(direction: :bottom_up) do |node| + case node.name + when 'span' + node.children.reject { |t| ["\n", "\r", "\r\n"].include?(t.text) }.each { |child| node.before child } + + node.remove + when 'div' + node.children.to_a.select { |t| t.text.match?(/\A([\n\r]+)\z/) }.each(&:remove) + + node.remove if node.children.none? && node.classes.none? + end + end + + parsed = Loofah.scrub_fragment(parsed, scrubber_cleaner).to_s + (parsed, attachments_inline) = HtmlSanitizer.replace_inline_images(parsed, image_prefix) send("#{attr}=", parsed) diff --git a/app/views/knowledge_base/public/answers/show.html.erb b/app/views/knowledge_base/public/answers/show.html.erb index 86c0f9328..2c57cf04b 100644 --- a/app/views/knowledge_base/public/answers/show.html.erb +++ b/app/views/knowledge_base/public/answers/show.html.erb @@ -8,7 +8,7 @@ data-available-locales='<%= @object_locales.map(&:locale).join(',') %>'>
- <%= prepare_rich_text_links @object.translation.content.body_with_urls.html_safe %> + <%= prepare_rich_text(@object.translation.content.body_with_urls).html_safe %>
<% if (attachments = @object.attachments_sorted) && attachments.present? %> diff --git a/spec/factories/knowledge_base/answer.rb b/spec/factories/knowledge_base/answer.rb index 55d4d3f57..8f5789bdd 100644 --- a/spec/factories/knowledge_base/answer.rb +++ b/spec/factories/knowledge_base/answer.rb @@ -2,14 +2,21 @@ FactoryBot.define do factory 'knowledge_base/answer', aliases: %i[knowledge_base_answer] do transient do add_translation { true } + translation_traits { [] } end category { create(:knowledge_base_category) } - before(:create) do |answer| + before(:create) do |answer, context| next if answer.translations.present? - answer.translations << build('knowledge_base/answer/translation', answer: answer) + answer.translations << build('knowledge_base/answer/translation', *context.translation_traits, answer: answer) + end + + trait :with_video do + transient do + translation_traits { [:with_video] } + end end end end diff --git a/spec/factories/knowledge_base/answer/translation.rb b/spec/factories/knowledge_base/answer/translation.rb index 980971b57..1fe545004 100644 --- a/spec/factories/knowledge_base/answer/translation.rb +++ b/spec/factories/knowledge_base/answer/translation.rb @@ -24,5 +24,9 @@ FactoryBot.define do translation.kb_locale = translation.answer.category.knowledge_base.kb_locales.first end end + + trait :with_video do + content { build(:knowledge_base_answer_translation_content, :with_video) } + end end end diff --git a/spec/factories/knowledge_base/answer/translation/content.rb b/spec/factories/knowledge_base/answer/translation/content.rb index 946e9f5da..c9d9b1f22 100644 --- a/spec/factories/knowledge_base/answer/translation/content.rb +++ b/spec/factories/knowledge_base/answer/translation/content.rb @@ -8,5 +8,9 @@ FactoryBot.define do create(:knowledge_base_answer_translation, content: content) end end + + trait :with_video do + body { '( widget: video, provider: youtube, id: vTTzwJsHpU8 )' } + end end end diff --git a/spec/support/knowledge_base_contexts.rb b/spec/support/knowledge_base_contexts.rb index 8350b81f1..d6ff5fe08 100644 --- a/spec/support/knowledge_base_contexts.rb +++ b/spec/support/knowledge_base_contexts.rb @@ -23,6 +23,10 @@ RSpec.shared_context 'basic Knowledge Base', current_user_id: 1 do create(:knowledge_base_answer, category: category, published_at: 1.week.ago) end + let :published_answer_with_video do + create(:knowledge_base_answer, :with_video, category: category, published_at: 1.week.ago) + end + let :internal_answer do create(:knowledge_base_answer, category: category, internal_at: 1.week.ago) end diff --git a/spec/system/knowledge_base/locale/answer/edit_spec.rb b/spec/system/knowledge_base/locale/answer/edit_spec.rb index 5a8d874d3..bc656d416 100644 --- a/spec/system/knowledge_base/locale/answer/edit_spec.rb +++ b/spec/system/knowledge_base/locale/answer/edit_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system, authenticated published_answer && draft_answer && internal_answer end - it 'when entering a long text' do + it 'wraps long texts' do long_string = '3KKFA9DAWE9VJYNNnpYRRtMwfa168O1yvpD2t9QXsfb3cppGV6KZ12q0UUJIy5r4Exfk18GnWPR0A3SoDsjxIHz1Gcu4aCEVzenilSOu4gAfxnB6k3mSBUOGIfdgChEBYhcHGgiCmV2EoXu4gG7GAJxKJhM2d4NUiL5RZttGtMXYYFr2Jsg7MV7xXGcygnsLMYqnwzOJxBK0vH3fzhdIZd6YrqR3fggaY0RyKtVigOBZ2SETC8s238Z9eDL4gfUW' visit "#knowledge_base/#{knowledge_base.id}/locale/#{primary_locale.system_locale.locale}/answer/#{draft_answer.id}/edit" @@ -19,4 +19,31 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system, authenticated expect(page).to have_css('.page-header-title') { |elem| !elem.obscured? } end end + + context 'embedded video' do + + it 'has adding functionality' do + visit "#knowledge_base/#{knowledge_base.id}/locale/#{primary_locale.system_locale.locale}/answer/#{published_answer.id}/edit" + + sleep 3 # wait for popover killer to pass + + find('a[data-action="embed_video"]').click + + within('.popover-content') do + find('input').fill_in with: 'https://www.youtube.com/watch?v=vTTzwJsHpU8' + find('[type=submit]').click + end + + within('.richtext-content') do + expect(page).to have_text('( widget: video, provider: youtube, id: vTTzwJsHpU8 )') + end + end + + it 'loads stored' do + visit "#knowledge_base/#{knowledge_base.id}/locale/#{primary_locale.system_locale.locale}/answer/#{published_answer_with_video.id}" + + iframe = find('iframe') + expect(iframe['src']).to start_with('http://www.youtube.com/embed/') + end + end end diff --git a/spec/system/knowledge_base_public/answer_spec.rb b/spec/system/knowledge_base_public/answer_spec.rb new file mode 100644 index 000000000..e5e5532b1 --- /dev/null +++ b/spec/system/knowledge_base_public/answer_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe 'Public Knowledge Base answer', type: :system, authenticated: false do + include_context 'basic Knowledge Base' + + context 'video content' do + + before do + published_answer_with_video + end + + it 'shows video player' do + visit help_answer_path(primary_locale.system_locale.locale, category, published_answer_with_video) + + iframe = find('iframe') + expect(iframe['src']).to start_with('http://www.youtube.com/embed/') + end + end +end