Fixes #2713 - Knowledge Base does't support animated GIFs and embedded Video (Youtube/Vimeo) content.
This commit is contained in:
parent
695d05689b
commit
0a70711695
24 changed files with 474 additions and 72 deletions
|
@ -5,10 +5,13 @@ class App.UiElement.richtext.additions.RichTextToolButton
|
||||||
@klass: ->
|
@klass: ->
|
||||||
# Needs implementation. Return constructor of RichTextToolPopup subclass.
|
# Needs implementation. Return constructor of RichTextToolPopup subclass.
|
||||||
|
|
||||||
|
@pickExisting: (sel, textEditor) ->
|
||||||
|
# needs implementation
|
||||||
|
|
||||||
@initializeAttributes: {}
|
@initializeAttributes: {}
|
||||||
|
|
||||||
@instantiateContent: (event, selection, delegate) ->
|
@instantiateContent: (event, selection, delegate) ->
|
||||||
attrs = @initializeAttributes
|
attrs = $.extend(true, {}, @initializeAttributes)
|
||||||
|
|
||||||
attrs['event'] = event
|
attrs['event'] = event
|
||||||
attrs['selection'] = selection
|
attrs['selection'] = selection
|
||||||
|
@ -35,46 +38,6 @@ class App.UiElement.richtext.additions.RichTextToolButton
|
||||||
|
|
||||||
hash
|
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
|
# close other buttons' popovers
|
||||||
@closeOtherPopovers: (event) ->
|
@closeOtherPopovers: (event) ->
|
||||||
$(event.currentTarget)
|
$(event.currentTarget)
|
||||||
|
@ -88,15 +51,10 @@ class App.UiElement.richtext.additions.RichTextToolButton
|
||||||
@selectionSnapshot: (sel) ->
|
@selectionSnapshot: (sel) ->
|
||||||
textEditor = $(event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
|
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'
|
type: 'existing'
|
||||||
dom: $(selectedLink)
|
dom: $(selected)
|
||||||
}
|
|
||||||
else if !sel.isCollapsed and selectedLink = @pickLink(sel, textEditor)
|
|
||||||
{
|
|
||||||
type: 'existing'
|
|
||||||
dom: $(selectedLink)
|
|
||||||
}
|
}
|
||||||
else if sel.type is 'Range' and $(sel.anchorNode).closest('[contenteditable]', textEditor)[0]
|
else if sel.type is 'Range' and $(sel.anchorNode).closest('[contenteditable]', textEditor)[0]
|
||||||
range = sel.getRangeAt(0)
|
range = sel.getRangeAt(0)
|
||||||
|
@ -117,6 +75,7 @@ class App.UiElement.richtext.additions.RichTextToolButton
|
||||||
dom: textEditor
|
dom: textEditor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# on clicking button above rich text area
|
||||||
@onClick: (event, delegate) ->
|
@onClick: (event, delegate) ->
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
|
@ -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
|
|
@ -1,7 +1,11 @@
|
||||||
class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerForm
|
class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerForm
|
||||||
events:
|
events:
|
||||||
'submit form': 'onSubmit'
|
'submit form': 'onSubmit'
|
||||||
'click .js-unlink': 'onUnlink'
|
'click .js-clear': 'onClear'
|
||||||
|
|
||||||
|
labelNew: 'Link'
|
||||||
|
labelExisting: 'Update'
|
||||||
|
labelClear: 'Remove'
|
||||||
|
|
||||||
formParams: (params) ->
|
formParams: (params) ->
|
||||||
# needs implementation
|
# needs implementation
|
||||||
|
@ -9,13 +13,13 @@ class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerF
|
||||||
constructor: (params) ->
|
constructor: (params) ->
|
||||||
if params.selection.type is 'existing'
|
if params.selection.type is 'existing'
|
||||||
url = params.selection.dom.attr('href')
|
url = params.selection.dom.attr('href')
|
||||||
label = 'Update'
|
label = @labelExisting
|
||||||
additional = [{
|
additional = [{
|
||||||
className: 'btn btn--danger js-unlink'
|
className: 'btn btn--danger js-clear'
|
||||||
text: 'Remove'
|
text: @labelClear
|
||||||
}]
|
}]
|
||||||
else
|
else
|
||||||
label = 'Link'
|
label = @labelNew
|
||||||
|
|
||||||
defaultParams =
|
defaultParams =
|
||||||
params: @formParams(params)
|
params: @formParams(params)
|
||||||
|
@ -39,16 +43,17 @@ class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerF
|
||||||
getAjaxAttributes: (field, attributes) ->
|
getAjaxAttributes: (field, attributes) ->
|
||||||
@delegate?.getAjaxAttributes?(field, attributes)
|
@delegate?.getAjaxAttributes?(field, attributes)
|
||||||
|
|
||||||
onUnlink: (e) ->
|
onClear: (e) =>
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
switch @selection.type
|
@clear()
|
||||||
when 'existing'
|
|
||||||
$(@selection.dom).contents().unwrap()
|
|
||||||
|
|
||||||
$(@event.currentTarget).popover('hide')
|
$(@event.currentTarget).popover('hide')
|
||||||
|
|
||||||
|
clear: ->
|
||||||
|
# needs implementation
|
||||||
|
|
||||||
@wrapElement: (wrapper, selection) ->
|
@wrapElement: (wrapper, selection) ->
|
||||||
topLevelOriginals = App.UiElement.richtext.buildParentsList(selection.range.startContainer, selection.range.commonAncestorContainer).reverse()
|
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)
|
wrapper.insertAfter(topLevelOriginalStart)
|
||||||
|
|
||||||
wrapLink: ->
|
apply: (callback) ->
|
||||||
# needs implementation
|
# needs implementation
|
||||||
|
callback()
|
||||||
|
|
||||||
onSubmit: (e) ->
|
onSubmit: (e) ->
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@wrapLink()
|
@apply =>
|
||||||
|
$(@event.currentTarget).popover('destroy')
|
||||||
$(@event.currentTarget).popover('destroy')
|
|
||||||
|
|
||||||
didInitialize: ->
|
didInitialize: ->
|
||||||
switch @selection.type
|
switch @selection.type
|
||||||
|
|
|
@ -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
|
|
@ -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]
|
|
@ -1,5 +1,5 @@
|
||||||
# coffeelint: disable=camel_case_classes
|
# 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'
|
@icon: 'knowledge-base-answer'
|
||||||
@text: 'Link Answer'
|
@text: 'Link Answer'
|
||||||
@klass: -> App.UiElement.richtext.additions.RichTextToolPopupAnswer
|
@klass: -> App.UiElement.richtext.additions.RichTextToolPopupAnswer
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# coffeelint: disable=camel_case_classes
|
# 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'
|
@icon: 'chain'
|
||||||
@text: 'Weblink'
|
@text: 'Weblink'
|
||||||
@klass: -> App.UiElement.richtext.additions.RichTextToolPopupLink
|
@klass: -> App.UiElement.richtext.additions.RichTextToolPopupLink
|
||||||
|
|
|
@ -18,7 +18,7 @@ class App.UiElement.richtext.additions.RichTextToolPopupAnswer extends App.UiEle
|
||||||
|
|
||||||
dom
|
dom
|
||||||
|
|
||||||
wrapLink: ->
|
apply: (callback) ->
|
||||||
id = @el.find('input').val()
|
id = @el.find('input').val()
|
||||||
object = App.KnowledgeBaseAnswerTranslation.find(id)
|
object = App.KnowledgeBaseAnswerTranslation.find(id)
|
||||||
textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
|
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)
|
@applyOnto(newElem, object)
|
||||||
placeholder.wrap(newElem)
|
placeholder.wrap(newElem)
|
||||||
placeholder.contents()
|
placeholder.contents()
|
||||||
|
|
||||||
|
callback()
|
||||||
|
|
||||||
|
clear: ->
|
||||||
|
switch @selection.type
|
||||||
|
when 'existing'
|
||||||
|
$(@selection.dom).contents().unwrap()
|
||||||
|
|
|
@ -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
|
|
@ -26,7 +26,7 @@ class App.UiElement.richtext.additions.RichTextToolPopupLink extends App.UiEleme
|
||||||
else
|
else
|
||||||
input
|
input
|
||||||
|
|
||||||
wrapLink: ->
|
apply: (callback) ->
|
||||||
input = @el.find('input').val()
|
input = @el.find('input').val()
|
||||||
url = @ensureProtocol(input)
|
url = @ensureProtocol(input)
|
||||||
|
|
||||||
|
@ -50,3 +50,10 @@ class App.UiElement.richtext.additions.RichTextToolPopupLink extends App.UiEleme
|
||||||
@applyOnto(newElem, url)
|
@applyOnto(newElem, url)
|
||||||
placeholder.wrap(newElem)
|
placeholder.wrap(newElem)
|
||||||
placeholder.contents()
|
placeholder.contents()
|
||||||
|
|
||||||
|
callback()
|
||||||
|
|
||||||
|
clear: ->
|
||||||
|
switch @selection.type
|
||||||
|
when 'existing'
|
||||||
|
$(@selection.dom).contents().unwrap()
|
||||||
|
|
|
@ -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)
|
|
@ -82,9 +82,16 @@ class App.KnowledgeBaseReaderController extends App.Controller
|
||||||
@renderPopovers()
|
@renderPopovers()
|
||||||
|
|
||||||
renderBody: (translation) ->
|
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')
|
switch $(linkDom).attr('data-target-type')
|
||||||
when 'knowledge-base-answer'
|
when 'knowledge-base-answer'
|
||||||
if object = App.KnowledgeBaseAnswerTranslation.find $(linkDom).attr('data-target-id')
|
if object = App.KnowledgeBaseAnswerTranslation.find $(linkDom).attr('data-target-id')
|
||||||
|
@ -92,7 +99,30 @@ class App.KnowledgeBaseReaderController extends App.Controller
|
||||||
else
|
else
|
||||||
$(linkDom).attr 'href', '#'
|
$(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) ->
|
renderAttachments: (attachments) ->
|
||||||
@answerAttachments.html App.view('generic/attachments')(
|
@answerAttachments.html App.view('generic/attachments')(
|
||||||
|
|
|
@ -64,6 +64,8 @@ class App.KnowledgeBaseAnswer extends App.Model
|
||||||
buttons: [
|
buttons: [
|
||||||
'link'
|
'link'
|
||||||
'link_answer'
|
'link_answer'
|
||||||
|
'insert_image'
|
||||||
|
'embed_video'
|
||||||
]
|
]
|
||||||
display: 'Content'
|
display: 'Content'
|
||||||
tag: 'richtext'
|
tag: 'richtext'
|
||||||
|
|
|
@ -1233,3 +1233,18 @@ b {
|
||||||
@extend %clickable;
|
@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%;
|
||||||
|
}
|
||||||
|
|
|
@ -11622,3 +11622,19 @@ span.is-disabled {
|
||||||
.highlight-emulator {
|
.highlight-emulator {
|
||||||
background-color: highlight;
|
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%;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
module KnowledgeBaseRichTextHelper
|
module KnowledgeBaseRichTextHelper
|
||||||
|
def prepare_rich_text(input)
|
||||||
|
prepare_rich_text_videos(prepare_rich_text_links(input))
|
||||||
|
end
|
||||||
|
|
||||||
def prepare_rich_text_links(input)
|
def prepare_rich_text_links(input)
|
||||||
scrubber = Loofah::Scrubber.new do |node|
|
scrubber = Loofah::Scrubber.new do |node|
|
||||||
next if node.name != 'a'
|
next if node.name != 'a'
|
||||||
|
@ -22,4 +26,26 @@ module KnowledgeBaseRichTextHelper
|
||||||
|
|
||||||
parsed
|
parsed
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -50,6 +50,21 @@ Checks if file is used inline
|
||||||
parsed = Loofah.scrub_fragment(raw, scrubber).to_s
|
parsed = Loofah.scrub_fragment(raw, scrubber).to_s
|
||||||
parsed = HtmlSanitizer.strict(parsed)
|
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)
|
(parsed, attachments_inline) = HtmlSanitizer.replace_inline_images(parsed, image_prefix)
|
||||||
|
|
||||||
send("#{attr}=", parsed)
|
send("#{attr}=", parsed)
|
||||||
|
|
|
@ -8,7 +8,7 @@ data-available-locales='<%= @object_locales.map(&:locale).join(',') %>'>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="article-content">
|
<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>
|
</div>
|
||||||
|
|
||||||
<% if (attachments = @object.attachments_sorted) && attachments.present? %>
|
<% if (attachments = @object.attachments_sorted) && attachments.present? %>
|
||||||
|
|
|
@ -2,14 +2,21 @@ FactoryBot.define do
|
||||||
factory 'knowledge_base/answer', aliases: %i[knowledge_base_answer] do
|
factory 'knowledge_base/answer', aliases: %i[knowledge_base_answer] do
|
||||||
transient do
|
transient do
|
||||||
add_translation { true }
|
add_translation { true }
|
||||||
|
translation_traits { [] }
|
||||||
end
|
end
|
||||||
|
|
||||||
category { create(:knowledge_base_category) }
|
category { create(:knowledge_base_category) }
|
||||||
|
|
||||||
before(:create) do |answer|
|
before(:create) do |answer, context|
|
||||||
next if answer.translations.present?
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,5 +24,9 @@ FactoryBot.define do
|
||||||
translation.kb_locale = translation.answer.category.knowledge_base.kb_locales.first
|
translation.kb_locale = translation.answer.category.knowledge_base.kb_locales.first
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :with_video do
|
||||||
|
content { build(:knowledge_base_answer_translation_content, :with_video) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,5 +8,9 @@ FactoryBot.define do
|
||||||
create(:knowledge_base_answer_translation, content: content)
|
create(:knowledge_base_answer_translation, content: content)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :with_video do
|
||||||
|
body { '( widget: video, provider: youtube, id: vTTzwJsHpU8 )' }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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)
|
create(:knowledge_base_answer, category: category, published_at: 1.week.ago)
|
||||||
end
|
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
|
let :internal_answer do
|
||||||
create(:knowledge_base_answer, category: category, internal_at: 1.week.ago)
|
create(:knowledge_base_answer, category: category, internal_at: 1.week.ago)
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ RSpec.describe 'Knowledge Base Locale Answer Edit', type: :system, authenticated
|
||||||
published_answer && draft_answer && internal_answer
|
published_answer && draft_answer && internal_answer
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'when entering a long text' do
|
it 'wraps long texts' do
|
||||||
long_string = '3KKFA9DAWE9VJYNNnpYRRtMwfa168O1yvpD2t9QXsfb3cppGV6KZ12q0UUJIy5r4Exfk18GnWPR0A3SoDsjxIHz1Gcu4aCEVzenilSOu4gAfxnB6k3mSBUOGIfdgChEBYhcHGgiCmV2EoXu4gG7GAJxKJhM2d4NUiL5RZttGtMXYYFr2Jsg7MV7xXGcygnsLMYqnwzOJxBK0vH3fzhdIZd6YrqR3fggaY0RyKtVigOBZ2SETC8s238Z9eDL4gfUW'
|
long_string = '3KKFA9DAWE9VJYNNnpYRRtMwfa168O1yvpD2t9QXsfb3cppGV6KZ12q0UUJIy5r4Exfk18GnWPR0A3SoDsjxIHz1Gcu4aCEVzenilSOu4gAfxnB6k3mSBUOGIfdgChEBYhcHGgiCmV2EoXu4gG7GAJxKJhM2d4NUiL5RZttGtMXYYFr2Jsg7MV7xXGcygnsLMYqnwzOJxBK0vH3fzhdIZd6YrqR3fggaY0RyKtVigOBZ2SETC8s238Z9eDL4gfUW'
|
||||||
|
|
||||||
visit "#knowledge_base/#{knowledge_base.id}/locale/#{primary_locale.system_locale.locale}/answer/#{draft_answer.id}/edit"
|
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? }
|
expect(page).to have_css('.page-header-title') { |elem| !elem.obscured? }
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
19
spec/system/knowledge_base_public/answer_spec.rb
Normal file
19
spec/system/knowledge_base_public/answer_spec.rb
Normal 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
|
Loading…
Reference in a new issue