-
Notifications
You must be signed in to change notification settings - Fork 70
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
} | ||
} | ||
} |
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 | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
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(", ") : ""; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" }); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, it looks like we should just reuse |
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn’t need |
||
), | ||
React.createElement('img', { src: data.thumbnail }), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs an |
||
this.renderTooltip(data) | ||
] | ||
); | ||
} | ||
} | ||
|
||
window.draftail.registerPlugin({ | ||
type: 'MEDIA', | ||
source: WagtailMediaChooser, | ||
block: WagtailMediaBlock | ||
}); |
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> |
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 %} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
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}}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is |
||
</video> | ||
{% endblock %} |
There was a problem hiding this comment.
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.