diff --git a/src/back/routes/common/sortable/sortable.css b/src/back/routes/common/sortable/sortable.css new file mode 100644 index 0000000..c10e8f4 --- /dev/null +++ b/src/back/routes/common/sortable/sortable.css @@ -0,0 +1,51 @@ +.sortable { + display: grid; + grid-template-columns: 1fr; + --element-height: 50px; + --gap: 8px; + + .sortable__element { + height: var(--element-height); + background-color: white; + box-sizing: border-box; + min-width: 400px; + font-size: 20px; + border: 1px solid black; + display: flex; + align-items: center; + justify-content: center; + cursor: move; + user-select: none; + grid-column: 1/2; + grid-row: calc((var(--index) + 1) * 3 + 1) / calc((var(--index) + 1) * 3 + 3); + + &.is-dragged { + opacity: 0.2; + } + } + + .sortable__hole { + grid-column: 1/2; + opacity: 0.5; + z-index: 1; + pointer-events: none; + grid-row: calc((var(--index) + 1) * 3 + 2) / calc((var(--index) + 1) * 3 + 5); + + &.ready-to-drop + .sortable__spacer { + height: var(--element-height) !important; + } + } + + &:has(.is-dragged) { + .sortable__hole { + pointer-events: all; + } + } + + .sortable__spacer { + grid-column: 1/2; + grid-row: calc((var(--index) + 1) * 3 + 3) / calc((var(--index) + 1) * 3 + 4); + transition: all 100ms; + height: 8px; + } +} diff --git a/src/back/routes/common/sortable/sortable.stimulus.ts b/src/back/routes/common/sortable/sortable.stimulus.ts new file mode 100644 index 0000000..265b87c --- /dev/null +++ b/src/back/routes/common/sortable/sortable.stimulus.ts @@ -0,0 +1,101 @@ +import { Controller } from "stimulus"; + +export default class Sortable extends Controller { + dragged_element: HTMLDivElement | null = null; + + clearDoubleHoles() { + this.element + .querySelectorAll(".ready-to-drop") + .forEach((e: HTMLDivElement) => e.classList.remove("ready-to-drop")); + } + + getNthElement(n: number): HTMLDivElement | null { + return this.element.querySelector(`.sortable__element:nth-child(${n})`); + } + + setIndex(node: HTMLDivElement, index: number) { + node.setAttribute("data-index", String(index)); + (node as HTMLDivElement).style.setProperty("--index", String(index)); + } + + setupHoleListeners(hole: HTMLDivElement) { + hole.addEventListener("dragenter", (event) => { + if (!this.dragged_element) { + return; + } + (event.target as HTMLDivElement).classList.add("ready-to-drop"); + event.preventDefault(); + }); + + hole.addEventListener("dragover", (event) => { + if (!this.dragged_element) { + return; + } + event.preventDefault(); + }); + + hole.addEventListener("dragleave", (event) => { + (event.target as HTMLDivElement).classList.remove("ready-to-drop"); + event.preventDefault(); + }); + + hole.addEventListener("drop", (event) => { + const target = event.target as HTMLDivElement; + target.classList.remove("ready-to-drop"); + const index_of_dropped_element = parseInt( + this.dragged_element.getAttribute("data-index") + ); + const index_of_drop_target = parseInt(target.getAttribute("data-index")); + + const nodes_of_dropped_element_index = this.element.querySelectorAll( + `[data-index="${index_of_dropped_element}"]` + ); + const nodes_of_target_index = this.element.querySelectorAll( + `[data-index="${index_of_drop_target}"]` + ); + + let last_node_of_target_index = + nodes_of_target_index[nodes_of_target_index.length - 1]; + + for (const node of Array.from(nodes_of_dropped_element_index)) { + last_node_of_target_index.after(node); + this.setIndex(node as HTMLDivElement, index_of_drop_target); + last_node_of_target_index = node; + } + let next_to_correct = nodes_of_dropped_element_index[0].previousSibling; + const children = Array.from(next_to_correct.parentNode.childNodes); + const children_to_correct = children.slice(2); + for (const dom_index in children_to_correct) { + const index = Math.max(Math.floor(parseInt(dom_index) / 3), 0); + this.setIndex(children_to_correct[dom_index] as HTMLDivElement, index); + } + + event.preventDefault(); + }); + } + + connect() { + this.element.querySelectorAll(".sortable__element").forEach((element) => { + element.addEventListener("dragstart", (e: DragEvent) => { + e.dataTransfer.dropEffect = "move"; + const target = e.target as HTMLDivElement; + this.dragged_element = target; + setTimeout(() => { + // https://stackoverflow.com/a/20733870/1467284 + target.classList.add("is-dragged"); + }, 0); + }); + + element.addEventListener("dragend", (e: DragEvent) => { + const target = e.target as HTMLDivElement; + this.dragged_element = null; + target.classList.remove("is-dragged"); + }); + }); + this.element + .querySelectorAll(".sortable__hole") + .forEach((dropElement: HTMLDivElement) => + this.setupHoleListeners(dropElement) + ); + } +} diff --git a/src/back/routes/common/sortable/sortable.tsx b/src/back/routes/common/sortable/sortable.tsx new file mode 100644 index 0000000..d75ad80 --- /dev/null +++ b/src/back/routes/common/sortable/sortable.tsx @@ -0,0 +1,41 @@ +import { TempstreamJSX } from "tempstream"; + +export function sortable({ items }: { items: JSX.Element[] }) { + return ( +