diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 96ff3d31..f1924521 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -1,4 +1,5 @@ -import { createItem, updateItem, focusItem, getItemByEvent, getNodeByEvent } from "./item" +import { Node } from "./node" +import { createItem, updateItem, focusItem, getItemByEvent, getItemById, getNodeByItem, getNodeByEvent } from "./item" export const Hooks = { outline: { @@ -6,15 +7,13 @@ export const Hooks = { const container: HTMLElement = this.el container.addEventListener("focusin", (event: FocusEvent) => { - const node = getNodeByEvent(event) - const uuid = node.uuid + const { uuid } = getNodeByEvent(event) this.pushEvent("set_focus", uuid) }) container.addEventListener("focusout", (event: FocusEvent) => { - const node = getNodeByEvent(event) - const uuid = node.uuid + const { uuid } = getNodeByEvent(event) this.pushEvent("remove_focus", uuid) }) @@ -27,64 +26,151 @@ export const Hooks = { container.addEventListener("keydown", (event: KeyboardEvent) => { const selection = window.getSelection() - const range = selection?.getRangeAt(0) + // const range = selection?.getRangeAt(0) const node = getNodeByEvent(event) + const item = getItemByEvent(event) + const prevItem = item.previousSibling + const nextItem = item.nextSibling + switch (event.key) { - case "Enter": + case "ArrowUp": + if (selection?.anchorOffset != 0) return event.preventDefault() - break - case "ArrowUp": - if (selection?.anchorOffset == 0) { - event.preventDefault() - } + if (!prevItem) return + // otherwise parentItem + + focusItem(prevItem) + this.pushEvent("set_focus", node.uuid) break case "ArrowDown": - if (selection?.anchorOffset == node.content.length) { - event.preventDefault() - } + if (selection?.anchorOffset != node.content.length) return + event.preventDefault() + + if (!nextItem) return + // otherwise firstChildItem + + focusItem(nextItem) + this.pushEvent("set_focus", node.uuid) break - case "Tab": + case "Enter": event.preventDefault() - if (event.shiftKey) { + const splitPos = selection?.anchorOffset || 0 + + const content = node.content + node.content = content?.substring(0, splitPos) + + updateItem(node) + + const newNode: Node = { + temp_id: self.crypto.randomUUID(), + content: content?.substring(splitPos), + parent_id: node.parent_id, + prev_id: node.uuid } + + const newItem = createItem(newNode) + item.after(newItem) + + focusItem(newItem, false) + + this.pushEvent("update_node", node) + this.pushEvent("create_node", newNode) break case "Backspace": - if (node.content.length == 0) { - const item = getItemByEvent(event) - item.parentNode!.removeChild(item) + if (selection?.anchorOffset != 0) return + event.preventDefault() - // focus next item + if (!prevItem) return - this.pushEvent("delete_node", node.uuid) - } + const prevNode = getNodeByItem(prevItem) + prevNode.content += node.content + updateItem(prevNode) + + item.parentNode?.removeChild(item) + + focusItem(prevItem) + this.pushEvent("delete_node", node.uuid) break case "Delete": - if (node.content.length == 0) { - const item = getItemByEvent(event) - item.parentNode!.removeChild(item) + if (selection?.anchorOffset != node.content.length) return + event.preventDefault() - // focus next item + if (!nextItem) return - this.pushEvent("delete_node", node.uuid) - } + const nextNode = getNodeByItem(nextItem) + node.content += nextNode.content + updateItem(node) + + nextItem.parentNode?.removeChild(nextItem) + + focusItem(item) + this.pushEvent("delete_node", nextNode.uuid) break + + // case "Tab": + // event.preventDefault() + + // if (event.shiftKey) { + // // outdentNode(node) + // // node.prev_id = node.parent_id + // } else { + // // indentNode(node) + // } + // break } }) + // container.addEventListener("keyup", (event) => { + // console.log("keyup", event) + // }) + this.handleEvent("list", ({ nodes }) => { nodes.forEach(node => { const item = createItem(node) - container.prepend(item) + container.append(item) + }) + + // sort all items + nodes.forEach(node => { + const item = getItemById(node.uuid) + const prevItem = getItemById(node.prev_id) + const parentItem = getItemById(node.parent_id) + + if (prevItem) { + prevItem.after(item) + } else if (parentItem) { + parentItem.querySelector("ol")?.append(item) + } else { + container.append(item) + } }) + + const lastItem = container.lastElementChild as HTMLLIElement + focusItem(lastItem) }) + + // this.handleEvent("insert", (node: Node) => { + // const item = createItem(node) + // container.append(item) + // }) + + // this.handleEvent("update", (node: Node) => { + // // console.log(node) + // // updateItem(node) + // }) + + // this.handleEvent("delete", ({ uuid }: Node) => { + // const item = getItemById(uuid!) + // item.parentNode!.removeChild(item) + // }) } } } diff --git a/assets/js/hooks/item.ts b/assets/js/hooks/item.ts index 9ed3a274..81c6c04c 100644 --- a/assets/js/hooks/item.ts +++ b/assets/js/hooks/item.ts @@ -1,19 +1,14 @@ -interface Node { - uuid?: string - content: string - creator_id?: number - parent_id?: string - prev_id?: string -} +import { Node } from "./node" -export function createItem({ uuid, content, parent_id, prev_id }: Node) { +export function createItem({ uuid, temp_id, content, parent_id, prev_id }: Node) { const input = document.createElement("div") input.textContent = content input.contentEditable = "plaintext-only" - // const ol = document.createElement("ol") + const ol = document.createElement("ol") const item = document.createElement("li") + temp_id && (item.id = "outline-node-" + temp_id) uuid && (item.id = "outline-node-" + uuid) item.className = "my-2 ml-2" @@ -22,21 +17,22 @@ export function createItem({ uuid, content, parent_id, prev_id }: Node) { item.setAttribute("data-prev", prev_id || "") item.appendChild(input) - // item.appendChild(ol) + item.appendChild(ol) return item } -export function updateItem({ uuid, content, parent_id, prev_id }: Node) { - const item = uuid && getItemById(uuid) +export function updateItem({ uuid, temp_id, content, parent_id, prev_id }: Node) { + const item = getItemById(temp_id || uuid!) + if (!item) return - if (item) { - const input = item.firstChild! - input.textContent = content + temp_id && uuid && (item.id = "outline-node-" + uuid) - item.setAttribute("data-parent", parent_id || "") - item.setAttribute("data-prev", prev_id || "") - } + const input = item.firstChild! + input.textContent = content + + item.setAttribute("data-parent", parent_id || "") + item.setAttribute("data-prev", prev_id || "") } export function getItemById(uuid: string) { @@ -63,26 +59,31 @@ export function getNodeByItem(item: HTMLLIElement): Node { const input = item.firstChild as HTMLDivElement const content = input.textContent! - const parent_id = item.getAttribute("data-parent")! - const prev_id = item.getAttribute("data-prev")! + const parent_id = item.getAttribute("data-parent") || undefined + const prev_id = item.getAttribute("data-prev") || undefined return { uuid, content, parent_id, prev_id } } export function focusItem(item: HTMLLIElement, toEnd: boolean = true) { - const uuid = item.id.split("outline-node-")[1] const input = item.firstChild as HTMLDivElement input.focus() if (toEnd) { const range = document.createRange() - range.selectNodeContents(input) - range.collapse(false) + range.setStart(input, 1) + range.collapse(true) const selection = window.getSelection() selection?.removeAllRanges() selection?.addRange(range) } - - this.pushEvent("set_focus", uuid) } + +// export function indentNode(node: Node) { +// // const node = event.target.parentNode +// // const parentNode = event.target.parentNode.previousSibling +// } + +// export function outdentNode(node: Node) { +// } diff --git a/assets/js/hooks/node.ts b/assets/js/hooks/node.ts new file mode 100644 index 00000000..bb7d970d --- /dev/null +++ b/assets/js/hooks/node.ts @@ -0,0 +1,8 @@ +export interface Node { + uuid?: string + temp_id?: string + content: string + creator_id?: number + parent_id?: string + prev_id?: string +} diff --git a/config/dev.exs b/config/dev.exs index 2a7b1a48..448380b8 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -80,3 +80,5 @@ config :phoenix_live_view, :debug_heex_annotations, true # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +config :mix_test_watch, clear: true diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index 3bb40b4b..d2cb0aca 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -69,25 +69,6 @@ defmodule Radiator.Outline do |> broadcast_node_action(:insert) end - @doc """ - Upsert a node. - - ## Examples - - iex> upsert_node(%{field: new_value}) - {:ok, %Node{}} - - iex> upsert_node(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def upsert_node(attrs) do - %Node{} - |> Node.changeset(attrs) - |> Repo.insert_or_update() - |> broadcast_node_action(:update) - end - @doc """ Updates a node. diff --git a/lib/radiator_web/live/outline_live/index.ex b/lib/radiator_web/live/outline_live/index.ex index e1f7ea83..7914a6f1 100644 --- a/lib/radiator_web/live/outline_live/index.ex +++ b/lib/radiator_web/live/outline_live/index.ex @@ -16,6 +16,7 @@ defmodule RadiatorWeb.OutlineLive.Index do socket |> assign(:page_title, "Outline") |> assign(:bookmarklet, get_bookmarklet(Endpoint.url() <> "/api/v1/outline", socket)) + |> assign(:episode_id, get_episode_id()) |> push_event("list", %{nodes: Outline.list_nodes()}) |> reply(:ok) end @@ -31,16 +32,24 @@ defmodule RadiatorWeb.OutlineLive.Index do |> reply(:noreply) end - def handle_event("create_node", params, socket) do + def handle_event("create_node", %{"temp_id" => temp_id} = params, socket) do user = socket.assigns.current_user - Outline.create_node(params, user) + attrs = Map.put(params, "episode_id", socket.assigns.episode_id) + + socket = + case Outline.create_node(attrs, user) do + {:ok, node} -> socket |> push_event("update", Map.put(node, :temp_id, temp_id)) + _ -> socket + end socket |> reply(:noreply) end - def handle_event("update_node", params, socket) do - Outline.upsert_node(params) + def handle_event("update_node", %{"uuid" => uuid} = params, socket) do + uuid + |> Outline.get_node!() + |> Outline.update_node(params) socket |> reply(:noreply) @@ -55,24 +64,34 @@ defmodule RadiatorWeb.OutlineLive.Index do end @impl true - def handle_info({:insert, node}, socket) do + def handle_info({:insert, _node}, socket) do socket - |> push_event("insert", %{nodes: [node]}) + # |> push_event("insert", node) |> reply(:noreply) end def handle_info({:update, _node}, socket) do socket - # |> push_event("update", %{nodes: [node]}) + # |> push_event("update", node) |> reply(:noreply) end - def handle_info({:delete, node}, socket) do + def handle_info({:delete, _node}, socket) do socket - |> push_event("delete", %{nodes: [node]}) + # |> push_event("delete", node) |> reply(:noreply) end + defp get_episode_id() do + Radiator.Podcast.list_episodes() + |> Enum.sort_by(& &1.id) + |> List.last() + |> case do + nil -> nil + %{id: id} -> id + end + end + defp get_bookmarklet(api_uri, socket) do token = socket.assigns.current_user diff --git a/test/radiator_web/live/outline_live_test.exs b/test/radiator_web/live/outline_live_test.exs index 8944f965..6aa30fbc 100644 --- a/test/radiator_web/live/outline_live_test.exs +++ b/test/radiator_web/live/outline_live_test.exs @@ -5,6 +5,8 @@ defmodule RadiatorWeb.OutlineLiveTest do import Radiator.AccountsFixtures import Radiator.OutlineFixtures + alias Radiator.Outline + describe "Outline page is restricted" do test "can render if user is logged in", %{conn: conn} do user = user_fixture() @@ -34,9 +36,41 @@ defmodule RadiatorWeb.OutlineLiveTest do assert live |> element("h2", "Inbox") - assert_push_event(live, "list", %{nodes: [pushed_node]}) + assert_push_event(live, "list", %{nodes: [^node]}) + end + + test "create a new node", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/admin/outline") + + temp_id = "f894d2ed-9447-4eef-8c31-fc52372b3bbe" + params = %{"temp_id" => temp_id, "content" => "new node temp content"} + + assert live |> render_hook(:create_node, params) + + node = + Outline.list_nodes() + |> Enum.find(&(&1.content == "new node temp content")) + |> Map.put(:temp_id, temp_id) + + assert_push_event(live, "update", ^node) + end + + test "update node", %{conn: conn, node: node} do + {:ok, live, _html} = live(conn, ~p"/admin/outline") + + params = + node + |> Map.from_struct() + |> Map.put(:content, "update node content") + + assert live |> render_hook(:update_node, params) + + updated_node = + Outline.list_nodes() + |> Enum.find(&(&1.content == "update node content")) - assert ^pushed_node = node + assert updated_node.uuid == params.uuid + assert updated_node.content == params.content end end end