Add skeleton DraftailRichTextArea, with provision for converting from dbHTML to contentstate

This commit is contained in:
Matt Westcott 2017-12-04 02:03:05 +00:00 committed by Thibaud Colas
parent df0a6354ab
commit 6806cff2d5
4 changed files with 375 additions and 0 deletions

View file

@ -0,0 +1,12 @@
from wagtail.admin.rich_text.converters.html_to_contentstate import HtmlToContentStateHandler
class ContentstateConverter():
def __init__(self, features=None):
self.features = features
self.html_to_contentstate_handler = HtmlToContentStateHandler(features)
def from_database_format(self, html):
self.html_to_contentstate_handler.reset()
self.html_to_contentstate_handler.feed(html)
return self.html_to_contentstate_handler.contentstate.as_json(indent=4, separators=(',', ': '))

View file

@ -0,0 +1,88 @@
import json
import random
import string
class Block(object):
def __init__(self, typ, depth=0):
self.type = typ
self.depth = depth
self.text = ""
self.key = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(5))
self.inline_style_ranges = []
self.entity_ranges = []
def as_dict(self):
return {
'key': self.key,
'type': self.type,
'depth': self.depth,
'text': self.text,
'inlineStyleRanges': [isr.as_dict() for isr in self.inline_style_ranges],
'entityRanges': [er.as_dict() for er in self.entity_ranges],
}
class InlineStyleRange(object):
def __init__(self, style):
self.style = style
self.offset = None
self.length = None
def as_dict(self):
return {
'offset': self.offset,
'length': self.length,
'style': self.style,
}
class Entity(object):
def __init__(self, entity_type, mutability, data):
self.entity_type = entity_type
self.mutability = mutability
self.data = data
def as_dict(self):
return {
'mutability': self.mutability,
'type': self.entity_type,
'data': self.data,
}
class EntityRange(object):
def __init__(self, key):
self.key = key
self.offset = None
self.length = None
def as_dict(self):
return {
'key': self.key,
'offset': self.offset,
'length': self.length,
}
class ContentState(object):
"""Pythonic representation of a draft.js contentState structure"""
def __init__(self):
self.blocks = []
self.entity_count = 0
self.entity_map = {}
def add_entity(self, entity):
key = self.entity_count
self.entity_map[key] = entity
self.entity_count += 1
return key
def as_dict(self):
return {
'blocks': [block.as_dict() for block in self.blocks],
'entityMap': {key: entity.as_dict() for (key, entity) in self.entity_map.items()},
}
def as_json(self, **kwargs):
return json.dumps(self.as_dict(), **kwargs)

View file

