From a6f60b3e736528ca79356916a2696ad44eddae38 Mon Sep 17 00:00:00 2001 From: sorax Date: Mon, 18 Dec 2023 15:13:41 +0100 Subject: [PATCH] feat: keyboard control outline --- assets/js/hooks/index.ts | 79 +++++++++++------ assets/js/hooks/item.ts | 88 +++++++++++++++++++ assets/js/hooks/node.ts | 30 ------- lib/radiator/outline.ex | 31 +++++-- lib/radiator_web/live/outline_live/index.ex | 42 ++++----- .../live/outline_live/index.html.heex | 12 +-- test/radiator_web/live/outline_live_test.exs | 30 +------ 7 files changed, 192 insertions(+), 120 deletions(-) create mode 100644 assets/js/hooks/item.ts delete mode 100644 assets/js/hooks/node.ts diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 452fe6e8..96ff3d31 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -1,4 +1,4 @@ -import { createNode, focusNode } from "./node" +import { createItem, updateItem, focusItem, getItemByEvent, getNodeByEvent } from "./item" export const Hooks = { outline: { @@ -6,56 +6,83 @@ export const Hooks = { const container: HTMLElement = this.el container.addEventListener("focusin", (event: FocusEvent) => { - const target = event.target - const domNode = target.parentElement! - const id = domNode.getAttribute("data-id") + const node = getNodeByEvent(event) + const uuid = node.uuid - this.pushEvent("set_focus", id) + this.pushEvent("set_focus", uuid) }) - container.addEventListener("focusout", (event) => { - const target = event.target - const domNode = target.parentElement! - const id = domNode.getAttribute("data-id") + container.addEventListener("focusout", (event: FocusEvent) => { + const node = getNodeByEvent(event) + const uuid = node.uuid - this.pushEvent("remove_focus", id) + this.pushEvent("remove_focus", uuid) + }) + + container.addEventListener("input", (event: Event) => { + const node = getNodeByEvent(event) + + this.pushEvent("update_node", node) }) container.addEventListener("keydown", (event: KeyboardEvent) => { const selection = window.getSelection() const range = selection?.getRangeAt(0) + const node = getNodeByEvent(event) + switch (event.key) { case "Enter": event.preventDefault() + break - const splitPos = range?.endOffset || 0 + case "ArrowUp": + if (selection?.anchorOffset == 0) { + event.preventDefault() + } + break - const target = event.target - const parent = target.parentElement! + case "ArrowDown": + if (selection?.anchorOffset == node.content.length) { + event.preventDefault() + } + break - const content = target.textContent || "" - const contentBefore = content.substring(0, splitPos) - const contentAfter = content.substring(splitPos) + case "Tab": + event.preventDefault() - const domNode = createNode({ content: contentAfter }) - parent.after(domNode) + if (event.shiftKey) { + } + break - target.textContent = contentBefore + case "Backspace": + if (node.content.length == 0) { + const item = getItemByEvent(event) + item.parentNode!.removeChild(item) - focusNode(domNode) + // focus next item + this.pushEvent("delete_node", node.uuid) + } break - } - }) - container.addEventListener("keyup", (event) => { + case "Delete": + if (node.content.length == 0) { + const item = getItemByEvent(event) + item.parentNode!.removeChild(item) + + // focus next item + + this.pushEvent("delete_node", node.uuid) + } + break + } }) - this.handleEvent("insert", ({ nodes }) => { + this.handleEvent("list", ({ nodes }) => { nodes.forEach(node => { - const li = createNode(node) - container.prepend(li) + const item = createItem(node) + container.prepend(item) }) }) } diff --git a/assets/js/hooks/item.ts b/assets/js/hooks/item.ts new file mode 100644 index 00000000..9ed3a274 --- /dev/null +++ b/assets/js/hooks/item.ts @@ -0,0 +1,88 @@ +interface Node { + uuid?: string + content: string + creator_id?: number + parent_id?: string + prev_id?: string +} + +export function createItem({ uuid, content, parent_id, prev_id }: Node) { + const input = document.createElement("div") + input.textContent = content + input.contentEditable = "plaintext-only" + + // const ol = document.createElement("ol") + + const item = document.createElement("li") + uuid && (item.id = "outline-node-" + uuid) + + item.className = "my-2 ml-2" + + item.setAttribute("data-parent", parent_id || "") + item.setAttribute("data-prev", prev_id || "") + + item.appendChild(input) + // item.appendChild(ol) + + return item +} + +export function updateItem({ uuid, content, parent_id, prev_id }: Node) { + const item = uuid && getItemById(uuid) + + if (item) { + const input = item.firstChild! + input.textContent = content + + item.setAttribute("data-parent", parent_id || "") + item.setAttribute("data-prev", prev_id || "") + } +} + +export function getItemById(uuid: string) { + const item = document.getElementById("outline-node-" + uuid) + + return item +} + +export function getNodeByEvent(event: Event): Node { + const item = getItemByEvent(event) + + return getNodeByItem(item) +} + +export function getItemByEvent(event: Event): HTMLLIElement { + const target = event.target + const item = target.parentElement! + + return item +} + +export function getNodeByItem(item: HTMLLIElement): Node { + const uuid = item.id.split("outline-node-")[1] + const input = item.firstChild as HTMLDivElement + const content = input.textContent! + + const parent_id = item.getAttribute("data-parent")! + const prev_id = item.getAttribute("data-prev")! + + 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) + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + } + + this.pushEvent("set_focus", uuid) +} diff --git a/assets/js/hooks/node.ts b/assets/js/hooks/node.ts deleted file mode 100644 index 00d12f57..00000000 --- a/assets/js/hooks/node.ts +++ /dev/null @@ -1,30 +0,0 @@ -interface Node { - uuid?: string - content?: string - creator_id?: number - parent_id?: string - prev_id?: string -} - -export function createNode({ uuid, content }: Node) { - const input = document.createElement("div") - input.textContent = content || "" - input.contentEditable = "plaintext-only" - - // const ol = document.createElement("ol") - // ol.className = "ml-2 list-disc list-inside" - - const domNode = document.createElement("li") - domNode.className = "my-2 ml-2" - domNode.appendChild(input) - // domNode.appendChild(ol) - uuid && (domNode.id = "outline-node-" + uuid) - - return domNode -} - - -export function focusNode(domNode: HTMLElement) { - const input = domNode.firstChild as HTMLElement - input.focus() -} diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index eac8b317..3bb40b4b 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -59,14 +59,33 @@ defmodule Radiator.Outline do %Node{} |> Node.changeset(attrs) |> Repo.insert() - |> broadcast_node_change(:insert) + |> broadcast_node_action(:insert) end def create_node(attrs, %{id: id}) do %Node{creator_id: id} |> Node.changeset(attrs) |> Repo.insert() - |> broadcast_node_change(:insert) + |> 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 """ @@ -85,7 +104,7 @@ defmodule Radiator.Outline do node |> Node.changeset(attrs) |> Repo.update() - |> broadcast_node_change(:update) + |> broadcast_node_action(:update) end @doc """ @@ -103,7 +122,7 @@ defmodule Radiator.Outline do def delete_node(%Node{} = node) do node |> Repo.delete() - |> broadcast_node_change(:delete) + |> broadcast_node_action(:delete) end @doc """ @@ -119,10 +138,10 @@ defmodule Radiator.Outline do Node.changeset(node, attrs) end - defp broadcast_node_change({:ok, node}, action) do + defp broadcast_node_action({:ok, node}, action) do PubSub.broadcast(Radiator.PubSub, @topic, {action, node}) {:ok, node} end - defp broadcast_node_change({:error, error}, _action), do: {:error, error} + defp broadcast_node_action({:error, error}, _action), do: {:error, error} end diff --git a/lib/radiator_web/live/outline_live/index.ex b/lib/radiator_web/live/outline_live/index.ex index 30a9d058..e1f7ea83 100644 --- a/lib/radiator_web/live/outline_live/index.ex +++ b/lib/radiator_web/live/outline_live/index.ex @@ -13,50 +13,46 @@ defmodule RadiatorWeb.OutlineLive.Index do Endpoint.subscribe(@topic) end - node = %Outline.Node{} - changeset = Outline.change_node(node) - socket |> assign(:page_title, "Outline") |> assign(:bookmarklet, get_bookmarklet(Endpoint.url() <> "/api/v1/outline", socket)) - |> assign(:node, node) - |> assign(:form, to_form(changeset)) - |> push_event("insert", %{nodes: Outline.list_nodes()}) + |> push_event("list", %{nodes: Outline.list_nodes()}) |> reply(:ok) end @impl true - def handle_event("next", %{"node" => params}, socket) do - user = socket.assigns.current_user - {:ok, node} = Outline.create_node(params, user) - + def handle_event("set_focus", _node_id, socket) do socket - |> push_event("insert", %{nodes: [node]}) |> reply(:noreply) end - def handle_event("set_focus", _node_id, socket) do + def handle_event("remove_focus", _node_id, socket) do socket |> reply(:noreply) end - def handle_event("remove_focus", _node_id, socket) do + def handle_event("create_node", params, socket) do + user = socket.assigns.current_user + Outline.create_node(params, user) + socket |> reply(:noreply) end - def handle_event("create_node", _params, socket) do + def handle_event("update_node", params, socket) do + Outline.upsert_node(params) + socket |> reply(:noreply) end - # def handle_event("delete", %{"uuid" => uuid}, socket) do - # node = Outline.get_node!(uuid) - # Outline.delete_node(node) + def handle_event("delete_node", node_id, socket) do + node = Outline.get_node!(node_id) + Outline.delete_node(node) - # socket - # |> reply(:noreply) - # end + socket + |> reply(:noreply) + end @impl true def handle_info({:insert, node}, socket) do @@ -65,6 +61,12 @@ defmodule RadiatorWeb.OutlineLive.Index do |> reply(:noreply) end + def handle_info({:update, _node}, socket) do + socket + # |> push_event("update", %{nodes: [node]}) + |> reply(:noreply) + end + def handle_info({:delete, node}, socket) do socket |> push_event("delete", %{nodes: [node]}) diff --git a/lib/radiator_web/live/outline_live/index.html.heex b/lib/radiator_web/live/outline_live/index.html.heex index b2d185b2..d10789e2 100644 --- a/lib/radiator_web/live/outline_live/index.html.heex +++ b/lib/radiator_web/live/outline_live/index.html.heex @@ -14,16 +14,6 @@
-

Inbox

- - <.form id="inbox-form" for={@form} phx-submit="next"> - <.input - type="text" - field={@form[:content]} - placeholder="this input will be removed in the future" - /> - - -
    +
      diff --git a/test/radiator_web/live/outline_live_test.exs b/test/radiator_web/live/outline_live_test.exs index 6c80cab0..8944f965 100644 --- a/test/radiator_web/live/outline_live_test.exs +++ b/test/radiator_web/live/outline_live_test.exs @@ -29,38 +29,14 @@ defmodule RadiatorWeb.OutlineLiveTest do %{conn: log_in_user(conn, user), node: node} end - test "lists all nodes", %{conn: conn, node: _node} do + test "lists all nodes", %{conn: conn, node: node} do {:ok, live, _html} = live(conn, ~p"/admin/outline") assert live |> element("h2", "Inbox") - assert_push_event(live, "insert", %{nodes: [%{content: "some content"}]}) + assert_push_event(live, "list", %{nodes: [pushed_node]}) - # assert html =~ node.content + assert ^pushed_node = node end - - test "save new node", %{conn: conn} do - {:ok, live, _html} = live(conn, ~p"/admin/outline") - - assert live - |> form("#inbox-form", node: %{content: "new node content"}) - |> render_submit() - - assert live - |> element("ul#inbox") - - assert_push_event(live, "insert", %{nodes: [%{content: "new node content"}]}) - end - - # test "delete existing node", %{conn: conn, node: node} do - # {:ok, live, _html} = live(conn, ~p"/admin/outline") - - # assert live - # |> element("#node-#{node.uuid} a", "Delete") - # |> render_click() - - # refute live - # |> has_element?("#node-#{node.uuid}") - # end end end