Skip to content

Commit 39928ad

Browse files
committed
Implement decorator with DOM
1 parent a0d1b75 commit 39928ad

File tree

6 files changed

+128
-78
lines changed

6 files changed

+128
-78
lines changed

draftjs_exporter/composite_decorators.py

Lines changed: 0 additions & 60 deletions
This file was deleted.

draftjs_exporter/html.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ def render_block(self, block, entity_map):
4545
entity_state.apply(command)
4646
self.style_state.apply(command)
4747

48-
style_node = self.style_state.create_node(text)
48+
style_node = self.style_state.create_node(
49+
text, with_decorator=not entity_state.entity_stack)
4950
entity_state.render_entitities(element, style_node)
5051

5152
def build_command_groups(self, block):

draftjs_exporter/style_state.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,28 @@ def get_style_value(self):
5656

5757
return ''.join(sorted(rules))
5858

59-
def create_node(self, text):
60-
61-
for deco in self.composite_decorators:
62-
text = deco.process(text, parent=element)
63-
64-
text_children = list(DOM.parse_html(
65-
'<textnode>' + text + '</textnode>').body.children)
59+
def get_decorated_text(self, text):
60+
while text:
61+
for deco in self.composite_decorators:
62+
match = deco.SEARCH_RE.search(text)
63+
if match:
64+
begin, end = match.span()
65+
yield DOM.create_text_node(text[:begin])
66+
yield deco.replace(match)
67+
text = text[end:]
68+
break
69+
else:
70+
yield DOM.create_text_node(text)
71+
return
72+
73+
def create_node(self, text, with_decorator=True):
74+
if with_decorator:
75+
text_children = self.get_decorated_text(text)
76+
else:
77+
text_children = [DOM.create_text_node(text)]
6678

6779
if self.is_unstyled():
68-
node = DOM.create_text_node('')
80+
node = DOM.create_document_fragment()
6981
for child in text_children:
7082
DOM.append_child(node, child)
7183
else:

example.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from __future__ import absolute_import, unicode_literals
44

5+
import cgi
56
import codecs
7+
import re
68

79
from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES
810
from draftjs_exporter.defaults import BLOCK_MAP, STYLE_MAP
@@ -35,12 +37,51 @@ def render(self, props):
3537
return DOM.create_element('a', {'href': href}, props['children'])
3638

3739

