Skip to content

Commit

Permalink
Allow media files to be embedded in rich text content
Browse files Browse the repository at this point in the history
- Update tests for rich text chooser
- Address inconsistent tab/spacing issues
- Address more CI issues
- Properly sort imports
- For wagtail versions <2.5, use MediaEmbedHandler instead of EmbedHandler
  • Loading branch information
juan0tron committed Feb 29, 2020
1 parent 8b11344 commit 5f16dc4
Show file tree
Hide file tree
Showing 13 changed files with 482 additions and 19 deletions.
2 changes: 2 additions & 0 deletions wagtailmedia/admin_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@

url(r'^chooser/$', chooser.chooser, name='chooser'),
url(r'^chooser/(\d+)/$', chooser.media_chosen, name='media_chosen'),
url(r'^chooser/(\d+)/select_format/$', chooser.chooser_select_format, name='chooser_select_format'),

url(r'^usage/(\d+)/$', media.usage, name='media_usage'),
]
64 changes: 64 additions & 0 deletions wagtailmedia/contentstate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from draftjs_exporter.dom import DOM
from wagtail.admin.rich_text.converters.contentstate_models import Entity
from wagtail.admin.rich_text.converters.html_to_contentstate import AtomicBlockEntityElementHandler

from wagtailmedia.models import get_media_model


def media_entity(props):
"""
Helper to construct elements of the form
<embed embedtype="custommedia" id="1"/>
when converting from contentstate data
"""
return DOM.create_element('embed', {
'embedtype': 'wagtailmedia',
'id': props.get('id'),
'title': props.get('title'),
'type': props.get('type'),

'thumbnail': props.get('thumbnail'),
'file': props.get('file'),

'autoplay': props.get('autoplay'),
'mute': props.get('mute'),
'loop': props.get('loop'),
})


class MediaElementHandler(AtomicBlockEntityElementHandler):
"""
Rule for building a media entity when converting from
database representation to contentstate
"""
def create_entity(self, name, attrs, state, contentstate):
Media = get_media_model()
try:
media = Media.objects.get(id=attrs['id'])
except Media.DoesNotExist:
media = None

return Entity('MEDIA', 'IMMUTABLE', {
'id': attrs['id'],
'title': media.title,
'type': media.type,

'thumbnail': media.thumbnail.url if media.thumbnail else '',
'file': media.file.url if media.file else '',

'autoplay': True if attrs.get('autoplay') == 'true' else False,
'loop': True if attrs.get('loop') == 'true' else False,
'mute': True if attrs.get('mute') == 'true' else False
})


ContentstateMediaConversionRule = {
'from_database_format': {
'embed[embedtype="wagtailmedia"]': MediaElementHandler(),
},
'to_database_format': {
'entity_decorators': {
'MEDIA': media_entity
}
}
}
41 changes: 41 additions & 0 deletions wagtailmedia/embed_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from django.template.loader import render_to_string

from wagtail import VERSION as WAGTAIL_VERSION

from wagtailmedia.models import get_media_model

if WAGTAIL_VERSION < (2, 5):
from wagtail.embeds.rich_text import MediaEmbedHandler as EmbedHandler
else:
from wagtail.core.rich_text import EmbedHandler


class MediaEmbedHandler(EmbedHandler):
identifier = 'wagtailmedia'

@staticmethod
def get_model():
return get_media_model()

@staticmethod
def expand_db_attributes(attrs):
"""
Given a dict of attributes from the <embed> tag, return the real HTML
representation for use on the front-end.
"""

if(attrs['type'] == 'video'):
template = 'wagtailmedia/embeds/video_embed.html'
elif(attrs['type'] == 'audio'):
template = 'wagtailmedia/embeds/audio_embed.html'

return render_to_string(template, {
'title': attrs['title'],

'thumbnail': attrs['thumbnail'],
'file': attrs['file'],

'autoplay': True if attrs['autoplay'] == 'true' else False,
'loop': True if attrs['loop'] == 'true' else False,
'mute': True if attrs['mute'] == 'true' else False
})
10 changes: 10 additions & 0 deletions wagtailmedia/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,13 @@ def get_media_form(model):
],
'wagtailmedia/permissions/includes/media_permissions_formset.html'
)


