def test_image_inside_link(self): # https://github.com/wagtail/wagtail/issues/4602 - ensure that an <embed> inside # a link is handled. This is not valid in Draftail as images are block-level, # but should be handled without errors, splitting the image into its own block converter = ContentstateConverter(features=['image', 'link']) result = json.loads(converter.from_database_format( ''' <p><a href="https://wagtail.io">before <embed embedtype="image" alt="an image" id="1" format="left" /> after</a></p> <p><a href="https://wagtail.io"><embed embedtype="image" alt="an image" id="1" format="left" /></a></p> ''' )) self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 6}], 'depth': 0, 'text': 'before', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 5}], 'depth': 0, 'text': 'after', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 2, 'offset': 0, 'length': 0}], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 3, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 2, 'offset': 0, 'length': 0}], 'depth': 0, 'text': '', 'type': 'unstyled'}, ], 'entityMap': { '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'https://wagtail.io'}}, '1': { 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'}, 'mutability': 'IMMUTABLE', 'type': 'IMAGE' }, '2': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'https://wagtail.io'}}, '3': { 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'}, 'mutability': 'IMMUTABLE', 'type': 'IMAGE' }, } })
def test_add_spacer_paragraph_between_hrs(self): converter = ContentstateConverter(features=['hr']) result = json.loads(converter.from_database_format( ''' <hr /> <hr /> ''' )) self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, ], 'entityMap': { '0': { 'data': {}, 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE' }, '1': { 'data': {}, 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE' }, } })
def test_add_spacer_paragraph_between_image_embeds(self): converter = ContentstateConverter(features=['image']) result = json.loads(converter.from_database_format( ''' <embed embedtype="image" alt="an image" id="1" format="left" /> <embed embedtype="image" alt="an image" id="1" format="left" /> ''' )) self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, ], 'entityMap': { '0': { 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'}, 'mutability': 'IMMUTABLE', 'type': 'IMAGE' }, '1': { 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'}, 'mutability': 'IMMUTABLE', 'type': 'IMAGE' }, } })
def test_br_element_between_paragraphs(self): converter = ContentstateConverter(features=[]) result = json.loads( converter.from_database_format(''' <p>before</p> <br /> <p>after</p> ''')) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [{ 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled' }, { 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled' }], })
def test_link_in_bare_text(self): converter = ContentstateConverter(features=['link']) result = json.loads( converter.from_database_format( '''an <a href="http://wagtail.io">external</a> link''')) self.assertContentStateEqual( result, { 'entityMap': { '0': { 'mutability': 'MUTABLE', 'type': 'LINK', 'data': { 'url': 'http://wagtail.io' } } }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{ 'offset': 3, 'length': 8, 'key': 0 }] }, ] })
def test_image_after_list(self): """ There should be no spacer paragraph inserted between a list and an image """ converter = ContentstateConverter(features=['ul', 'image']) result = json.loads(converter.from_database_format( ''' <ul> <li>Milk</li> <li>Eggs</li> </ul> <embed embedtype="image" alt="an image" id="1" format="left" /> <ul> <li>More milk</li> <li>More eggs</li> </ul> ''' )) self.assertContentStateEqual(result, { 'entityMap': { '0': { 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'}, 'mutability': 'IMMUTABLE', 'type': 'IMAGE' }, }, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'inlineStyleRanges': [], 'text': 'More milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'More eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, ] })
def test_inline_styles_depend_on_features(self): converter = ContentstateConverter( features=['italic', 'just-made-it-up']) result = json.loads( converter.from_database_format(''' <p>You <b>do <em>not</em> talk</b> about Fight Club.</p> ''')) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [{ 'offset': 7, 'length': 3, 'style': 'ITALIC' }], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
class DraftailRichTextArea(WidgetWithScript, widgets.HiddenInput): # this class's constructor accepts a 'features' kwarg accepts_features = True def get_panel(self): return RichTextFieldPanel def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) kwargs.pop('options', None) self.options = {} self.features = kwargs.pop('features', None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin('draftail', feature) if plugin: plugin.construct_options(self.options) self.converter = ContentstateConverter(self.features) super().__init__(*args, **kwargs) def translate_value(self, value): # Convert database rich text representation to the format required by # the input field if value is None: value = '' return self.converter.from_database_format(value) def render(self, name, value, attrs=None): if attrs is None: attrs = {} attrs['data-draftail-input'] = True translated_value = self.translate_value(value) return super().render(name, translated_value, attrs) def render_js_init(self, id_, name, value): return "window.draftail.initEditor('#{id}', {opts}, document.currentScript)".format( id=id_, opts=json.dumps(self.options)) def value_from_datadict(self, data, files, name): original_value = super().value_from_datadict(data, files, name) if original_value is None: return None return self.converter.to_database_format(original_value) @property def media(self): return Media(js=[ 'wagtailadmin/js/draftail.js', ], css={'all': ['wagtailadmin/css/panels/draftail.css']})
def test_nested_list(self): converter = ContentstateConverter(features=['h1', 'ul']) result = json.loads(converter.from_database_format( ''' <h1>Shopping list</h1> <ul> <li>Milk</li> <li> Flour <ul> <li>Plain</li> <li>Self-raising</li> </ul> </li> <li>Eggs</li> </ul> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Shopping list', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Flour', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Plain', 'depth': 1, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Self-raising', 'depth': 1, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []}, ] })
def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) kwargs.pop('options', None) self.options = {} self._media = Media( js=[ versioned_static('wagtailadmin/js/draftail.js'), ], css={ 'all': [versioned_static('wagtailadmin/css/panels/draftail.css')] }) self.features = kwargs.pop('features', None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin('draftail', feature) if plugin: plugin.construct_options(self.options) self._media += plugin.media self.converter = ContentstateConverter(self.features) default_attrs = {'data-draftail-input': True} attrs = kwargs.get('attrs') if attrs: default_attrs.update(attrs) kwargs['attrs'] = default_attrs super().__init__(*args, **kwargs)
def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) kwargs.pop("options", None) self.options = {} self.plugins = [] self.features = kwargs.pop("features", None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin("draftail", feature) if plugin is None: warnings.warn( f"Draftail received an unknown feature '{feature}'.", category=RuntimeWarning, ) else: plugin.construct_options(self.options) self.plugins.append(plugin) self.converter = ContentstateConverter(self.features) default_attrs = {"data-draftail-input": True} attrs = kwargs.get("attrs") if attrs: default_attrs.update(attrs) kwargs["attrs"] = default_attrs super().__init__(*args, **kwargs)
class DraftailRichTextArea(WidgetWithScript, widgets.HiddenInput): # this class's constructor accepts a 'features' kwarg accepts_features = True def get_panel(self): return RichTextFieldPanel def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) self.options = {} self.features = kwargs.pop('features', None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin('draftail', feature) if plugin: plugin.construct_options(self.options) self.converter = ContentstateConverter(self.features) super().__init__(*args, **kwargs) def translate_value(self, value): # Convert database rich text representation to the format required by # the input field if value is None: value = '' return self.converter.from_database_format(value) def render(self, name, value, attrs=None): if attrs is None: attrs = {} attrs['data-draftail-input'] = True translated_value = self.translate_value(value) return super().render(name, translated_value, attrs) def render_js_init(self, id_, name, value): return "window.draftail.initEditor('#{id}', {opts}, document.currentScript)".format( id=id_, opts=json.dumps(self.options)) def value_from_datadict(self, data, files, name): original_value = super().value_from_datadict(data, files, name) if original_value is None: return None return self.converter.to_database_format(original_value) @property def media(self): return Media(js=[ 'wagtailadmin/js/draftail.js', ], css={ 'all': ['wagtailadmin/css/panels/draftail.css'] })
def test_paragraphs(self): converter = ContentstateConverter(features=[]) result = json.loads( converter.from_database_format(''' <p>Hello world!</p> <p>Goodbye world!</p> ''')) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def test_broken_page_link(self): converter = ContentstateConverter(features=['link']) result = json.loads( converter.from_database_format(''' <p>an <a linktype="page" id="9999">internal</a> link</p> ''')) self.assertContentStateEqual( result, { 'entityMap': { '0': { 'mutability': 'MUTABLE', 'type': 'LINK', 'data': {} } }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an internal link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{ 'offset': 3, 'length': 8, 'key': 0 }] }, ] })
def test_inline_styles_at_start_of_bare_block(self): converter = ContentstateConverter(features=['bold', 'italic']) result = json.loads( converter.from_database_format( '''<b>Seriously</b>, stop talking about <i>Fight Club</i> already.''' )) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [ { 'offset': 0, 'length': 9, 'style': 'BOLD' }, { 'offset': 30, 'length': 10, 'style': 'ITALIC' }, ], 'text': 'Seriously, stop talking about Fight Club already.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def test_inline_styles_at_top_level(self): converter = ContentstateConverter(features=['bold', 'italic']) result = json.loads( converter.from_database_format(''' You <b>do <em>not</em> talk</b> about Fight Club. ''')) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [{ 'offset': 4, 'length': 11, 'style': 'BOLD' }, { 'offset': 7, 'length': 3, 'style': 'ITALIC' }], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def test_broken_document_link(self): converter = ContentstateConverter(features=['document-link']) result = json.loads( converter.from_database_format(''' <p>a <a linktype="document" id="9999">document</a> link</p> ''')) self.assertContentStateEqual( result, { 'entityMap': { '0': { 'mutability': 'MUTABLE', 'type': 'DOCUMENT', 'data': {} } }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'a document link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{ 'offset': 2, 'length': 8, 'key': 0 }] }, ] })
def test_bare_text_becomes_paragraph(self): converter = ContentstateConverter(features=[]) result = json.loads( converter.from_database_format(''' before <p>paragraph</p> between <p>paragraph</p> after ''')) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'before', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [], 'text': 'paragraph', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [], 'text': 'between', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [], 'text': 'paragraph', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [], 'text': 'after', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def test_add_spacer_paras_between_media_embeds(self, get_embed): get_embed.return_value = Embed( url='https://www.youtube.com/watch?v=Kh0Y2hVe_bw', max_width=None, type='video', html='test html', title='what are birds', author_name='look around you', provider_name='YouTube', thumbnail_url='http://test/thumbnail.url', width=1000, height=1000, ) converter = ContentstateConverter(features=['embed']) result = json.loads(converter.from_database_format( ''' <embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" /> <embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" /> ''' )) self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, ], 'entityMap': { '0': { 'data': { 'thumbnail': 'http://test/thumbnail.url', 'embedType': 'video', 'providerName': 'YouTube', 'title': 'what are birds', 'authorName': 'look around you', 'url': 'https://www.youtube.com/watch?v=Kh0Y2hVe_bw' }, 'mutability': 'IMMUTABLE', 'type': 'EMBED' }, '1': { 'data': { 'thumbnail': 'http://test/thumbnail.url', 'embedType': 'video', 'providerName': 'YouTube', 'title': 'what are birds', 'authorName': 'look around you', 'url': 'https://www.youtube.com/watch?v=Kh0Y2hVe_bw' }, 'mutability': 'IMMUTABLE', 'type': 'EMBED' }, } })
def test_ordered_list(self): converter = ContentstateConverter( features=['h1', 'ol', 'bold', 'italic']) result = json.loads( converter.from_database_format(''' <h1>The rules of Fight Club</h1> <ol> <li>You do not talk about Fight Club.</li> <li>You <b>do <em>not</em> talk</b> about Fight Club.</li> </ol> ''')) self.assertContentStateEqual( result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'The rules of Fight Club', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00000', 'entityRanges': [] }, { 'inlineStyleRanges': [{ 'offset': 4, 'length': 11, 'style': 'BOLD' }, { 'offset': 7, 'length': 3, 'style': 'ITALIC' }], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00000', 'entityRanges': [] }, ] })
def test_html_entities(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' <p>Arthur "two sheds" Jackson <the third> & his wife</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Arthur "two sheds" Jackson <the third> & his wife', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, ] })
def test_ignore_unrecognised_tags_in_blocks(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' <p>Hello <foo>frabjuous</foo> world!</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Hello frabjuous world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, ] })
def test_paragraphs_retain_keys(self): converter = ContentstateConverter(features=[]) contentState = json.dumps({ 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00001', 'entityRanges': []}, ] }) result = converter.to_database_format(contentState) self.assertHTMLEqual(result, ''' <p data-block-key='00000'>Hello world!</p> <p data-block-key='00001'>Goodbye world!</p> ''')
def test_br_element_in_paragraph(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' <p>before<br/>after</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before\nafter', 'type': 'unstyled'} ], })
def test_link_at_start_of_bare_text(self): converter = ContentstateConverter(features=['link']) result = json.loads( converter.from_database_format( '''<a href="http://wagtail.io">an external link</a> and <a href="http://torchbox.com">another</a>''' )) self.assertContentStateEqual( result, { 'entityMap': { '0': { 'mutability': 'MUTABLE', 'type': 'LINK', 'data': { 'url': 'http://wagtail.io' } }, '1': { 'mutability': 'MUTABLE', 'type': 'LINK', 'data': { 'url': 'http://torchbox.com' } }, }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an external link and another', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [ { 'offset': 0, 'length': 16, 'key': 0 }, { 'offset': 21, 'length': 7, 'key': 1 }, ] }, ] })
def test_block_element_in_empty_paragraph(self): converter = ContentstateConverter(features=['hr']) result = json.loads( converter.from_database_format(''' <p><hr /></p> ''')) # ignoring the paragraph completely would probably be better, # but we'll settle for an empty preceding paragraph and not crashing as the next best thing... # (and if it's the first/last block we actually do want a spacer paragraph anyhow) self.assertContentStateEqual( result, { 'blocks': [ { 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled' }, { 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{ 'key': 0, 'offset': 0, 'length': 1 }], 'depth': 0, 'text': ' ', 'type': 'atomic' }, { 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled' }, ], 'entityMap': { '0': { 'data': {}, 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE' } } })
def test_paragraphs(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' <p>Hello world!</p> <p>Goodbye world!</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, ] })
def test_reject_javascript_link(self): converter = ContentstateConverter(features=['link']) contentstate_json = json.dumps({ 'entityMap': { '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': "javascript:alert('oh no')"}} }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}] }, ] }) result = converter.to_database_format(contentstate_json) self.assertEqual(result, '<p data-block-key="00000">an <a>external</a> link</p>')
def test_extra_end_tag_after(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' <p>After</p> </p> ''' )) # The tailing </p> tag should be ignored instead of blowing up with a # pop from empty list error self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'After', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, ] })
def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) kwargs.pop('options', None) self.options = {} self._media = Media(js=[ 'wagtailadmin/js/draftail.js', ], css={ 'all': ['wagtailadmin/css/panels/draftail.css'] }) self.features = kwargs.pop('features', None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin('draftail', feature) if plugin: plugin.construct_options(self.options) self._media += plugin.media self.converter = ContentstateConverter(self.features) default_attrs = {'data-draftail-input': True} attrs = kwargs.get('attrs') if attrs: default_attrs.update(attrs) kwargs['attrs'] = default_attrs super().__init__(*args, **kwargs)
def test_link_in_bare_text(self): converter = ContentstateConverter(features=['link']) result = json.loads(converter.from_database_format( '''an <a href="http://wagtail.io">external</a> link''' )) self.assertContentStateEqual(result, { 'entityMap': { '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://wagtail.io'}} }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}] }, ] })
def test_inline_styles_at_start_of_bare_block(self): converter = ContentstateConverter(features=['bold', 'italic']) result = json.loads(converter.from_database_format( '''<b>Seriously</b>, stop talking about <i>Fight Club</i> already.''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [ {'offset': 0, 'length': 9, 'style': 'BOLD'}, {'offset': 30, 'length': 10, 'style': 'ITALIC'}, ], 'text': 'Seriously, stop talking about Fight Club already.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) self.options = {} self.features = kwargs.pop('features', None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin('draftail', feature) if plugin: plugin.construct_options(self.options) self.converter = ContentstateConverter(self.features) super().__init__(*args, **kwargs)
def test_style_fallback(self): # Test a block which uses an invalid inline style, and will be removed converter = ContentstateConverter(features=[]) result = converter.to_database_format(json.dumps({ 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [{'offset': 0, 'length': 12, 'style': 'UNDERLINE'}], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })) self.assertHTMLEqual(result, ''' <p data-block-key="00000"> Hello world! </p> ''')
def test_collapse_targeted_whitespace_characters(self): # We expect all targeted whitespace characters (one or more consecutively) # to be replaced by a single space. (\xa0 is a non-breaking whitespace) converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' <p>Multiple whitespaces: should be reduced</p> <p>Multiple non-breaking whitespace characters: \xa0\xa0\xa0 should be preserved</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Multiple whitespaces: should be reduced', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'Multiple non-breaking whitespace characters: \xa0\xa0\xa0 should be preserved', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, ] })
def test_hr(self): converter = ContentstateConverter(features=['hr']) result = json.loads( converter.from_database_format(''' <p>before</p> <hr /> <p>after</p> ''')) self.assertContentStateEqual( result, { 'blocks': [{ 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled' }, { 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{ 'key': 0, 'offset': 0, 'length': 1 }], 'depth': 0, 'text': ' ', 'type': 'atomic' }, { 'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled' }], 'entityMap': { '0': { 'data': {}, 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE' } } })
def test_p_with_class(self): # Test support for custom conversion rules which require correct treatment of # CSS precedence in HTMLRuleset. Here, <p class="intro"> should match the # 'p[class="intro"]' rule rather than 'p' and thus become an 'intro-paragraph' block converter = ContentstateConverter(features=['intro']) result = json.loads(converter.from_database_format( ''' <p class="intro">before</p> <p>after</p> ''' )) self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'intro-paragraph'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'} ], 'entityMap': {} })
def test_inline_styles_at_top_level(self): converter = ContentstateConverter(features=['bold', 'italic']) result = json.loads(converter.from_database_format( ''' You <b>do <em>not</em> talk</b> about Fight Club. ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [ {'offset': 4, 'length': 11, 'style': 'BOLD'}, {'offset': 7, 'length': 3, 'style': 'ITALIC'} ], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def test_inline_styles_depend_on_features(self): converter = ContentstateConverter(features=['italic', 'just-made-it-up']) result = json.loads(converter.from_database_format( ''' <p>You <b>do <em>not</em> talk</b> about Fight Club.</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ { 'inlineStyleRanges': [ {'offset': 7, 'length': 3, 'style': 'ITALIC'} ], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [] }, ] })
def test_block_element_in_paragraph(self): converter = ContentstateConverter(features=['hr']) result = json.loads(converter.from_database_format( ''' <p>before<hr />after</p> ''' )) self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'} ], 'entityMap': { '0': { 'data': {}, 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE' } } })
def test_link_at_start_of_bare_text(self): converter = ContentstateConverter(features=['link']) result = json.loads(converter.from_database_format( '''<a href="http://wagtail.io">an external link</a> and <a href="http://torchbox.com">another</a>''' )) self.assertContentStateEqual(result, { 'entityMap': { '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://wagtail.io'}}, '1': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://torchbox.com'}}, }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an external link and another', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [ {'offset': 0, 'length': 16, 'key': 0}, {'offset': 21, 'length': 7, 'key': 1}, ] }, ] })
def test_bare_text_becomes_paragraph(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' before <p>paragraph</p> between <p>paragraph</p> after ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'before', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'paragraph', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'between', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'paragraph', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'after', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, ] })
def test_block_element_in_empty_paragraph(self): converter = ContentstateConverter(features=['hr']) result = json.loads(converter.from_database_format( ''' <p><hr /></p> ''' )) # ignoring the paragraph completely would probably be better, # but we'll settle for an empty preceding paragraph and not crashing as the next best thing... self.assertContentStateEqual(result, { 'blocks': [ {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'}, {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'}, ], 'entityMap': { '0': { 'data': {}, 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE' } } })
def test_document_link_with_missing_id(self): converter = ContentstateConverter(features=['document-link']) result = json.loads(converter.from_database_format( ''' <p>a <a linktype="document">document</a> link</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': { '0': { 'mutability': 'MUTABLE', 'type': 'DOCUMENT', 'data': {} } }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'a document link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{'offset': 2, 'length': 8, 'key': 0}] }, ] })
def test_link_to_root_page(self): converter = ContentstateConverter(features=['link']) result = json.loads(converter.from_database_format( ''' <p>an <a linktype="page" id="1">internal</a> link</p> ''' )) self.assertContentStateEqual(result, { 'entityMap': { '0': { 'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'id': 1, 'url': None, 'parentId': None} } }, 'blocks': [ { 'inlineStyleRanges': [], 'text': 'an internal link', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}] }, ] })
def test_ordered_list(self): converter = ContentstateConverter(features=['h1', 'ol', 'bold', 'italic']) result = json.loads(converter.from_database_format( ''' <h1>The rules of Fight Club</h1> <ol> <li>You do not talk about Fight Club.</li> <li>You <b>do <em>not</em> talk</b> about Fight Club.</li> </ol> ''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'The rules of Fight Club', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []}, {'inlineStyleRanges': [], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00000', 'entityRanges': []}, { 'inlineStyleRanges': [ {'offset': 4, 'length': 11, 'style': 'BOLD'}, {'offset': 7, 'length': 3, 'style': 'ITALIC'} ], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00000', 'entityRanges': [] }, ] })
class DraftailRichTextArea(widgets.HiddenInput): template_name = 'wagtailadmin/widgets/draftail_rich_text_area.html' # this class's constructor accepts a 'features' kwarg accepts_features = True def get_panel(self): return RichTextFieldPanel def __init__(self, *args, **kwargs): # note: this constructor will receive an 'options' kwarg taken from the WAGTAILADMIN_RICH_TEXT_EDITORS setting, # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg) kwargs.pop('options', None) self.options = {} self._media = Media(js=[ 'wagtailadmin/js/draftail.js', ], css={ 'all': ['wagtailadmin/css/panels/draftail.css'] }) self.features = kwargs.pop('features', None) if self.features is None: self.features = feature_registry.get_default_features() for feature in self.features: plugin = feature_registry.get_editor_plugin('draftail', feature) if plugin: plugin.construct_options(self.options) self._media += plugin.media self.converter = ContentstateConverter(self.features) default_attrs = {'data-draftail-input': True} attrs = kwargs.get('attrs') if attrs: default_attrs.update(attrs) kwargs['attrs'] = default_attrs super().__init__(*args, **kwargs) def format_value(self, value): # Convert database rich text representation to the format required by # the input field value = super().format_value(value) if value is None: value = '' return self.converter.from_database_format(value) def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) context['widget']['options_json'] = json.dumps(self.options) return context def value_from_datadict(self, data, files, name): original_value = super().value_from_datadict(data, files, name) if original_value is None: return None return self.converter.to_database_format(original_value) @property def media(self): return self._media