@ -0,0 +1,238 @@
from html.parser import HTMLParser
from wagtail.admin.rich_text.converters.contentstate_models import (
Block, ContentState, Entity, EntityRange, InlineStyleRange
)
class HandlerState(object):
def __init__(self):
self.current_block = None
self.current_inline_styles = []
self.current_entity_ranges = []
self.depth = 0
self.list_item_type = None
self.pushed_states = []
def push(self):
self.pushed_states.append({
'current_block': self.current_block,
'current_inline_styles': self.current_inline_styles,
'current_entity_ranges': self.current_entity_ranges,
'depth': self.depth,
'list_item_type': self.list_item_type
})
def pop(self):
last_state = self.pushed_states.pop()
self.current_block = last_state['current_block']
self.current_inline_styles = last_state['current_inline_styles']
self.current_entity_ranges = last_state['current_entity_ranges']
self.depth = last_state['depth']
self.list_item_type = last_state['list_item_type']
class ListElementHandler(object):
""" Handler for <ul> / <ol> tags """
def __init__(self, list_item_type):
self.list_item_type = list_item_type
def handle_starttag(self, name, attrs, state, contentstate):
state.push()
if state.list_item_type is None:
# this is not nested in another list => depth remains unchanged
pass
else:
# start the next nesting level
state.depth += 1
state.list_item_type = self.list_item_type
def handle_endtag(self, name, state, contentstate):
state.pop()
class BlockElementHandler(object):
def __init__(self, block_type):
self.block_type = block_type
def create_block(self, name, attrs, state, contentstate):
assert state.current_block is None, "%s element found nested inside another block" % name
return Block(self.block_type, depth=state.depth)
def handle_starttag(self, name, attrs, state, contentstate):
block = self.create_block(name, dict(attrs), state, contentstate)
contentstate.blocks.append(block)
state.current_block = block
def handle_endtag(self, name, state, contentState):
assert not state.current_inline_styles, "End of block reached without closing inline style elements"
assert not state.current_entity_ranges, "End of block reached without closing entity elements"
state.current_block = None
class ListItemElementHandler(BlockElementHandler):
""" Handler for <li> tag """
def __init__(self):
pass # skip setting self.block_type
def create_block(self, name, attrs, state, contentstate):
assert state.list_item_type is not None, "%s element found outside of an enclosing list element" % name
return Block(state.list_item_type, depth=state.depth)
class InlineStyleElementHandler(object):
def __init__(self, style):
self.style = style
def handle_starttag(self, name, attrs, state, contentstate):
assert state.current_block is not None, "%s element found at the top level" % name
inline_style_range = InlineStyleRange(self.style)
inline_style_range.offset = len(state.current_block.text)
state.current_block.inline_style_ranges.append(inline_style_range)
state.current_inline_styles.append(inline_style_range)
def handle_endtag(self, name, state, contentstate):
inline_style_range = state.current_inline_styles.pop()
assert inline_style_range.style == self.style
inline_style_range.length = len(state.current_block.text) - inline_style_range.offset
class LinkElementHandler(object):
def __init__(self, entity_type):
self.entity_type = entity_type
def handle_starttag(self, name, attrs, state, contentstate):
assert state.current_block is not None, "%s element found at the top level" % name
attrs = dict(attrs)
entity = Entity(self.entity_type, 'MUTABLE', {'url': attrs['href']})
key = contentstate.add_entity(entity)
entity_range = EntityRange(key)
entity_range.offset = len(state.current_block.text)
state.current_block.entity_ranges.append(entity_range)
state.current_entity_ranges.append(entity_range)
def handle_endtag(self, name, state, contentstate):
entity_range = state.current_entity_ranges.pop()
entity_range.length = len(state.current_block.text) - entity_range.offset
class AtomicBlockEntityElementHandler(object):
"""
Handler for elements like <img> that exist as a single immutable item at the block level
"""
def handle_starttag(self, name, attrs, state, contentstate):
assert state.current_block is None, "%s element found nested inside another block" % name
entity = self.create_entity(name, dict(attrs), state, contentstate)
key = contentstate.add_entity(entity)
block = Block('atomic', depth=state.depth)
contentstate.blocks.append(block)
block.text = ' '
entity_range = EntityRange(key)
entity_range.offset = 0
entity_range.length = 1
block.entity_ranges.append(entity_range)
def handle_endtag(self, name, state, contentstate):
pass
class ImageElementHandler(AtomicBlockEntityElementHandler):
def create_entity(self, name, attrs, state, contentstate):
return Entity('IMAGE', 'IMMUTABLE', {'altText': attrs.get('alt'), 'src': attrs['src']})
ELEMENT_HANDLERS_BY_FEATURE = {
'ol': {
'ol': ListElementHandler('ordered-list-item'),
'li': ListItemElementHandler(),
},
'ul': {
'ul': ListElementHandler('unordered-list-item'),
'li': ListItemElementHandler(),
},
'h1': {
'h1': BlockElementHandler('header-one'),
},
'h2': {
'h2': BlockElementHandler('header-two'),
},
'h3': {
'h3': BlockElementHandler('header-three'),
},
'h4': {
'h4': BlockElementHandler('header-four'),
},
'h5': {
'h5': BlockElementHandler('header-five'),
},
'h6': {
'h6': BlockElementHandler('header-six'),
},
'italic': {
'i': InlineStyleElementHandler('ITALIC'),
'em': InlineStyleElementHandler('ITALIC'),
},
'bold': {
'b': InlineStyleElementHandler('BOLD'),
'strong': InlineStyleElementHandler('BOLD'),
},
'link': {
'a': LinkElementHandler('LINK'),
},
# 'img': ImageElementHandler(),
}
class HtmlToContentStateHandler(HTMLParser):
def __init__(self, features=None):
self.element_handlers = {
'p': BlockElementHandler('unstyled'),
}
if features is not None:
for feature in features:
try:
feature_element_handlers = ELEMENT_HANDLERS_BY_FEATURE[feature]
except KeyError:
continue
self.element_handlers.update(feature_element_handlers)
super().__init__()
def reset(self):
self.state = HandlerState()
self.contentstate = ContentState()
super().reset()
def add_block(self, block):
self.contentstate.blocks.append(block)
self.current_block = block
def handle_starttag(self, name, attrs):
try:
element_handler = self.element_handlers[name]
except KeyError:
return # ignore unrecognised elements
element_handler.handle_starttag(name, attrs, self.state, self.contentstate)
def handle_endtag(self, name):
try:
element_handler = self.element_handlers[name]
except KeyError:
return # ignore unrecognised elements
element_handler.handle_endtag(name, self.state, self.contentstate)
def handle_data(self, content):
if self.state.current_block is None:
assert not content.strip(), "Bare text content found at the top level: %r" % content
else:
self.state.current_block.text += content

View file

@ -0,0 +1,37 @@
from django.forms import widgets
from wagtail.admin.edit_handlers import RichTextFieldPanel
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
from wagtail.core.rich_text import features
class DraftailRichTextArea(widgets.Textarea):
# this class's constructor accepts a 'features' kwarg
accepts_features = True
def get_panel(self):
return RichTextFieldPanel
def __init__(self, *args, **kwargs):
self.options = kwargs.pop('options', None)
self.features = kwargs.pop('features', None)
if self.features is None:
self.features = features.get_default_features()
self.converter = ContentstateConverter(self.features)
super().__init__(*args, **kwargs)
def render(self, name, value, attrs=None):
if value is None:
translated_value = None
else:
translated_value = self.converter.from_database_format(value)
return super().render(name, translated_value, attrs)
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)