class MediaInsertionForm(forms.Form):
"""
Form for customizing media player behavior (e.g. autoplay by default)
prior to insertion into a rich text area
"""
autoplay = forms.BooleanField(required=False)
mute = forms.BooleanField(required=False)
loop = forms.BooleanField(required=False)
206 changes: 206 additions & 0 deletions wagtailmedia/static/wagtailmedia/js/WagtailMediaBlock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
const React = window.React;
const ReactDOM = window.ReactDOM;
const Modifier = window.DraftJS.Modifier;
const EditorState = window.DraftJS.EditorState;
const AtomicBlockUtils = window.DraftJS.AtomicBlockUtils;

/**
* Choose a media file in this modal
*/
class WagtailMediaChooser extends window.draftail.ModalWorkflowSource {
componentDidMount() {
const { onClose, entityType, entity, editorState } = this.props;

$(document.body).on('hidden.bs.modal', this.onClose);

this.workflow = global.ModalWorkflow({
url: `${window.chooserUrls.mediaChooser}?select_format=true`,
onload: MEDIA_CHOOSER_MODAL_ONLOAD_HANDLERS,
urlParams: {},
responses: {
mediaChosen: (data) => this.onChosen(data)
},
onError: (err) => {
console.error("WagtailMediaChooser Error", err);
onClose();
},
});
}

onChosen(data) {
const { editorState, entityType, onComplete } = this.props;

const content = editorState.getCurrentContent();
const selection = editorState.getSelection();

const entityData = data;
const mutability = 'IMMUTABLE';

const contentWithEntity = content.createEntity(entityType.type, mutability, entityData);
const entityKey = contentWithEntity.getLastCreatedEntityKey();
const nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' ');

this.workflow.close();

onComplete(nextState);
}
}

// Constraints the maximum size of the tooltip.
const OPTIONS_MAX_WIDTH = 300;
const OPTIONS_SPACING = 70;
const TOOLTIP_MAX_WIDTH = OPTIONS_MAX_WIDTH + OPTIONS_SPACING;

/**
* Places media thumbnail HTML in the Rich Text Editor
*/
class WagtailMediaBlock extends React.Component {
constructor(props) {
super(props);

this.state = {
showTooltipAt: null,
};

this.setState = this.setState.bind(this);
this.openTooltip = this.openTooltip.bind(this);
this.closeTooltip = this.closeTooltip.bind(this);
this.renderTooltip = this.renderTooltip.bind(this);
}

componentDidMount() {
document.addEventListener('mouseup', this.closeTooltip);
document.addEventListener('keyup', this.closeTooltip);
window.addEventListener('resize', this.closeTooltip);
}

openTooltip(e) {
const { blockProps } = this.props;
const { entity, onRemoveEntity } = blockProps;
const data = entity.getData();

const trigger = e.target.closest('[data-draftail-trigger]');

if (!trigger) return; // Click is within the tooltip

const container = trigger.closest('[data-draftail-editor-wrapper]');

if (container.children.length > 1) return; // Tooltip already exists

const containerRect = container.getBoundingClientRect();
const rect = trigger.getBoundingClientRect();
const maxWidth = trigger.parentNode.offsetWidth - rect.width;
const direction = maxWidth >= TOOLTIP_MAX_WIDTH ? 'left' : 'top-left'; // Determines position of the arrow on the tooltip

let top = 0;
let left = 0;

if(direction == 'left'){
left = rect.width + 50;
top = rect.top - containerRect.top + (rect.height / 2);
}
else if (direction == 'top-left'){
top = rect.top - containerRect.top + rect.height;
}

this.setState({
showTooltipAt: {
container: container,
top: top,
left: left,
width: rect.width,
height: rect.height,
direction: direction,
}
});
}

closeTooltip(e) {
if(e.target.classList){
if(e.target.classList.contains("Tooltip__button")){
return; // Don't setState if the "Delete" button was clicked
}
}
this.setState({ showTooltipAt: null });
}

/**
* Returns either a tooltip "portal" element or null
*/
renderTooltip(data) {
const { showTooltipAt } = this.state;
const { blockProps } = this.props;
const { entity, onRemoveEntity } = blockProps;

// No tooltip coords exist, don't show one
if(!showTooltipAt) return null;

let options = []
if(data.autoplay) options.push("Autoplay");
if(data.mute) options.push("Mute");
if(data.loop) options.push("Loop");
const options_str = options.length ? options.join(", ") : "";

return ReactDOM.createPortal(React.createElement('div', null,
React.createElement('div',
{
style: {
top: showTooltipAt.top,
left: showTooltipAt.left
},
class: "Tooltip Tooltip--"+showTooltipAt.direction,
role: "tooltip"
},
React.createElement('div', { style: { maxWidth: showTooltipAt.width } }, [
React.createElement('p', {
class: "ImageBlock__alt"
}, data.type.toUpperCase()+": "+data.title),
React.createElement('p', { class: "ImageBlock__alt" }, options_str),
React.createElement('button', {
class: "button button-secondary no Tooltip__button",
onClick: onRemoveEntity
}, "Delete")
])
)
), showTooltipAt.container);
}

render() {
const { blockProps } = this.props;
const { entity } = blockProps;
const data = entity.getData();

let icon;
if(data.type == 'video'){
icon = React.createElement('span', { class:"icon icon-fa-video-camera", 'aria-hidden':"true" });
}
else if(data.type == 'audio'){
icon = React.createElement('span', { class:"icon icon-fa-music", 'aria-hidden':"true" });
}

return React.createElement('button',
{
class: 'MediaBlock WagtailMediaBlock '+data.type,
type: 'button',
tabindex: '-1',
'data-draftail-trigger': "true",
onClick: this.openTooltip,
style: { 'min-width': '100px', 'min-height': '100px'}
},
[
React.createElement('span',
{ class:"MediaBlock__icon-wrapper", 'aria-hidden': "true" },
React.createElement('span', {}, icon)
),
React.createElement('img', { src: data.thumbnail }),
this.renderTooltip(data)
]
);
}
}

