Files
writebook/app/javascript/controllers/arrangement_controller.js

404 lines
9.8 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
const Direction = {
BEFORE: -1,
AFTER: 1
}
const NEW_ITEM_ID = "dragged_item"
const NEW_ITEM_DATA_TYPE = "x-writebook/create"
const MOVE_ITEM_DATA_TYPE = "x-writebook/move"
const ITEM_SELECTOR = "[data-arrangement-target=item]"
export default class extends Controller {
static targets = [ "container", "list", "item", "layer", "dragImage" ]
static classes = [ "cursor", "selected", "placeholder", "addingMode", "moveMode" ]
static values = { url: String }
#cursorPosition
#dragItem
#wasDropped
#originalOrder
#selection
#moveMode = false
#arrangeMode = false
// Actions - selection state
click(event) {
if (!this.#arrangeMode) { return }
const target = event.target.closest(ITEM_SELECTOR)
this.#setSelection(target, event.shiftKey)
}
setArrangeMode(event) {
this.#arrangeMode = event.target.checked
for (const item of this.itemTargets) {
item.setAttribute("draggable", this.#arrangeMode)
}
this.#resetSelection()
}
// Actions - keyboard
moveBefore(event) {
if (!this.#arrangeMode) { return }
event.preventDefault()
if (this.#moveMode) {
this.#moveSelection(Direction.BEFORE)
} else {
const newPosition = this.#cursorPosition === undefined ? this.itemTargets.length - 1 : Math.max(this.#cursorPosition - 1, 0)
this.#setCursor(newPosition, event.shiftKey)
}
}
moveAfter(event) {
if (!this.#arrangeMode) { return }
event.preventDefault()
if (this.#moveMode) {
this.#moveSelection(Direction.AFTER)
} else {
const newPosition = this.#cursorPosition === undefined ? 0 : Math.min(this.#cursorPosition + 1, this.itemTargets.length - 1)
this.#setCursor(newPosition, event.shiftKey)
}
}
toggleMoveMode(event) {
if (!this.#arrangeMode) { return }
event.preventDefault()
if (this.#moveMode) {
this.applyMoveMode(event)
} else {
this.#moveMode = true
this.#saveOriginalOrder()
}
this.#renderSelection()
}
applyMoveMode(event) {
if (!this.#arrangeMode) { return }
event.preventDefault()
this.#moveMode = false
this.#renderSelection()
this.#submitMove()
}
cancelMoveMode(event) {
if (!this.#arrangeMode) { return }
event.preventDefault()
if (this.#moveMode) {
this.#restoreOriginalOrder()
this.#moveMode = false
}
this.#resetSelection()
}
// Actions - drag & drop
dragStartCreate(event) {
this.#saveOriginalOrder()
const entry = document.createElement("li")
entry.id = NEW_ITEM_ID
entry.innerHTML = " "
entry.dataset.arrangementTarget = "item"
this.listTarget.prepend(entry)
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData(NEW_ITEM_DATA_TYPE, event.params.url)
this.#wasDropped = false
this.#dragItem = entry
this.#setSelection(entry, false)
this.containerTarget.classList.add(this.addingModeClass)
this.#enableDraggingLayer()
}
dragEndCreate() {
if (!this.#wasDropped) {
this.#dragItem.remove()
this.#restoreOriginalOrder()
this.#selection = undefined
this.#renderSelection()
}
this.containerTarget.classList.remove(this.addingModeClass)
this.#disableDraggingLayer()
}
dragStart(event) {
this.#wasDropped = false
this.#dragItem = event.target
this.#saveOriginalOrder()
event.dataTransfer.dropEffect = "move"
event.dataTransfer.setData(MOVE_ITEM_DATA_TYPE, event.target)
if (!this.#targetIsSelected(event.target)) {
this.#setSelection(event.target, false)
}
if (this.#selectionSize > 1) {
this.dragImageTarget.textContent = `${this.#selectionSize} items`
event.dataTransfer.setDragImage(this.dragImageTarget, 0, 0)
}
this.#enableDraggingLayer()
}
drop(event) {
this.#wasDropped = true
const createURL = event.dataTransfer.getData(NEW_ITEM_DATA_TYPE)
if (createURL) {
this.#submitCreate(createURL)
} else {
if (this.#arrangeMode) {
this.#submitMove()
}
}
}
dragEnd() {
if (!this.#wasDropped) {
this.#restoreOriginalOrder()
this.#selection = undefined
this.#renderSelection()
}
this.#disableDraggingLayer()
}
dragOver(event) {
if (this.itemTargets.includes(event.target) && !this.#targetIsSelected(event.target)) {
const offset = this.itemTargets.indexOf(this.#dragItem) - this.itemTargets.indexOf(event.target)
const isBefore = offset < 0
this.#keepingSelection(() => {
if (isBefore) {
event.target.after(...this.#selectedItems)
} else {
event.target.before(...this.#selectedItems)
}
})
this.#updateLayer()
this.#renderSelection()
}
}
// Internal
#setCursor(index, expandSelection) {
this.#cursorPosition = index
this.itemTargets[this.#cursorPosition].scrollIntoView({ block: 'nearest', inline: 'nearest' })
this.#setSelection(this.itemTargets[index], expandSelection)
}
#setSelection(target, expandSelection) {
const idx = this.itemTargets.indexOf(target)
if (expandSelection && this.#selection) {
this.#selection = [
Math.min(idx, this.#selectionStart),
Math.max(idx, this.#selectionEnd),
]
} else {
this.#selection = [ idx, idx ]
}
this.#renderSelection()
}
#renderSelection() {
for (const [ index, item ] of this.itemTargets.entries()) {
item.classList.toggle(this.selectedClass, index >= this.#selectionStart && index <= this.#selectionEnd)
item.classList.toggle(this.cursorClass, index === this.#cursorPosition)
}
this.containerTarget.classList.toggle(this.moveModeClass, this.#moveMode)
}
#moveSelection(direction) {
this.#keepingSelection(() => {
if (direction === Direction.BEFORE && this.#selectionStart > 0) {
this.itemTargets[this.#selectionEnd].after(this.itemTargets[this.#selectionStart - 1])
this.#cursorPosition--
}
if (direction === Direction.AFTER && this.#selectionEnd < this.itemTargets.length - 1) {
this.itemTargets[this.#selectionStart].before(this.itemTargets[this.#selectionEnd + 1])
this.#cursorPosition++
}
})
this.#renderSelection()
}
#enableDraggingLayer() {
this.#buildLayer()
setTimeout(() => {
this.containerTarget.style.opacity = "0"
}, 0)
}
#disableDraggingLayer() {
this.containerTarget.style.opacity = "1"
this.#clearLayer()
}
#buildLayer() {
const fragment = document.createDocumentFragment()
for (const [ index, item ] of this.itemTargets.entries()) {
const selected = index >= this.#selectionStart && index <= this.#selectionEnd
const clone = selected ? this.#makePlaceholder() : item.cloneNode(true)
clone.style.position = "absolute"
clone.style.pointerEvents = "none"
clone.style.transition = "top 160ms, left 160ms"
fragment.append(clone)
item.clone = clone
}
this.layerTarget.append(fragment)
this.#updateLayer()
}
#updateLayer() {
const updates = []
for (const item of this.itemTargets) {
if (item.clone) {
updates.push({ el: item.clone, rect: this.#getElementRect(item) })
}
}
for (const { el, rect } of updates) {
this.#setElementRect(el, rect)
}
}
#clearLayer() {
this.layerTarget.innerHTML = ""
}
#getElementRect(element) {
const rect = element.getBoundingClientRect()
const parent = element.closest(".position-relative").getBoundingClientRect()
return {
top: rect.top - parent.top,
left: rect.left - parent.left,
bottom: rect.bottom - parent.bottom,
right: rect.right - parent.right,
width: rect.width,
height: rect.height
};
}
#setElementRect(element, rect) {
element.style.position = 'absolute'
element.style.left = `${rect.left}px`
element.style.top = `${rect.top}px`
element.style.width = `${rect.width}px`
element.style.height = `${rect.height}px`
}
#makePlaceholder() {
const node = document.createElement("div")
node.classList.add(this.placeholderClass)
return node
}
#targetIsSelected(target) {
const idx = this.itemTargets.indexOf(target)
return idx >= this.#selectionStart && idx <= this.#selectionEnd
}
#keepingSelection(fn) {
const first = this.itemTargets[this.#selectionStart]
const last = this.itemTargets[this.#selectionEnd]
fn()
this.#selection = [ this.itemTargets.indexOf(first), this.itemTargets.indexOf(last) ]
}
#resetSelection() {
this.#selection = undefined
this.#cursorPosition = undefined
this.#renderSelection()
}
#submitMove() {
const position = this.#selection[0]
const ids = this.itemTargets
.slice(this.#selection[0], this.#selection[1] + 1)
.map((item) => item.dataset.id)
const body = new FormData()
body.append("position", position)
ids.forEach((id) => body.append("id[]", id))
post(this.urlValue, { body })
}
#submitCreate(url) {
const position = this.#selection[0]
const body = new FormData()
body.append("position", position)
post(url, { body, responseKind: "turbo-stream" })
}
#saveOriginalOrder() {
this.#originalOrder = [ ...this.itemTargets ]
}
#restoreOriginalOrder() {
this.listTarget.append(...this.#originalOrder)
this.#originalOrder = undefined
}
get #selectionStart() {
if (this.#selection) {
return this.#selection[0]
}
}
get #selectionEnd() {
if (this.#selection) {
return this.#selection[1]
}
}
get #selectionSize() {
if (this.#selection) {
return this.#selectionEnd - this.#selectionStart + 1
}
return 0
}
get #selectedItems() {
return this.itemTargets.slice(this.#selectionStart, this.#selectionEnd + 1)
}
}