Skip to content

Commit

Permalink
feat: keyboard control outline
Browse files Browse the repository at this point in the history
  • Loading branch information
sorax committed Dec 24, 2023
1 parent a0ede33 commit a6f60b3
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 120 deletions.
79 changes: 53 additions & 26 deletions assets/js/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,88 @@
import { createNode, focusNode } from "./node"
import { createItem, updateItem, focusItem, getItemByEvent, getNodeByEvent } from "./item"

export const Hooks = {
outline: {
mounted() {
const container: HTMLElement = this.el

container.addEventListener("focusin", (event: FocusEvent) => {
const target = <HTMLElement>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 = <HTMLElement>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 = <HTMLElement>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)
})
})
}
Expand Down
88 changes: 88 additions & 0 deletions assets/js/hooks/item.ts
Original file line number Diff line number Diff line change
@@ -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 = <HTMLLIElement>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 = <HTMLElement>event.target
const item = <HTMLLIElement>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)
}
30 changes: 0 additions & 30 deletions assets/js/hooks/node.ts

This file was deleted.

31 changes: 25 additions & 6 deletions lib/radiator/outline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand All @@ -85,7 +104,7 @@ defmodule Radiator.Outline do
node
|> Node.changeset(attrs)
|> Repo.update()
|> broadcast_node_change(:update)
|> broadcast_node_action(:update)
end

@doc """
Expand All @@ -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 """
Expand All @@ -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
42 changes: 22 additions & 20 deletions lib/radiator_web/live/outline_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]})
Expand Down
12 changes: 1 addition & 11 deletions lib/radiator_web/live/outline_live/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,6 @@
</section>

<section class="my-12">
<h2 class="text-2xl">Inbox</h2>

<.form id="inbox-form" for={@form} phx-submit="next">
<.input
type="text"
field={@form[:content]}
placeholder="this input will be removed in the future"
/>
</.form>

<ol id="outline" class="mt-8 list-disc list-inside" phx-hook="outline"></ol>
<ol id="outline" phx-hook="outline"></ol>
</section>
</div>
Loading

0 comments on commit a6f60b3

Please sign in to comment.