Commit 1a3d7e0d authored by Robert Knight's avatar Robert Knight

Merge pull request #2805 from hypothesis/TPUsXCk4-add-media-embeds-feature

Add media embeds feature
parents 22aa37eb e5beb610
mediaEmbedder = require('../media-embedder')
loadMathJax = ->
if !MathJax?
$.ajax {
......@@ -297,7 +299,16 @@ module.exports = ['$filter', '$sanitize', '$sce', '$timeout', ($filter, $sanitiz
startMath = index + 2
$sanitize convert renderInlineMath textToCheck.substring(endMath, index)
return parts.join('')
htmlString = parts.join('')
# Transform the HTML string into a DOM element.
domElement = document.createElement('div')
domElement.innerHTML = htmlString
if scope.embedsEnabled
mediaEmbedder.replaceLinksWithEmbeds(domElement)
return domElement.innerHTML
renderInlineMath = (textToCheck) ->
re = /\\?\\\(|\\?\\\)/g
......@@ -333,8 +344,7 @@ module.exports = ['$filter', '$sanitize', '$sce', '$timeout', ($filter, $sanitiz
if !scope.readOnly and !scope.preview
inputEl.val (ctrl.$viewValue or '')
value = ctrl.$viewValue or ''
rendered = renderMathAndMarkdown value
scope.rendered = $sce.trustAsHtml rendered
output.innerHTML = renderMathAndMarkdown(value)
if mathJaxFallback
$timeout (-> MathJax?.Hub.Queue ['Typeset', MathJax.Hub, output]), 0, false
......@@ -356,5 +366,6 @@ module.exports = ['$filter', '$sanitize', '$sce', '$timeout', ($filter, $sanitiz
scope:
readOnly: '='
required: '@'
embedsEnabled: '='
templateUrl: 'markdown.html'
]
'use strict';
/**
* Return an iframe DOM element with the given src URL.
*/
function iframe(src) {
var iframe_ = document.createElement('iframe');
iframe_.src = src;
iframe_.classList.add('annotation-media-embed');
iframe_.setAttribute('frameborder', '0');
return iframe_;
}
/**
* Return a YouTube embed (<iframe>) DOM element for the given video ID.
*/
function youTubeEmbed(id) {
return iframe('https://www.youtube.com/embed/' + id);
}
function vimeoEmbed(id) {
return iframe('https://player.vimeo.com/video/' + id);
}
/**
* A list of functions that return an "embed" DOM element (e.g. an <iframe>)
* for a given link.
*
* Each function either returns `undefined` if it can't generate an embed for
* the link, or a DOM element if it can.
*
*/
var embedGenerators = [
// Matches URLs like https://www.youtube.com/watch?v=rw6oWkCojpw
function iframeFromYouTubeWatchURL(link) {
if (link.hostname !== 'www.youtube.com') {
return;
}
if (!/\/watch\/?/.test(link.pathname)) {
return;
}
var groups = /[&\?]v=([^&#]+)/.exec(link.search);
if (groups) {
return youTubeEmbed(groups[1]);
}
},
// Matches URLs like https://youtu.be/rw6oWkCojpw
function iframeFromYouTubeShareURL(link) {
if (link.hostname !== 'youtu.be') {
return;
}
var groups = /^\/([^\/]+)\/?$/.exec(link.pathname);
if (groups) {
return youTubeEmbed(groups[1]);
}
},
// Matches URLs like https://vimeo.com/149000090
function iFrameFromVimeoLink(link) {
if (link.hostname !== 'vimeo.com') {
return;
}
var groups = /^\/([^\/\?#]+)\/?$/.exec(link.pathname);
if (groups) {
return vimeoEmbed(groups[1]);
}
},
// Matches URLs like https://vimeo.com/channels/staffpicks/148845534
function iFrameFromVimeoChannelLink(link) {
if (link.hostname !== 'vimeo.com') {
return;
}
var groups = /^\/channels\/[^\/]+\/([^\/?#]+)\/?$/.exec(link.pathname);
if (groups) {
return vimeoEmbed(groups[1]);
}
},
];
/**
* Return an embed element for the given link if it's an embeddable link.
*
* If the link is a link for a YouTube video or other embeddable media then
* return an embed DOM element (for example an <iframe>) for that media.
*
* Otherwise return undefined.
*
*/
function embedForLink(link) {
var embed;
var j;
for (j = 0; j < embedGenerators.length; j++) {
embed = embedGenerators[j](link);
if (embed) {
return embed;
}
}
}
/** Replace the given link element with an embed.
*
* If the given link element is a link to an embeddable media then it will be
* replaced in the DOM with an embed (e.g. an <iframe>) of the same media.
*
* If it's not an embeddable link the link will be left untouched.
*
*/
function replaceLinkWithEmbed(link) {
// If the user gives a custom link text then we don't replace the link with
// an embed.
if (link.href !== link.innerText) {
return;
}
var embed = embedForLink(link);
if (embed) {
link.parentElement.replaceChild(embed, link);
}
}
/**
* Replace all embeddable link elements beneath the given element with embeds.
*
* All links to YouTube videos or other embeddable media will be replaced with
* embeds of the same media.
*
*/
function replaceLinksWithEmbeds(element) {
var links = element.getElementsByTagName('a');
// `links` is a "live list" of the <a> element children of `element`.
// We want to iterate over `links` and replace some of them with embeds,
// but we can't modify `links` while looping over it so we need to copy it to
// a nice, normal array first.
links = Array.prototype.slice.call(links, 0);
var i;
for (i = 0; i < links.length; i++) {
replaceLinkWithEmbed(links[i]);
}
}
module.exports = {
replaceLinksWithEmbeds: replaceLinksWithEmbeds,
};
'use strict';
var mediaEmbedder = require('../media-embedder.js');
describe('media-embedder', function () {
function domElement (html) {
var element = document.createElement('div');
element.innerHTML = html;
return element;
}
it('replaces YouTube watch links with iframes', function () {
var urls = [
'https://www.youtube.com/watch?v=QCkm0lL-6lc',
'https://www.youtube.com/watch/?v=QCkm0lL-6lc',
'https://www.youtube.com/watch?foo=bar&v=QCkm0lL-6lc',
'https://www.youtube.com/watch?foo=bar&v=QCkm0lL-6lc&h=j',
'https://www.youtube.com/watch?v=QCkm0lL-6lc&foo=bar',
];
urls.forEach(function (url) {
var element = domElement('<a href="' + url + '">' + url + '</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 1);
assert.equal(element.children[0].tagName, 'IFRAME', url);
assert.equal(
element.children[0].src,
'https://www.youtube.com/embed/QCkm0lL-6lc');
});
});
it('replaces YouTube share links with iframes', function () {
var urls = [
'https://youtu.be/QCkm0lL-6lc',
'https://youtu.be/QCkm0lL-6lc/',
]
urls.forEach(function (url) {
var element = domElement('<a href="' + url + '">' + url + '</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 1);
assert.equal(element.children[0].tagName, 'IFRAME');
assert.equal(
element.children[0].src, 'https://www.youtube.com/embed/QCkm0lL-6lc');
});
});
it('replaces Vimeo links with iframes', function () {
var urls = [
'https://vimeo.com/149000090',
'https://vimeo.com/149000090/',
'https://vimeo.com/149000090#fragment',
'https://vimeo.com/149000090/#fragment',
'https://vimeo.com/149000090?foo=bar&a=b',
'https://vimeo.com/149000090/?foo=bar&a=b',
]
urls.forEach(function (url) {
var element = domElement('<a href="' + url + '">' + url + '</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 1);
assert.equal(element.children[0].tagName, 'IFRAME');
assert.equal(
element.children[0].src, 'https://player.vimeo.com/video/149000090');
});
});
it('replaces Vimeo channel links with iframes', function () {
var urls = [
'https://vimeo.com/channels/staffpicks/148845534',
'https://vimeo.com/channels/staffpicks/148845534/',
'https://vimeo.com/channels/staffpicks/148845534/?q=foo&id=bar',
'https://vimeo.com/channels/staffpicks/148845534#fragment',
'https://vimeo.com/channels/staffpicks/148845534/#fragment',
'https://vimeo.com/channels/staffpicks/148845534?foo=bar&id=1',
'https://vimeo.com/channels/otherchannel/148845534',
];
urls.forEach(function (url) {
var element = domElement('<a href="' + url + '">' + url + '</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 1);
assert.equal(element.children[0].tagName, 'IFRAME');
assert.equal(
element.children[0].src, 'https://player.vimeo.com/video/148845534');
});
});
it('does not replace links if the link text is different', function () {
var url = 'https://youtu.be/QCkm0lL-6lc';
var element = domElement('<a href="' + url + '">different label</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 1);
assert.equal(element.children[0].tagName, 'A');
});
it('does not replace non-media links', function () {
var url = 'https://example.com/example.html';
var element = domElement('<a href="' + url + '">' + url + '</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 1);
assert.equal(element.children[0].tagName, 'A');
});
it('does not mess with the rest of the HTML', function () {
var url = 'https://www.youtube.com/watch?v=QCkm0lL-6lc';
var element = domElement(
'<p>Look at this video:</p>\n\n' +
'<a href="' + url + '">' + url + '</a>\n\n' +
'<p>Isn\'t it cool!</p>\n\n');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 3);
assert.equal(
element.children[0].outerHTML, '<p>Look at this video:</p>');
assert.equal(
element.children[2].outerHTML, '<p>Isn\'t it cool!</p>');
});
it('replaces multiple links with multiple embeds', function () {
var url1 = 'https://www.youtube.com/watch?v=QCkm0lL-6lc';
var url2 = 'https://youtu.be/abcdefg';
var element = domElement(
'<a href="' + url1 + '">' + url1 + '</a>\n\n' +
'<a href="' + url2 + '">' + url2 + '</a>');
mediaEmbedder.replaceLinksWithEmbeds(element);
assert.equal(element.childElementCount, 2);
assert.equal(element.children[0].tagName, 'IFRAME');
assert.equal(
element.children[0].src, 'https://www.youtube.com/embed/QCkm0lL-6lc');
assert.equal(element.children[1].tagName, 'IFRAME');
assert.equal(
element.children[1].src, 'https://www.youtube.com/embed/abcdefg');
});
});
......@@ -99,6 +99,11 @@ $annotation-card-left-padding: 10px;
.excerpt { max-height: 16.2em; }
}
.annotation-media-embed {
width: 369px;
height: 208px;
}
.annotation-user {
color: $text-color;
font-weight: bold;
......
......@@ -72,8 +72,9 @@
<section name="text" class="annotation-body">
<excerpt enabled="vm.feature('truncate_annotations') && !vm.editing()">
<markdown ng-model="vm.form.text"
read-only="!vm.editing()"
></markdown>
read-only="!vm.editing()"
embeds-enabled="vm.feature('embed_media')">
</markdown>
</excerpt>
</section>
<!-- / Body -->
......
......@@ -17,4 +17,4 @@
ng-hide="readOnly || preview"
ng-click="$event.stopPropagation()"
ng-required="required"></textarea>
<div class="styled-text js-markdown-preview" ng-class="preview && 'markdown-preview'" ng-dblclick="togglePreview()" ng-bind-html="rendered" ng-show="readOnly || preview"></div>
<div class="styled-text js-markdown-preview" ng-class="preview && 'markdown-preview'" ng-dblclick="togglePreview()" ng-show="readOnly || preview"></div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment