Skip to content

Commit a57d9a6

Browse files
author
juan0tron
committed
Allow media files to be embedded in rich text content
- 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
1 parent 8b11344 commit a57d9a6

File tree

13 files changed

+482
-19
lines changed

13 files changed

+482
-19
lines changed

wagtailmedia/admin_urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@
1010

1111
url(r'^chooser/$', chooser.chooser, name='chooser'),
1212
url(r'^chooser/(\d+)/$', chooser.media_chosen, name='media_chosen'),
13+
url(r'^chooser/(\d+)/select_format/$', chooser.chooser_select_format, name='chooser_select_format'),
14+
1315
url(r'^usage/(\d+)/$', media.usage, name='media_usage'),
1416
]

wagtailmedia/contentstate.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from draftjs_exporter.dom import DOM
2+
from wagtail.admin.rich_text.converters.contentstate_models import Entity
3+
from wagtail.admin.rich_text.converters.html_to_contentstate import AtomicBlockEntityElementHandler
4+
5+
from wagtailmedia.models import get_media_model
6+
7+
8+
def media_entity(props):
9+
"""
10+
Helper to construct elements of the form
11+
<embed embedtype="custommedia" id="1"/>
12+
when converting from contentstate data
13+
"""
14+
return DOM.create_element('embed', {
15+
'embedtype': 'wagtailmedia',
16+
'id': props.get('id'),
17+
'title': props.get('title'),
18+
'type': props.get('type'),
19+
20+
'thumbnail': props.get('thumbnail'),
21+
'file': props.get('file'),
22+
23+
'autoplay': props.get('autoplay'),
24+
'mute': props.get('mute'),
25+
'loop': props.get('loop'),
26+
})
27+
28+
29+
class MediaElementHandler(AtomicBlockEntityElementHandler):
30+
"""
31+
Rule for building a media entity when converting from
32+
database representation to contentstate
33+
"""
34+
def create_entity(self, name, attrs, state, contentstate):
35+
Media = get_media_model()
36+
try:
37+
media = Media.objects.get(id=attrs['id'])
38+
except Media.DoesNotExist:
39+
media = None
40+
41+
return Entity('MEDIA', 'IMMUTABLE', {
42+
'id': attrs['id'],
43+
'title': media.title,
44+
'type': media.type,
45+
46+
'thumbnail': media.thumbnail.url if media.thumbnail else '',
47+
'file': media.file.url if media.file else '',
48+
49+
'autoplay': True if attrs.get('autoplay') == 'true' else False,
50+
'loop': True if attrs.get('loop') == 'true' else False,
51+
'mute': True if attrs.get('mute') == 'true' else False
52+
})
53+
54+
55+
ContentstateMediaConversionRule = {
56+
'from_database_format': {
57+
'embed[embedtype="wagtailmedia"]': MediaElementHandler(),
58+
},
59+
'to_database_format': {
60+
'entity_decorators': {
61+
'MEDIA': media_entity
62+
}
63+
}
64+
}

wagtailmedia/embed_handlers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from django.template.loader import render_to_string
2+
3+
from wagtail import VERSION as WAGTAIL_VERSION
4+
5+
from wagtailmedia.models import get_media_model
6+
7+
if WAGTAIL_VERSION < (2, 5):
8+
from wagtail.embeds.rich_text import MediaEmbedHandler
9+
else:
10+
from wagtail.core.rich_text import EmbedHandler
11+
12+
13+
class MediaEmbedHandler(EmbedHandler):
14+
identifier = 'wagtailmedia'
15+
16+
@staticmethod
17+
def get_model():
18+
return get_media_model()
19+
20+
@staticmethod
21+
def expand_db_attributes(attrs):
22+
"""
23+
Given a dict of attributes from the <embed> tag, return the real HTML
24+
representation for use on the front-end.
25+
"""
26+
27+
if(attrs['type'] == 'video'):
28+
template = 'wagtailmedia/embeds/video_embed.html'
29+
elif(attrs['type'] == 'audio'):
30+
template = 'wagtailmedia/embeds/audio_embed.html'
31+
32+
return render_to_string(template, {
33+
'title': attrs['title'],
34+
35+
'thumbnail': attrs['thumbnail'],
36+
'file': attrs['file'],
37+
38+
'autoplay': True if attrs['autoplay'] == 'true' else False,
39+
'loop': True if attrs['loop'] == 'true' else False,
40+
'mute': True if attrs['mute'] == 'true' else False
41+
})

