Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/draft.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ defmodule Draft do
"<p>Hello</p>"
"""
def to_html(input) do
entity_map = Map.get(input, "entityMap")

input
|> Map.get("blocks")
|> Enum.map(&Draft.Block.to_html/1)
|> Enum.map(&(Draft.Block.to_html(&1, entity_map)))
|> Enum.join("")
end
end
32 changes: 19 additions & 13 deletions lib/draft/block.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ defmodule Draft.Block do
Converts a single DraftJS block to html.
"""

alias Draft.Ranges

@doc """
Renders the given DraftJS input as html.

## Examples
iex> entity_map = %{}
iex> block = %{"key" => "1", "text" => "Hello", "type" => "unstyled",
...> "depth" => 0, "inlineStyleRanges" => [], "entityRanges" => [],
...> "data" => %{}}
iex> Draft.Block.to_html block
iex> Draft.Block.to_html block, entity_map
"<p>Hello</p>"
"""
def to_html(block) do
process_block(block)
def to_html(block, entity_map) do
process_block(block, entity_map)
end

defp process_block(%{"type" => "unstyled",
Expand All @@ -23,7 +26,7 @@ defmodule Draft.Block do
"data" => _,
"depth" => _,
"entityRanges" => _,
"inlineStyleRanges" => _}) do
"inlineStyleRanges" => _}, _) do
"<br>"
end

Expand All @@ -32,30 +35,33 @@ defmodule Draft.Block do
"key" => _,
"data" => _,
"depth" => _,
"entityRanges" => _,
"inlineStyleRanges" => _}) do
"entityRanges" => entity_ranges,
"inlineStyleRanges" => inline_style_ranges},
entity_map) do
tag = header_tags[header]
"<#{tag}>#{text}</#{tag}>"
"<#{tag}>#{Ranges.apply(text, inline_style_ranges, entity_ranges, entity_map)}</#{tag}>"
end

defp process_block(%{"type" => "blockquote",
"text" => text,
"key" => _,
"data" => _,
"depth" => _,
"entityRanges" => _,
"inlineStyleRanges" => _}) do
"<blockquote>#{text}</blockquote>"
"entityRanges" => entity_ranges,
"inlineStyleRanges" => inline_style_ranges},
entity_map) do
"<blockquote>#{Ranges.apply(text, inline_style_ranges, entity_ranges, entity_map)}</blockquote>"
end

defp process_block(%{"type" => "unstyled",
"text" => text,
"key" => _,
"data" => _,
"depth" => _,
"entityRanges" => _,
"inlineStyleRanges" => _}) do
"<p>#{text}</p>"
"entityRanges" => entity_ranges,
"inlineStyleRanges" => inline_style_ranges},
entity_map) do
"<p>#{Ranges.apply(text, inline_style_ranges, entity_ranges, entity_map)}</p>"
end

defp header_tags do
Expand Down
109 changes: 109 additions & 0 deletions lib/draft/ranges.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
defmodule Draft.Ranges do
@moduledoc """
Provides functions for adding inline style ranges and entity ranges
"""

def apply(text, inline_style_ranges, entity_ranges, entity_map) do
inline_style_ranges ++ entity_ranges
|> consolidate_ranges()
|> Enum.reduce(text, fn {start, finish}, acc ->
{style_opening_tag, style_closing_tag} =
case get_styles_for_range(start, finish, inline_style_ranges) do
"" -> {"", ""}
styles -> {"<span style=\"#{styles}\">", "</span>"}
end
entity_opening_tags = get_entity_opening_tags_for_start(start, entity_ranges, entity_map)
entity_closing_tags = get_entity_closing_tags_for_finish(finish, entity_ranges, entity_map)
opening_tags = "#{entity_opening_tags}#{style_opening_tag}"
closing_tags = "#{style_closing_tag}#{entity_closing_tags}"

adjusted_start = start + String.length(acc) - String.length(text)
adjusted_finish = finish + String.length(acc) - String.length(text)

acc
|> String.split_at(adjusted_finish)
|> Tuple.to_list
|> Enum.join(closing_tags)
|> String.split_at(adjusted_start)
|> Tuple.to_list
|> Enum.join(opening_tags)
end)
end

defp process_style("BOLD") do
"font-weight: bold;"
end

defp process_style("ITALIC") do
"font-style: italic;"
end

defp process_entity(%{"type"=>"LINK","mutability"=>"MUTABLE","data"=>%{"url"=>url}}) do
{"<a href=\"#{url}\">", "</a>"}
end

defp get_styles_for_range(start, finish, inline_style_ranges) do
inline_style_ranges
|> Enum.filter(fn range -> is_in_range(range, start, finish) end)
|> Enum.map(fn range -> process_style(range["style"]) end)
|> Enum.join(" ")
end

defp get_entity_opening_tags_for_start(start, entity_ranges, entity_map) do
entity_ranges
|> Enum.filter(fn range -> range["offset"] === start end)
|> Enum.map(fn range -> Map.get(entity_map, range["key"]) |> process_entity() |> elem(0) end)
end

defp get_entity_closing_tags_for_finish(finish, entity_ranges, entity_map) do
entity_ranges
|> Enum.filter(fn range -> range["offset"] + range["length"] === finish end)
|> Enum.map(fn range -> Map.get(entity_map, range["key"]) |> process_entity() |> elem(1) end)
|> Enum.reverse()
end

defp is_in_range(range, start, finish) do
range_start = range["offset"]
range_finish = range["offset"] + range["length"]

start >= range_start && finish <= range_finish
end

