mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-05-05 22:14:45 +00:00
Add skeleton DraftailRichTextArea, with provision for converting from dbHTML to contentstate
This commit is contained in:
parent
df0a6354ab
commit
6806cff2d5
4 changed files with 375 additions and 0 deletions
12
wagtail/admin/rich_text/converters/contentstate.py
Normal file
12
wagtail/admin/rich_text/converters/contentstate.py
Normal 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=(',', ': '))
|
||||
88
wagtail/admin/rich_text/converters/contentstate_models.py
Normal file
88
wagtail/admin/rich_text/converters/contentstate_models.py
Normal 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)
|
||||
238
wagtail/admin/rich_text/converters/html_to_contentstate.py
Normal file
238
wagtail/admin/rich_text/converters/html_to_contentstate.py
Normal 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
|
||||
37
wagtail/admin/rich_text/editors/draftail.py
Normal file
37
wagtail/admin/rich_text/editors/draftail.py
Normal 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)
|
||||
Loading…
Reference in a new issue