Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Embed media in rich text fields using Draftail #90

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it might be better to just serialize all the props as a single JSON-encoded attribute, rather than have to store them as separate HTML attributes like this. Then you wouldn’t have to do the annoying True if attrs.get('autoplay') == 'true' else False conversion back the other way around.

})


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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn’t ever recommend anyone make anything autoplay, although I know some people might. How can we make the presence of this field configurable?

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(", ") : "";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not to worry about right now but these strings should be translate-able. We can implement this similarly to Wagtail.


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" });
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Draftail supports providing icons as SVG so we don’t need font awesome for these at least.


return React.createElement('button',
{
class: 'MediaBlock WagtailMediaBlock '+data.type,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it looks like we should just reuse MediaBlock.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn’t need aria-hidden if it’s already set on the icon.

),
React.createElement('img', { src: data.thumbnail }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs an alt="" attribute to be valid HTML

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 %}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a stupid question but this project didn’t have anything like this to define the medias’ output before?

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}}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is data-title for?

</video>
{% endblock %}
Loading