@doc """
Takes multiple potentially overlapping ranges and breaks them into other mutually exclusive
ranges, so we can take each mini-range and add the specified, potentially multiple, styles
and entities to each mini-range

## Examples
iex> ranges = [
%{"offset" => 0, "length" => 4, "style" => "ITALIC"},
%{"offset" => 4, "length" => 4, "style" => "BOLD"},
%{"offset" => 2, "length" => 3, "key" => 0}]
iex> consolidate_ranges(ranges)
[{0, 2}, {2, 4}, {4, 5}, {5, 8}]
"""
defp consolidate_ranges(ranges) do
ranges

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So my one overall note on this (aside from it being somewhat hard to follow). Why not us Elixir's actual ranges for this? (didn't really consider that in light of adjusting ranges as the text is modified but it would make finding overlaps and inclusions a little simpler if they could be used)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great minds. I started using ranges, which 1. were introduced in 1.8, so I wasn't pumped about that in a lib, but moreover 2. they are always inclusive, so it was cumbersome translating to/from the ranges that come from draftjs (which can be easily treated as non-inclusive "ranges," but really the offset+length thing made for some rough overhead)

|> ranges_to_points()
|> points_to_ranges()
end

defp points_to_ranges(points) do
points
|> Enum.with_index
|> Enum.reduce([], fn {point, index}, acc ->
case Enum.at(points, index + 1) do
nil -> acc
next -> acc ++ [{point, next}]
end
end)
end

defp ranges_to_points(ranges) do
Enum.reduce(ranges, [], fn range, acc ->
acc ++ [range["offset"], range["offset"] + range["length"]]
end)
|> Enum.uniq
|> Enum.sort
end
end
50 changes: 50 additions & 0 deletions test/draft_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,54 @@ defmodule DraftTest do
output = "<br>"
assert Draft.to_html(input) == output
end

test "wraps single inline style" do
input = %{"entityMap"=>%{},"blocks"=>[%{"text"=>"Hello","inlineStyleRanges"=>[%{"style"=>"BOLD","offset"=>2,"length"=>2}],"type"=>"unstyled","depth"=>0,"entityRanges"=>[],"data"=>%{},"key"=>"9d21d"}]}
output = "<p>He<span style=\"font-weight: bold;\">ll</span>o</p>"
assert Draft.to_html(input) == output
end

test "wraps multiple inline styles" do
input = %{"entityMap"=>%{},"blocks"=>[%{"text"=>"Hello World!","inlineStyleRanges"=>[%{"style"=>"ITALIC","offset"=>8,"length"=>3},%{"style"=>"BOLD","offset"=>2,"length"=>2}],"type"=>"unstyled","depth"=>0,"entityRanges"=>[],"data"=>%{},"key"=>"9d21d"}]}
output = "<p>He<span style=\"font-weight: bold;\">ll</span>o Wo<span style=\"font-style: italic;\">rld</span>!</p>"
assert Draft.to_html(input) == output
end

test "wraps nested inline styles" do
input = %{"entityMap"=>%{},"blocks"=>[%{"text"=>"Hello World!","inlineStyleRanges"=>[%{"style"=>"ITALIC","offset"=>2,"length"=>5},%{"style"=>"BOLD","offset"=>2,"length"=>2}],"type"=>"unstyled","depth"=>0,"entityRanges"=>[],"data"=>%{},"key"=>"9d21d"}]}
output = "<p>He<span style=\"font-style: italic; font-weight: bold;\">ll</span><span style=\"font-style: italic;\">o W</span>orld!</p>"
assert Draft.to_html(input) == output
end

test "wraps overlapping inline styles" do
input = %{"entityMap"=>%{},"blocks"=>[%{"text"=>"Hello World!","inlineStyleRanges"=>[%{"style"=>"ITALIC","offset"=>2,"length"=>5}, %{"style"=>"BOLD","offset"=>4,"length"=>5}],"type"=>"unstyled","depth"=>0,"entityRanges"=>[],"data"=>%{},"key"=>"9d21d"}]}
output = "<p>He<span style=\"font-style: italic;\">ll</span><span style=\"font-style: italic; font-weight: bold;\">o W</span><span style=\"font-weight: bold;\">or</span>ld!</p>"
assert Draft.to_html(input) == output
end

test "wraps anchor entities" do
input = %{"entityMap"=>%{0=>%{"type"=>"LINK","mutability"=>"MUTABLE","data"=>%{"url"=>"http://google.com"}}},
"blocks"=>[%{"text"=>"Hello World!","inlineStyleRanges"=>[],"type"=>"unstyled","depth"=>0,"entityRanges"=>[
%{"offset"=>2,"length"=>3,"key"=>0}
],"data"=>%{},"key"=>"9d21d"}]}
output = "<p>He<a href=\"http://google.com\">llo</a> World!</p>"
assert Draft.to_html(input) == output
end

test "wraps overlapping entities and inline styles" do
input = %{"entityMap"=>%{0=>%{"type"=>"LINK","mutability"=>"MUTABLE","data"=>%{"url"=>"http://google.com"}}},
"blocks"=>[%{"text"=>"Hello World!",
"inlineStyleRanges"=>[
%{"style"=>"ITALIC","offset"=>0,"length"=>4},
%{"style"=>"BOLD","offset"=>4,"length"=>4},
],
"entityRanges"=>[
%{"offset"=>2,"length"=>3,"key"=>0}
],
"type"=>"unstyled",
"depth"=>0,
"data"=>%{},"key"=>"9d21d"}]}
output = "<p><span style=\"font-style: italic;\">He</span><a href=\"http://google.com\"><span style=\"font-style: italic;\">ll</span><span style=\"font-weight: bold;\">o</span></a><span style=\"font-weight: bold;\"> Wo</span>rld!</p>"
assert Draft.to_html(input) == output
end
end