wagtailmedia/forms.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,13 @@ def get_media_form(model):
7878
],
7979
'wagtailmedia/permissions/includes/media_permissions_formset.html'
8080
)
81+
82+
83+
class MediaInsertionForm(forms.Form):
84+
"""
85+
Form for customizing media player behavior (e.g. autoplay by default)
86+
prior to insertion into a rich text area
87+
"""
88+
autoplay = forms.BooleanField(required=False)
89+
mute = forms.BooleanField(required=False)
90+
loop = forms.BooleanField(required=False)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
const React = window.React;
2+
const ReactDOM = window.ReactDOM;
3+
const Modifier = window.DraftJS.Modifier;
4+
const EditorState = window.DraftJS.EditorState;
5+
const AtomicBlockUtils = window.DraftJS.AtomicBlockUtils;
6+
7+
/**
8+
* Choose a media file in this modal
9+
*/
10+
class WagtailMediaChooser extends window.draftail.ModalWorkflowSource {
11+
componentDidMount() {
12+
const { onClose, entityType, entity, editorState } = this.props;
13+
14+
$(document.body).on('hidden.bs.modal', this.onClose);
15+
16+
this.workflow = global.ModalWorkflow({
17+
url: `${window.chooserUrls.mediaChooser}?select_format=true`,
18+
onload: MEDIA_CHOOSER_MODAL_ONLOAD_HANDLERS,
19+
urlParams: {},
20+
responses: {
21+
mediaChosen: (data) => this.onChosen(data)
22+
},
23+
onError: (err) => {
24+
console.error("WagtailMediaChooser Error", err);
25+
onClose();
26+
},
27+
});
28+
}
29+
30+
onChosen(data) {
31+
const { editorState, entityType, onComplete } = this.props;
32+
33+
const content = editorState.getCurrentContent();
34+
const selection = editorState.getSelection();
35+
36+
const entityData = data;
37+
const mutability = 'IMMUTABLE';
38+
39+
const contentWithEntity = content.createEntity(entityType.type, mutability, entityData);
40+
const entityKey = contentWithEntity.getLastCreatedEntityKey();
41+
const nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' ');
42+
43+
this.workflow.close();
44+
45+
onComplete(nextState);
46+
}
47+
}
48+
49+
// Constraints the maximum size of the tooltip.
50+
const OPTIONS_MAX_WIDTH = 300;
51+
const OPTIONS_SPACING = 70;
52+
const TOOLTIP_MAX_WIDTH = OPTIONS_MAX_WIDTH + OPTIONS_SPACING;
53+
54+
/**
55+
* Places media thumbnail HTML in the Rich Text Editor
56+
*/
57+
class WagtailMediaBlock extends React.Component {
58+
constructor(props) {
59+
super(props);
60+
61+
this.state = {
62+
showTooltipAt: null,
63+
};
64+
65+
this.setState = this.setState.bind(this);
66+
this.openTooltip = this.openTooltip.bind(this);
67+
this.closeTooltip = this.closeTooltip.bind(this);
68+
this.renderTooltip = this.renderTooltip.bind(this);
69+
}
70+
71+
componentDidMount() {
72+
document.addEventListener('mouseup', this.closeTooltip);
73+
document.addEventListener('keyup', this.closeTooltip);
74+
window.addEventListener('resize', this.closeTooltip);
75+
}
76+
77+
openTooltip(e) {
78+
const { blockProps } = this.props;
79+
const { entity, onRemoveEntity } = blockProps;
80+
const data = entity.getData();
81+
82+
const trigger = e.target.closest('[data-draftail-trigger]');
83+
84+
if (!trigger) return; // Click is within the tooltip
85+
86+
const container = trigger.closest('[data-draftail-editor-wrapper]');
87+
88+
if (container.children.length > 1) return; // Tooltip already exists
89+
90+
const containerRect = container.getBoundingClientRect();
91+
const rect = trigger.getBoundingClientRect();
92+
const maxWidth = trigger.parentNode.offsetWidth - rect.width;
93+
const direction = maxWidth >= TOOLTIP_MAX_WIDTH ? 'left' : 'top-left'; // Determines position of the arrow on the tooltip
94+
95+
let top = 0;
96+
let left = 0;
97+
98+
if(direction == 'left'){
99+
left = rect.width + 50;
100+
top = rect.top - containerRect.top + (rect.height / 2);
101+
}
102+
else if (direction == 'top-left'){
103+
top = rect.top - containerRect.top + rect.height;
104+
}
105+
106+
this.setState({
107+
showTooltipAt: {
108+
container: container,
109+
top: top,
110+
left: left,
111+
width: rect.width,
112+
height: rect.height,
113+
direction: direction,
114+
}
115+
});
116+
}
117+
118+
closeTooltip(e) {
119+
if(e.target.classList){
120+
if(e.target.classList.contains("Tooltip__button")){
121+
return; // Don't setState if the "Delete" button was clicked
122+
}
123+
}
124+
this.setState({ showTooltipAt: null });
125+
}
126+
127+
/**
128+
* Returns either a tooltip "portal" element or null
129+
*/
130+
renderTooltip(data) {
131+
const { showTooltipAt } = this.state;
132+
const { blockProps } = this.props;
133+
const { entity, onRemoveEntity } = blockProps;
134+
135+
// No tooltip coords exist, don't show one
136+
if(!showTooltipAt) return null;
137+
138+
let options = []
139+
if(data.autoplay) options.push("Autoplay");
140+
if(data.mute) options.push("Mute");
141+
if(data.loop) options.push("Loop");
142+
const options_str = options.length ? options.join(", ") : "";
143+
144+
return ReactDOM.createPortal(React.createElement('div', null,
145+
React.createElement('div',
146+
{
147+
style: {
148+
top: showTooltipAt.top,
149+
left: showTooltipAt.left
150+
},
151+
class: "Tooltip Tooltip--"+showTooltipAt.direction,
152+
role: "tooltip"
153+
},
154+
React.createElement('div', { style: { maxWidth: showTooltipAt.width } }, [
155+
React.createElement('p', {
156+
class: "ImageBlock__alt"
157+
}, data.type.toUpperCase()+": "+data.title),
158+
React.createElement('p', { class: "ImageBlock__alt" }, options_str),
159+
React.createElement('button', {
160+
class: "button button-secondary no Tooltip__button",
161+
onClick: onRemoveEntity
162+
}, "Delete")
163+
])
164+
)
165+
), showTooltipAt.container);
166+
}
167+
168+
render() {
169+
const { blockProps } = this.props;
170+
const { entity } = blockProps;
171+
const data = entity.getData();
172+
173+
let icon;
174+
if(data.type == 'video'){
175+
icon = React.createElement('span', { class:"icon icon-fa-video-camera", 'aria-hidden':"true" });
176+
}
177+
else if(data.type == 'audio'){
178+
icon = React.createElement('span', { class:"icon icon-fa-music", 'aria-hidden':"true" });
179+
}
180+
181+
return React.createElement('button',
182+
{
183+
class: 'MediaBlock WagtailMediaBlock '+data.type,
184+
type: 'button',
185+
tabindex: '-1',
186+
'data-draftail-trigger': "true",
187+
onClick: this.openTooltip,
188+
style: { 'min-width': '100px', 'min-height': '100px'}
189+
},
190+
[
191+
React.createElement('span',
192+
{ class:"MediaBlock__icon-wrapper", 'aria-hidden': "true" },
193+
React.createElement('span', {}, icon)
194+
),
195+
React.createElement('img', { src: data.thumbnail }),
196+
this.renderTooltip(data)
197+
]
198+
);
199+
}
200+
}
201+
202+
window.draftail.registerPlugin({
203+
type: 'MEDIA',
204+
source: WagtailMediaChooser,
205+
block: WagtailMediaBlock
206+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{% load wagtailimages_tags %}
2+
{% load i18n %}
3+
{% trans "Media Player Options" as choose_str %}
4+
{% include "wagtailadmin/shared/header.html" with title=choose_str %}
5+
6+
<div class="row row-flush nice-padding">
7+
<div class="col5">
8+
{% if media.thumbnail %}
9+
<img src="{{media.thumbnail.url}}" alt="{{media.title}}">
10+
{% elif media.type == 'video' %}
11+
<div class="media-thumbnail-placeholder video">
12+
<i class="icon icon-fa-video-camera"></i>
13+
</div>
14+
{% elif media.type == 'audio' %}
15+
<div class="media-thumbnail-placeholder audio">
16+
<i class="icon icon-fa-music"></i>
17+
</div>
18+
{% endif %}
19+
</div>
20+
<div class="col7">
21+
<form action="{% url 'wagtailmedia:chooser_select_format' media.id %}" class="media-player-settings" method="POST" novalidate>
22+
{% csrf_token %}
23+
<ul class="fields">
24+
{% for field in form %}
25+
{% include "wagtailadmin/shared/field_as_li.html" with field=field %}
26+
{% endfor %}
27+
<li><input type="submit" value="{% trans 'Insert media' %}" class="button" /></li>
28+
</ul>
29+
</form>
30+
</div>
31+
</div>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% block audio %}
2+
<audio
3+
class="wagtailmedia-audio"
4+
src="{{file}}"
5+
{% if mute %}muted{% endif %}
6+
{% if autoplay %}autoplay{% endif %}
7+
{% if loop %}loop{% endif %}
8+
controls>
9+
</audio>
10+
{% endblock %}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% block video %}
2+
<video class="wagtailmedia-video"
3+
controls
4+
{% if mute %}muted{% endif %}
5+
{% if autoplay %}autoplay{% endif %}
6+
{% if loop %}loop{% endif %}
7+
{% if thumbnail %}poster="{{thumbnail}}"{% endif %}>
8+
<source src="{{file}}" data-title={{title}}>
9+
</video>
10+
{% endblock %}

0 commit comments

Comments
 (0)