defmodule Philtre.Editor.Engine do
@moduledoc """
Holds shared logic for modifying editor blocks
"""
alias Philtre.Block.ContentEditable
alias Philtre.Block.ContentEditable.Cell
alias Philtre.Block.ContentEditable.CleanEmptyCells
alias Philtre.Block.ContentEditable.Reduce
alias Philtre.Block.ContentEditable.Selection
alias Philtre.BlockRegistry
alias Philtre.Editor
alias Philtre.Editor.Utils
require Logger
@spec update(
ContentEditable.t(),
%{required(:selection) => map, required(:cells) => list(map)}
) :: ContentEditable.t()
def update(%_{cells: _} = block, %{selection: selection, cells: new_cells}) do
# the new cells are content received from the client side of the ContentEditable hook
# and should be the exact correct content of the updated block
updated_cells =
Enum.map(new_cells, fn %{"id" => id, "modifiers" => modifiers, "text" => text} ->
%Cell{id: id, modifiers: modifiers, text: text}
end)
resolve_transform(%{block | cells: updated_cells, selection: selection})
end
@spec toggle_style_on_selection(
Editor.t(),
ContentEditable.t(),
%{
required(:selection) => map,
required(:style) => String.t()
}
) :: Editor.t()
def toggle_style_on_selection(%Editor{} = editor, %ContentEditable{} = block, %{
selection: %Selection{
start_id: start_id,
end_id: end_id,
start_offset: start_offset,
end_offset: end_offset
},
style: style
}) do
cells = block.cells
start_cell_index = Enum.find_index(cells, &(&1.id === start_id))
new_block =
if start_id !== end_id do
{cells_before, [start_cell | remaining_cells]} = Enum.split(cells, start_cell_index)
end_cell_index = Enum.find_index(remaining_cells, &(&1.id === end_id))
{cells_between, [end_cell | cells_after]} = Enum.split(remaining_cells, end_cell_index)
{start_text_left, start_text_right} = String.split_at(start_cell.text, start_offset)
start_cell = %{start_cell | text: start_text_left}
new_cell_left = %{start_cell | id: Utils.new_id(), text: start_text_right}
{end_text_left, end_text_right} = String.split_at(end_cell.text, end_offset)
end_cell = %{end_cell | text: end_text_right}
new_cell_right = %{end_cell | id: Utils.new_id(), text: end_text_left}
new_cells =
Enum.map([new_cell_left] ++ cells_between ++ [new_cell_right], fn cell ->
new_modifiers = cell.modifiers |> Enum.concat([style]) |> Enum.dedup() |> Enum.sort()
%{cell | modifiers: new_modifiers}
end)
new_selection = %Selection{
start_id: new_cell_left.id,
end_id: new_cell_right.id,
start_offset: 0,
end_offset: String.length(new_cell_right.text)
}
%{
block
| cells: cells_before ++ new_cells ++ cells_after,
selection: new_selection
}
else
{cells_before, [start_cell | cells_after]} = Enum.split(cells, start_cell_index)
{text_left, remaining_text} = String.split_at(start_cell.text, start_offset)
{text_between, text_right} = String.split_at(remaining_text, end_offset - start_offset)
new_modifiers =
if style in start_cell.modifiers do
List.delete(start_cell.modifiers, style)
else
start_cell.modifiers |> Enum.concat([style]) |> Enum.sort()
end
[_, selected_cell, _] =
new_cells = [
%{start_cell | text: text_left},
%{start_cell | id: Utils.new_id(), text: text_between, modifiers: new_modifiers},
%{start_cell | id: Utils.new_id(), text: text_right}
]
new_selection = %Selection{
start_id: selected_cell.id,
end_id: selected_cell.id,
start_offset: 0,
end_offset: String.length(selected_cell.text)
}
%{
block
| cells: cells_before ++ new_cells ++ cells_after,
selection: new_selection
}
end
new_block =
new_block
|> CleanEmptyCells.call()
|> Reduce.call()
index = Enum.find_index(editor.blocks, &(&1.id === block.id))
new_blocks = List.replace_at(editor.blocks, index, new_block)
%{editor | blocks: new_blocks}
end
def add_block(%Editor{} = editor, %_{id: _} = block) do
index = Enum.find_index(editor.blocks, &(&1.id === block.id))
cell = %Cell{}
new_block = %ContentEditable{
cells: [cell],
id: Utils.new_id(),
kind: "p",
selection: Selection.new_start_of(cell)
}
new_blocks = List.insert_at(editor.blocks, index + 1, new_block)
%{editor | blocks: new_blocks}
end
@doc """
Performs action of spliting a block like into two lines, where both stay part of the same block.
This is the result of the user usually hitting Shift + Enter.
"""
@spec split_line(Editor.t(), ContentEditable.t(), %{required(:selection) => map}) :: Editor.t()
def split_line(%Editor{} = editor, %ContentEditable{kind: kind} = block, %{
selection: %Selection{} = selection
})
when kind in ["p", "pre", "blockquote"] do
new_block =
block
|> set_selection(selection)
|> split_line_at_selection()
Editor.replace_block(editor, block, [new_block])
end
def split_line(%Editor{} = editor, %ContentEditable{}, %{}), do: editor
@spec split_line_at_selection(ContentEditable.t()) :: ContentEditable.t()
defp split_line_at_selection(
%ContentEditable{
selection: %Selection{
start_id: start_id,
end_id: end_id,
start_offset: start_offset,
end_offset: end_offset
}
} = block
)
when start_id == end_id and start_offset == end_offset do
cell_index = Enum.find_index(block.cells, &(&1.id === start_id))
%Cell{} = cell = Enum.at(block.cells, cell_index)
{text_before, text_after} = String.split_at(cell.text, start_offset)
new_text =
[text_before, "\n", text_after]
|> Enum.join()
|> fix_double_newlines()
|> fix_trailing_newline()
%Cell{} = new_cell = %{cell | text: new_text}
new_cells = List.replace_at(block.cells, cell_index, new_cell)
new_selection = shift(block.selection, 1)
%{block | cells: new_cells, selection: new_selection}
end
defp split_line_at_selection(%ContentEditable{} = block), do: block
@spec fix_double_newlines(String.t()) :: String.t()
defp fix_double_newlines(text), do: String.replace(text, "\n\n", "\n", global: true)
# The browser will ignore the final newline in html, so to enable us to add a
# new line at the end of a block without having to hit Shift+Enter twice, we
# have to duplicate the trailing newline
@spec fix_trailing_newline(String.t()) :: String.t()
defp fix_trailing_newline(text) do
if String.ends_with?(text, "\n") do
text <> "\n"
else
text
end
end
@doc """
Performs action of splitting a block into two separate blocks at current cursor position.
This is the result of a user hitting Enter.
The first block retains the type of the original.
The second block is usually a P block.
"""
@spec split_block(
ContentEditable.t(),
%{required(:selection) => Selection.t()}
) :: {ContentEditable.t(), ContentEditable.t()}
def split_block(
%ContentEditable{} = block,
%{selection: %Selection{} = selection}
) do
[block_before, block_after] =
block
|> set_selection(selection)
|> remove_selection()
|> split_at_selection()
block_before =
if empty_block?(block_before) do
%{block_before | cells: [Cell.new()]}
else
block_before
end
block_after =
if empty_block?(block_after) do
cell = Cell.new()
selection = Selection.new_start_of(cell)
%{block_after | cells: [cell], selection: selection}
else
block_after
end
{block_before, block_after}
end
defp set_selection(%type{} = block, %Selection{} = selection) do
type.set_selection(block, selection)
end
defp remove_selection(
%ContentEditable{
selection: %Selection{
start_id: id,
end_id: end_id,
start_offset: offset,
end_offset: end_offset
}
} = block
)
when id == end_id and offset == end_offset do
block
end
defp remove_selection(
%ContentEditable{
selection: %Selection{
start_id: id,
end_id: end_id,
start_offset: start_offset,
end_offset: end_offset
}
} = block
)
when id == end_id and start_offset < end_offset do
index = Enum.find_index(block.cells, &(&1.id === id))
{cells_before, [cell | cells_after]} = Enum.split(block.cells, index)
{cell_before, _cell_in, cell_after} = split_cell(cell, start_offset, end_offset)
new_cells =
cells_before
|> Enum.concat([cell_before, cell_after])
|> Enum.concat(cells_after)
%{block | cells: new_cells, selection: Selection.new_start_of(cell_after)}
end
defp remove_selection(
%ContentEditable{
selection: %Selection{
start_id: start_id,
end_id: end_id,
start_offset: start_offset,
end_offset: end_offset
}
} = block
)
when start_id != end_id and start_offset != end_offset do
index = Enum.find_index(block.cells, &(&1.id === start_id))
{cells_before, [start_cell | rest]} = Enum.split(block.cells, index)
{start_cell_before, _start_cell_after} = split_cell(start_cell, start_offset)
index = Enum.find_index(rest, &(&1.id === end_id))
{_end_cells_before, [end_cell | cells_after]} = Enum.split(rest, index)
{_end_cell_before, end_cell_after} = split_cell(end_cell, end_offset)
cells_before =
cells_before
|> Enum.concat([start_cell_before])
|> Enum.reject(&empty_cell?/1)
[%Cell{} = cell_after | _] =
cells_after =
[end_cell_after]
|> Enum.concat(cells_after)
|> Enum.reject(&empty_cell?/1)
new_cells = Enum.concat(cells_before, cells_after)
%{block | cells: new_cells, selection: Selection.new_start_of(cell_after)}
end
defp split_at_selection(
%ContentEditable{
selection: %Selection{
start_id: id,
end_id: end_id,
start_offset: offset,
end_offset: end_offset
}
} = block
)
when id == end_id and offset == end_offset do
index = Enum.find_index(block.cells, &(&1.id === id))
{cells_before, [cell | cells_after]} = Enum.split(block.cells, index)
{cell_before, cell_after} = split_cell(cell, offset)
cells_before =
cells_before
|> Enum.concat([cell_before])
|> Enum.reject(&empty_cell?/1)
cells_after =
[cell_after]
|> Enum.concat(cells_after)
|> Enum.reject(&empty_cell?/1)
cells_after =
if cells_after == [] do
[Cell.new()]
else
cells_after
end
selected_cell = Enum.at(cells_after, 0)
new_kind =
case block.kind do
"li" -> "li"
_ -> "p"
end
[
%{block | cells: cells_before, selection: Selection.new_empty()},
%{
block
| cells: cells_after,
id: Utils.new_id(),
selection: Selection.new_start_of(selected_cell),
kind: new_kind
}
]
end
@doc """
Splits block into two at cursor, then pastes in the current
cliboard contents of the editor, between the two.
"""
@spec paste(Editor.t(), ContentEditable.t(), map) :: Editor.t()
def paste(%Editor{clipboard: nil} = editor, %ContentEditable{} = _block, %{}), do: editor
def paste(%Editor{} = editor, %ContentEditable{} = block, %{
selection: %Selection{} = selection
}) do
[%{cells: [first_cell | _]} = first_block | rest] =
Enum.map(editor.clipboard, &Map.put(&1, :id, Utils.new_id()))
first_block =
Map.put(first_block, :selection, %Selection{
start_id: first_cell.id,
end_id: first_cell.id,
start_offset: 0,
end_offset: 0
})
clipboard_blocks = [first_block | rest]
[block_before, block_after] =
block
|> set_selection(selection)
|> remove_selection()
|> split_at_selection()
new_blocks = Enum.reject([block_before] ++ clipboard_blocks ++ [block_after], &empty_block?/1)
Editor.replace_block(editor, block, new_blocks)
end
@spec empty_block?(ContentEditable.t()) :: boolean
defp empty_block?(%ContentEditable{cells: []}), do: true
defp empty_block?(%ContentEditable{cells: [cell]}), do: empty_cell?(cell)
defp empty_block?(%ContentEditable{}), do: false
defp empty_cell?(%Cell{text: ""}), do: true
defp empty_cell?(%Cell{}), do: false
@spec split_cell(Cell.t(), non_neg_integer()) :: {Cell.t(), Cell.t()}
defp split_cell(%Cell{id: _, modifiers: _, text: _} = cell, index) do
{text_before, text_after} = String.split_at(cell.text, index)
cell_before = %{cell | text: text_before}
cell_after = %{cell | id: Utils.new_id(), text: text_after}
{cell_before, cell_after}
end
@spec split_cell(Cell.t(), non_neg_integer(), non_neg_integer()) ::
{Cell.t(), Cell.t(), Cell.t()}
defp split_cell(%{id: _, text: _, modifiers: _} = cell, start_offset, end_offset)
when start_offset < end_offset do
{text_before, rest} = String.split_at(cell.text, start_offset)
{text_in, text_after} = String.split_at(rest, end_offset - start_offset)
{
%{cell | text: text_before},
%{cell | id: Utils.new_id(), text: text_in},
%{cell | id: Utils.new_id(), text: text_after}
}
end
@spec backspace_from_start(Editor.t(), ContentEditable.t()) :: Editor.t()
def backspace_from_start(%Editor{} = editor, %ContentEditable{kind: "p"} = block) do
merge_previous(editor, block)
end
def backspace_from_start(%Editor{} = editor, %ContentEditable{kind: "h1"} = block) do
convert(editor, block, "h2")
end
def backspace_from_start(%Editor{} = editor, %ContentEditable{kind: "h2"} = block) do
convert(editor, block, "h3")
end
def backspace_from_start(%Editor{} = editor, %ContentEditable{} = block) do
convert(editor, block, "p")
end
@spec convert(Editor.t(), ContentEditable.t(), String.t()) :: Editor.t()
defp convert(%Editor{} = editor, %ContentEditable{} = block, kind)
when kind in ["p", "h1", "h2", "h3"] do
index = Enum.find_index(editor.blocks, &(&1.id === block.id))
if index >= 0 do
new_block = %{block | kind: kind}
new_blocks = List.replace_at(editor.blocks, index, new_block)
%{editor | id: Utils.new_id(), blocks: new_blocks}
else
editor
end
end
defp merge_previous(%Editor{} = editor, %block_type{} = block) do
index = Enum.find_index(editor.blocks, &(&1 == block)) - 1
if index >= 0 do
%previous_type{} = previous_block = Enum.at(editor.blocks, index)
merged = %{merge_second_into_first(previous_block, block) | id: Utils.new_id()}
first_cell = block |> block_type.cells() |> Enum.at(0)
last_cell = previous_block |> previous_type.cells() |> Enum.at(-1)
selection =
cond do
not is_nil(last_cell) and not empty_cell?(last_cell) ->
Selection.new_end_of(last_cell)
not is_nil(first_cell) and not empty_cell?(first_cell) ->
Selection.new_start_of(first_cell)
Enum.count(merged.cells) == 1 ->
[only_cell] = merged.cells
Selection.new_start_of(only_cell)
end
merged = merged |> set_selection(selection) |> reduce()
blocks =
editor.blocks
|> List.delete_at(index + 1)
|> List.replace_at(index, merged)
%{editor | blocks: blocks}
else
editor
end
end
@spec merge_second_into_first(ContentEditable.t(), ContentEditable.t()) :: ContentEditable.t()
defp merge_second_into_first(%ContentEditable{} = first_block, %ContentEditable{} = other_block) do
new_cells =
first_block.cells
|> Enum.concat(other_block.cells)
|> Enum.reject(&empty_cell?/1)
ensure_single_cell(%{first_block | cells: new_cells})
end
defp merge_second_into_first(%first_type{} = first_block, %ContentEditable{} = other_block) do
first_type.merge(first_block, other_block)
end
@spec ensure_single_cell(ContentEditable.t()) :: ContentEditable.t()
defp ensure_single_cell(%ContentEditable{cells: []} = block), do: %{block | cells: [Cell.new()]}
defp ensure_single_cell(%ContentEditable{} = block), do: block
# checks for transform wildcard character sequences in the contents of the block (first cell)
# and applies any matched transform
@spec resolve_transform(ContentEditable.t()) :: ContentEditable.t()
defp resolve_transform(%ContentEditable{} = block) do
with %Cell{text: text} <- Enum.at(block.cells, 0),
{prefix, transform} <- match_transform(text) do
block
|> drop_prefix(prefix)
|> transform(transform)
else
nil -> block
end
end
defp resolve_transform(%s{} = block) do
Logger.warn("TODO: Transform struct #{s}")
block
end
defp match_transform(text) when is_binary(text) do
Enum.find(BlockRegistry.transforms(), fn {prefix, _transform} ->
String.starts_with?(text, prefix)
end)
end
defp drop_prefix(%ContentEditable{} = block, prefix) do
{new_cells, shift} = drop_leading(block.cells, prefix)
%{block | cells: new_cells, selection: shift(block.selection, -shift)}
end
defp transform(%ContentEditable{} = self, {module, data}) do
module.transform(self, data)
end
defp transform(%ContentEditable{} = self, module) do
module.transform(self)
end
@spec drop_leading(list(Cell.t()), String.t()) :: {list(Cell.t()), integer}
defp drop_leading([], _), do: []
defp drop_leading([%Cell{} = first | rest], prefix) do
replaced = String.replace(first.text, prefix, "")
# we need to make sure the transformed cell has at least a single "fake space"
# character into which the editor can focus
# this can probably be written more cleanly
offset =
case replaced do
"" -> String.length(prefix) - 1
_other -> String.length(prefix)
end
new_text =
case replaced do
"" -> ""
other -> other
end
first = %{first | text: new_text}
{[first | rest], offset}
end
@spec shift(Selection.t(), integer) :: Selection.t()
defp shift(
%Selection{
start_id: start_id,
end_id: end_id,
start_offset: start_offset,
end_offset: end_offset
},
amount
) do
end_offset = Kernel.max(end_offset + amount, 0)
start_offset = Kernel.max(start_offset + amount, 0)
%Selection{
start_id: start_id,
end_id: end_id,
start_offset: start_offset,
end_offset: end_offset
}
end
defp reduce(%block_type{} = block), do: block_type.reduce(block)
end