From 5f16dc4ee5933c42a62785d839dc29138eb77954 Mon Sep 17 00:00:00 2001 From: juan0tron Date: Sat, 29 Feb 2020 12:28:42 -0800 Subject: [PATCH] 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 --- wagtailmedia/admin_urls.py | 2 + wagtailmedia/contentstate.py | 64 ++++++ wagtailmedia/embed_handlers.py | 41 ++++ wagtailmedia/forms.py | 10 + .../wagtailmedia/js/WagtailMediaBlock.js | 206 ++++++++++++++++++ .../wagtailmedia/chooser/select_format.html | 31 +++ .../wagtailmedia/embeds/audio_embed.html | 10 + .../wagtailmedia/embeds/video_embed.html | 10 + .../templates/wagtailmedia/media/list.html | 40 ++-- wagtailmedia/tests/settings.py | 1 + wagtailmedia/tests/test_views.py | 10 +- wagtailmedia/views/chooser.py | 39 +++- wagtailmedia/wagtail_hooks.py | 37 ++++ 13 files changed, 482 insertions(+), 19 deletions(-) create mode 100644 wagtailmedia/contentstate.py create mode 100644 wagtailmedia/embed_handlers.py create mode 100644 wagtailmedia/static/wagtailmedia/js/WagtailMediaBlock.js create mode 100644 wagtailmedia/templates/wagtailmedia/chooser/select_format.html create mode 100644 wagtailmedia/templates/wagtailmedia/embeds/audio_embed.html create mode 100644 wagtailmedia/templates/wagtailmedia/embeds/video_embed.html diff --git a/wagtailmedia/admin_urls.py b/wagtailmedia/admin_urls.py index b9955048..85ae76f7 100644 --- a/wagtailmedia/admin_urls.py +++ b/wagtailmedia/admin_urls.py @@ -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'), ] diff --git a/wagtailmedia/contentstate.py b/wagtailmedia/contentstate.py new file mode 100644 index 00000000..ed2abe3e --- /dev/null +++ b/wagtailmedia/contentstate.py @@ -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 + + 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 + } + } +} diff --git a/wagtailmedia/embed_handlers.py b/wagtailmedia/embed_handlers.py new file mode 100644 index 00000000..d2342b93 --- /dev/null +++ b/wagtailmedia/embed_handlers.py @@ -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 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 + }) diff --git a/wagtailmedia/forms.py b/wagtailmedia/forms.py index 01674d48..d08d53be 100644 --- a/wagtailmedia/forms.py +++ b/wagtailmedia/forms.py @@ -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) diff --git a/wagtailmedia/static/wagtailmedia/js/WagtailMediaBlock.js b/wagtailmedia/static/wagtailmedia/js/WagtailMediaBlock.js new file mode 100644 index 00000000..e80b2c80 --- /dev/null +++ b/wagtailmedia/static/wagtailmedia/js/WagtailMediaBlock.js @@ -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 +}); diff --git a/wagtailmedia/templates/wagtailmedia/chooser/select_format.html b/wagtailmedia/templates/wagtailmedia/chooser/select_format.html new file mode 100644 index 00000000..75210fd1 --- /dev/null +++ b/wagtailmedia/templates/wagtailmedia/chooser/select_format.html @@ -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 %} + +
+
+ {% if media.thumbnail %} + {{media.title}} + {% elif media.type == 'video' %} +
+ +
+ {% elif media.type == 'audio' %} +
+ +
+ {% endif %} +
+
+
+ {% csrf_token %} +
    + {% for field in form %} + {% include "wagtailadmin/shared/field_as_li.html" with field=field %} + {% endfor %} +
  • +
+
+
+
diff --git a/wagtailmedia/templates/wagtailmedia/embeds/audio_embed.html b/wagtailmedia/templates/wagtailmedia/embeds/audio_embed.html new file mode 100644 index 00000000..4f8dbf0b --- /dev/null +++ b/wagtailmedia/templates/wagtailmedia/embeds/audio_embed.html @@ -0,0 +1,10 @@ +{% block audio %} + +{% endblock %} diff --git a/wagtailmedia/templates/wagtailmedia/embeds/video_embed.html b/wagtailmedia/templates/wagtailmedia/embeds/video_embed.html new file mode 100644 index 00000000..6164c1e6 --- /dev/null +++ b/wagtailmedia/templates/wagtailmedia/embeds/video_embed.html @@ -0,0 +1,10 @@ +{% block video %} + +{% endblock %} diff --git a/wagtailmedia/templates/wagtailmedia/media/list.html b/wagtailmedia/templates/wagtailmedia/media/list.html index fa431941..e35d19e1 100644 --- a/wagtailmedia/templates/wagtailmedia/media/list.html +++ b/wagtailmedia/templates/wagtailmedia/media/list.html @@ -30,23 +30,33 @@ {% for media_file in media_files %} - - {% if choosing %} -

{{ media_file.title }}

- {% else %} -

{{ media_file.title }}

- {% endif %} - - - {% if choosing %} - {{ media_file.filename }} - {% else %} - {{ media_file.filename }} - {% endif %} - + + {% if choosing %} +

+ {% if will_select_format %} + + {{ media_file.title }} + + {% else %} + + {{ media_file.title }} + + {% endif %} +

+ {% else %} +

{{ media_file.title }}