40+
class URLDecorator:
41+
"""
42+
Replace plain urls with actual hyperlinks.
43+
"""
44+
SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)')
45+
46+
def __init__(self, new_window=False):
47+
self.new_window = new_window
48+
49+
def replace(self, match):
50+
u_protocol = match.group(1)
51+
u_href = match.group(2)
52+
u_href = u_protocol + u_href
53+
54+
text = cgi.escape(u_href)
55+
if u_href.startswith("www"):
56+
u_href = "http://" + u_href
57+
props = {'href': u_href}
58+
if self.new_window:
59+
props.update(target="_blank")
60+
61+
return DOM.create_element('a', props, text)
62+
63+
64+
class HashTagDecorator:
65+
"""
66+
Wrap hash tags in spans with specific class.
67+
"""
68+
69+
SEARCH_RE = re.compile(r'#\w+')
70+
71+
def replace(self, match):
72+
return DOM.create_element('em', {'class': 'hash_tag'}, match.group(0))
73+
74+
3875
config = {
3976
'entity_decorators': {
4077
ENTITY_TYPES.LINK: Link(),
4178
ENTITY_TYPES.IMAGE: Image(),
4279
ENTITY_TYPES.TOKEN: Null(),
4380
},
81+
'composite_decorators': [
82+
URLDecorator(),
83+
HashTagDecorator(),
84+
],
4485
# Extend/override the default block map.
4586
'block_map': dict(BLOCK_MAP, **{
4687
BLOCK_TYPES.HEADER_TWO: {
@@ -104,15 +145,15 @@ def render(self, props):
104145
},
105146
{
106147
'key': '5384u',
107-
'text': 'Everyone 🍺 Springload applies the best principles of UX to their work.',
148+
'text': 'Everyone 🍺 Springload applies the best #principles of UX to their work. (https://www.springload.co.nz/work/nz-festival/)',
108149
'type': 'blockquote',
109150
'depth': 0,
110151
'inlineStyleRanges': [],
111152
'entityRanges': []
112153
},
113154
{
114155
'key': 'eelkd',
115-
'text': 'The design decisions we make building tools and services for your customers are based on empathy for what your customers need.',
156+
'text': 'The design decisions we make building #tools and #services for your customers are based on empathy for what your customers need.',
116157
'type': 'unstyled',
117158
'depth': 0,
118159
'inlineStyleRanges': [],

tests/test_composite_decorator.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,48 @@
11
from __future__ import absolute_import, unicode_literals
22

3+
import cgi
34
import re
45
import unittest
6+
7+
from draftjs_exporter.dom import DOM
58
from draftjs_exporter.html import HTML
6-
from draftjs_exporter.composite_decorators import CompositeDecorator, URLDecorator
7-
from draftjs_exporter.entities import Link
89

10+
from .test_entities import Link
11+
12+
13+
class URLDecorator:
14+
"""
15+
Replace plain urls with actual hyperlinks.
16+
"""
17+
SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)')
18+
19+
def __init__(self, new_window=False):
20+
self.new_window = new_window
21+
22+
def replace(self, match):
23+
u_protocol = match.group(1)
24+
u_href = match.group(2)
25+
u_href = u_protocol + u_href
26+
27+
text = cgi.escape(u_href)
28+
if u_href.startswith("www"):
29+
u_href = "http://" + u_href
30+
props = {'href': u_href}
31+
if self.new_window:
32+
props.update(target="_blank")
33+
34+
return DOM.create_element('a', props, text)
35+
36+
37+
class HashTagDecorator:
38+
"""
39+
Wrap hash tags in spans with specific class.
40+
"""
941

10-
class HashTagDecorator(CompositeDecorator):
1142
SEARCH_RE = re.compile(r'#\w+')
1243

1344
def replace(self, match):
14-
return '<span class="hash_tag">{hash_tag}</span>'.format(
15-
hash_tag=match.group(0) or '')
45+
return DOM.create_element('span', {'class': 'hash_tag'}, match.group(0))
1646

1747

1848
config = {
@@ -37,8 +67,12 @@ class TestCompositeDecorator(unittest.TestCase):
3767

3868
def setUp(self):
3969
self.exporter = HTML(config)
70+
self.maxDiff = None
4071

41-
def test_render_with_composite_decorator(self):
72+
def test_render_with_entity_and_decorators(self):
73+
"""
74+
The composite decorator should never render text in any entities.
75+
"""
4276
self.assertEqual(self.exporter.render({
4377
'entityMap': {
4478
'1': {
@@ -71,3 +105,25 @@ def test_render_with_composite_decorator(self):
71105
'<a href="http://www.google.com">www.google.com</a> for '
72106
'<span class="hash_tag">#github</span> and '
73107
'<span class="hash_tag">#facebook</span></div>')
108+
109+
def test_render_with_multiple_decorators(self):
110+
"""
111+
When multiple decorators match the same part of text,
112+
only the first one should perform the replacement.
113+
"""
114+
self.assertEqual(self.exporter.render({
115+
'entityMap': {},
116+
'blocks': [
117+
{
118+
'key': '5s7g9',
119+
'text': 'search http://www.google.com#world for the #world',
120+
'type': 'unstyled',
121+
'depth': 0,
122+
'inlineStyleRanges': [],
123+
'entityRanges': [],
124+
},
125+
]
126+
}),
127+
'<div>search <a href="http://www.google.com#world">'
128+
'http://www.google.com#world</a> for the '
129+
'<span class="hash_tag">#world</span></div>')

tests/test_style_state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def test_get_style_value_multiple(self):
5050
self.assertEqual(self.style_state.get_style_value(), 'text-decoration: underline;')
5151

5252
def test_create_node_unstyled(self):
53-
self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'textnode')
53+
self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'fragment')
5454
self.assertEqual(DOM.get_text_content(self.style_state.create_node('Test text')), 'Test text')
5555

5656
def test_create_node_unicode(self):

0 commit comments

Comments
 (0)