Fixes #2713 - Knowledge Base does't support animated GIFs and embedded Video (Youtube/Vimeo) content.

This commit is contained in:
Mantas Masalskis 2020-02-19 18:07:52 +01:00 committed by Thorsten Eckel
parent 695d05689b
commit 0a70711695
24 changed files with 474 additions and 72 deletions

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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 = $('<img>')[0]
newElem.src = base64
newElem.style = 'width: 1000px; max-width: 100%;'
@selection.dom.append(newElem)
when 'caret'
newElem = $('<img>')
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 = $('<img>')
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

View file

@ -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()

View file

@ -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)

View file

@ -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)
$('<container>').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
"<div class='videoWrapper'><iframe id='#{settings.provider}#{settings.id}' type='text/html' src='#{url}' frameborder='0'></iframe></div>"
renderAttachments: (attachments) ->
@answerAttachments.html App.view('generic/attachments')(

View file

@ -64,6 +64,8 @@ class App.KnowledgeBaseAnswer extends App.Model
buttons: [
'link'
'link_answer'
'insert_image'
'embed_video'
]
display: 'Content'
tag: 'richtext'

View file

@ -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%;
}

View file

@ -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%;
}

View file

@ -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
"<div class='videoWrapper'><iframe id='#{settings[:provider]}#{settings[:id]}' type='text/html' src='#{url}' frameborder='0'></iframe></div>"
end
end
end

View file

@ -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)

View file

@ -8,7 +8,7 @@ data-available-locales='<%= @object_locales.map(&:locale).join(',') %>'>
</h1>
<div class="article-content">
<%= prepare_rich_text_links @object.translation.content.body_with_urls.html_safe %>
<%= prepare_rich_text(@object.translation.content.body_with_urls).html_safe %>
</div>
<% if (attachments = @object.attachments_sorted) && attachments.present? %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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