window.draftail.registerPlugin({
type: 'MEDIA',
source: WagtailMediaChooser,
block: WagtailMediaBlock
});
31 changes: 31 additions & 0 deletions wagtailmedia/templates/wagtailmedia/chooser/select_format.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% load wagtailimages_tags %}
{% load i18n %}
{% trans "Media Player Options" as choose_str %}
{% include "wagtailadmin/shared/header.html" with title=choose_str %}

<div class="row row-flush nice-padding">
<div class="col5">
{% if media.thumbnail %}
<img src="{{media.thumbnail.url}}" alt="{{media.title}}">
{% elif media.type == 'video' %}
<div class="media-thumbnail-placeholder video">
<i class="icon icon-fa-video-camera"></i>
</div>
{% elif media.type == 'audio' %}
<div class="media-thumbnail-placeholder audio">
<i class="icon icon-fa-music"></i>
</div>
{% endif %}
</div>
<div class="col7">
<form action="{% url 'wagtailmedia:chooser_select_format' media.id %}" class="media-player-settings" method="POST" novalidate>
{% csrf_token %}
<ul class="fields">
{% for field in form %}
{% include "wagtailadmin/shared/field_as_li.html" with field=field %}
{% endfor %}
<li><input type="submit" value="{% trans 'Insert media' %}" class="button" /></li>
</ul>
</form>
</div>
</div>
10 changes: 10 additions & 0 deletions wagtailmedia/templates/wagtailmedia/embeds/audio_embed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% block audio %}
<audio
class="wagtailmedia-audio"
src="{{file}}"
{% if mute %}muted{% endif %}
{% if autoplay %}autoplay{% endif %}
{% if loop %}loop{% endif %}
controls>
</audio>
{% endblock %}
10 changes: 10 additions & 0 deletions wagtailmedia/templates/wagtailmedia/embeds/video_embed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% block video %}
<video class="wagtailmedia-video"
controls
{% if mute %}muted{% endif %}
{% if autoplay %}autoplay{% endif %}
{% if loop %}loop{% endif %}
{% if thumbnail %}poster="{{thumbnail}}"{% endif %}>
<source src="{{file}}" data-title={{title}}>
</video>
{% endblock %}
Loading

0 comments on commit 5f16dc4

Please sign in to comment.