+ {% endif %} + + + {% if choosing %} + {{ media_file.filename }} + {% else %} + {{ media_file.filename }} + {% endif %} + {{ media_file.get_type_display }}
{{ media_file.created_at|timesince }} ago
{% endfor %} - \ No newline at end of file + diff --git a/wagtailmedia/tests/settings.py b/wagtailmedia/tests/settings.py index 323b1715..a021a770 100644 --- a/wagtailmedia/tests/settings.py +++ b/wagtailmedia/tests/settings.py @@ -96,6 +96,7 @@ 'wagtail.images', 'wagtail.users', 'wagtail.documents', + 'wagtail.embeds', 'wagtail.admin', 'wagtail.core', ] diff --git a/wagtailmedia/tests/test_views.py b/wagtailmedia/tests/test_views.py index 15a3d6ad..86b75a7a 100644 --- a/wagtailmedia/tests/test_views.py +++ b/wagtailmedia/tests/test_views.py @@ -631,7 +631,15 @@ def test_simple(self): 'result': { 'id': self.media.id, 'title': self.media.title, - 'edit_link': reverse('wagtailmedia:edit', args=[self.media.id],) + 'type': self.media.type, + 'edit_link': reverse('wagtailmedia:edit', args=[self.media.id],), + + 'file': '', + 'thumbnail': '', + + 'autoplay': False, + 'loop': False, + 'mute': False, } }) diff --git a/wagtailmedia/views/chooser.py b/wagtailmedia/views/chooser.py index 7daddc60..c73142bc 100644 --- a/wagtailmedia/views/chooser.py +++ b/wagtailmedia/views/chooser.py @@ -6,6 +6,7 @@ from wagtail.core import hooks from wagtail.core.models import Collection +from wagtailmedia.forms import MediaInsertionForm from wagtailmedia.models import get_media_model from wagtailmedia.permissions import permission_policy from wagtailmedia.utils import paginate @@ -25,16 +26,23 @@ permission_checker = PermissionPolicyChecker(permission_policy) -def get_media_json(media): +def get_media_json(media, player_options={}): """ helper function: given a media, return the json to pass back to the chooser panel """ - return { 'id': media.id, 'title': media.title, - 'edit_link': reverse('wagtailmedia:edit', args=(media.id,)) + 'edit_link': reverse('wagtailmedia:edit', args=(media.id,)), + 'type': media.type, + + 'thumbnail': media.thumbnail.url if media.thumbnail else '', + 'file': media.file.url if media.file else '', + + 'autoplay': player_options.get('autoplay', False), + 'loop': player_options.get('loop', False), + 'mute': player_options.get('mute', False) } @@ -89,6 +97,7 @@ def chooser(request): 'collections': collections, 'is_searching': False, 'pagination_template': pagination_template, + 'will_select_format': request.GET.get('select_format') }, json_data={ 'step': 'chooser', 'error_label': "Server Error", @@ -105,3 +114,27 @@ def media_chosen(request, media_id): None, json_data={'step': 'media_chosen', 'result': get_media_json(media)} ) + + +def chooser_select_format(request, media_id): + media = get_object_or_404(get_media_model(), id=media_id) + + # POST the completed form + if request.method == 'POST': + form = MediaInsertionForm(request.POST, prefix='media-chooser-insertion') + if form.is_valid(): + media_data = get_media_json(media, form.cleaned_data) + return render_modal_workflow( + request, None, None, + None, json_data={'step': 'media_chosen', 'result': media_data} + ) + # GET the empty form for the user to fill out + else: + initial = {} + initial.update(request.GET.dict()) + form = MediaInsertionForm(request.POST, prefix='media-chooser-insertion') + + return render_modal_workflow( + request, 'wagtailmedia/chooser/select_format.html', None, + {'media': media, 'form': form}, json_data={'step': 'select_format'} + ) diff --git a/wagtailmedia/wagtail_hooks.py b/wagtailmedia/wagtail_hooks.py index c14ccb9c..7387cb91 100644 --- a/wagtailmedia/wagtail_hooks.py +++ b/wagtailmedia/wagtail_hooks.py @@ -5,11 +5,14 @@ from django.utils.translation import ungettext from wagtail.admin.menu import MenuItem +from wagtail.admin.rich_text.editors.draftail import features as draftail_features from wagtail.admin.search import SearchArea from wagtail.admin.site_summary import SummaryItem from wagtail.core import hooks from wagtailmedia import admin_urls +from wagtailmedia.contentstate import ContentstateMediaConversionRule +from wagtailmedia.embed_handlers import MediaEmbedHandler from wagtailmedia.forms import GroupMediaPermissionFormSet from wagtailmedia.models import get_media_model from wagtailmedia.permissions import permission_policy @@ -103,3 +106,37 @@ def describe_collection_media(collection): ) % {'count': media_count}, 'url': url, } + + +@hooks.register('register_rich_text_features') +def register_custom_media_feature(features): + # Register a handler for converting to frontend HTML + features.register_embed_type(MediaEmbedHandler) + + feature_name = 'wagtailmedia' + type_ = 'MEDIA' + + control = { + 'type': type_, + 'icon': 'media', + 'description': 'Media', + } + + plugin_js = [ + 'wagtailmedia/js/media-chooser-modal.js', + 'wagtailmedia/js/WagtailMediaBlock.js', + ] + + features.register_editor_plugin( + 'draftail', + feature_name, + draftail_features.EntityFeature(control, js=plugin_js) + ) + + features.register_converter_rule( + 'contentstate', + feature_name, + ContentstateMediaConversionRule + ) + + features.default_features.append(feature_name)