This commit is contained in:
1
app/javascript/actions/index.js
Normal file
1
app/javascript/actions/index.js
Normal file
@@ -0,0 +1 @@
|
||||
import "actions/scroll_into_view"
|
||||
16
app/javascript/actions/scroll_into_view.js
Normal file
16
app/javascript/actions/scroll_into_view.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Turbo } from "@hotwired/turbo-rails"
|
||||
|
||||
Turbo.StreamActions.scroll_into_view = function() {
|
||||
const animation = this.getAttribute("animation")
|
||||
const element = this.targetElements[0]
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
|
||||
if (animation) {
|
||||
element.addEventListener("animationend", () => {
|
||||
element.classList.remove(animation)
|
||||
}, { once: true })
|
||||
|
||||
element.classList.add(animation)
|
||||
}
|
||||
}
|
||||
6
app/javascript/application.js
Normal file
6
app/javascript/application.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "actions"
|
||||
import "controllers"
|
||||
import "house"
|
||||
|
||||
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
403
app/javascript/controllers/arrangement_controller.js
Normal file
403
app/javascript/controllers/arrangement_controller.js
Normal file
@@ -0,0 +1,403 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
7
app/javascript/controllers/auto_submit_controller.js
Normal file
7
app/javascript/controllers/auto_submit_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.requestSubmit()
|
||||
}
|
||||
}
|
||||
7
app/javascript/controllers/autoremove_controller.js
Normal file
7
app/javascript/controllers/autoremove_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
remove() {
|
||||
this.element.remove()
|
||||
}
|
||||
}
|
||||
59
app/javascript/controllers/autosave_controller.js
Normal file
59
app/javascript/controllers/autosave_controller.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { submitForm } from "helpers/form_helpers"
|
||||
|
||||
const AUTOSAVE_INTERVAL = 3000
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = [ "clean", "dirty", "saving" ]
|
||||
|
||||
#timer
|
||||
|
||||
// Lifecycle
|
||||
|
||||
disconnect() {
|
||||
this.submit()
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
async submit() {
|
||||
if (this.#dirty) {
|
||||
await this.#save()
|
||||
}
|
||||
}
|
||||
|
||||
change(event) {
|
||||
if (event.target.form === this.element && !this.#dirty) {
|
||||
this.#scheduleSave()
|
||||
this.#updateAppearance()
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
async #save() {
|
||||
this.#updateAppearance(true)
|
||||
this.#resetTimer()
|
||||
await submitForm(this.element)
|
||||
this.#updateAppearance()
|
||||
}
|
||||
|
||||
#updateAppearance(saving = false) {
|
||||
this.element.classList.toggle(this.cleanClass, !this.#dirty)
|
||||
this.element.classList.toggle(this.dirtyClass, this.#dirty)
|
||||
this.element.classList.toggle(this.savingClass, saving)
|
||||
}
|
||||
|
||||
#scheduleSave() {
|
||||
this.#timer = setTimeout(() => this.#save(), AUTOSAVE_INTERVAL)
|
||||
}
|
||||
|
||||
#resetTimer() {
|
||||
clearTimeout(this.#timer)
|
||||
this.#timer = null
|
||||
}
|
||||
|
||||
get #dirty() {
|
||||
return !!this.#timer
|
||||
}
|
||||
}
|
||||
13
app/javascript/controllers/autoselect_controller.js
Normal file
13
app/javascript/controllers/autoselect_controller.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
if (this.autoselect) {
|
||||
this.element.select()
|
||||
}
|
||||
}
|
||||
|
||||
get autoselect() {
|
||||
return this.element.autofocus
|
||||
}
|
||||
}
|
||||
25
app/javascript/controllers/copy_to_clipboard_controller.js
Normal file
25
app/javascript/controllers/copy_to_clipboard_controller.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { content: String }
|
||||
static classes = [ "success" ]
|
||||
|
||||
async copy(event) {
|
||||
event.preventDefault()
|
||||
this.reset()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.contentValue)
|
||||
this.element.classList.add(this.successClass)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.element.classList.remove(this.successClass)
|
||||
this.#forceReflow()
|
||||
}
|
||||
|
||||
#forceReflow() {
|
||||
this.element.offsetWidth
|
||||
}
|
||||
}
|
||||
15
app/javascript/controllers/dependent_checkbox_controller.js
Normal file
15
app/javascript/controllers/dependent_checkbox_controller.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "dependant", "dependee" ]
|
||||
|
||||
input({target}) {
|
||||
if (target === this.dependantTarget && target.checked) {
|
||||
this.dependeeTarget.checked = true
|
||||
}
|
||||
|
||||
if (target === this.dependeeTarget && !target.checked) {
|
||||
this.dependantTarget.checked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/javascript/controllers/dialog_controller.js
Normal file
9
app/javascript/controllers/dialog_controller.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "menu" ]
|
||||
|
||||
open() {
|
||||
this.menuTarget.showModal()
|
||||
}
|
||||
}
|
||||
32
app/javascript/controllers/edit_mode_controller.js
Normal file
32
app/javascript/controllers/edit_mode_controller.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { readCookie, setCookie } from "helpers/cookie_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { targetUrl: String }
|
||||
static classes = [ "editing" ]
|
||||
static outlets = [ "autosave" ]
|
||||
|
||||
connect() {
|
||||
document.body.classList.toggle(this.editingClass, this.#savedCheckedState)
|
||||
}
|
||||
|
||||
async change({ target: { checked } }) {
|
||||
if (!checked) {
|
||||
await this.#submitAutosaveControllers()
|
||||
}
|
||||
|
||||
setCookie("edit_mode", checked)
|
||||
Turbo.visit(this.targetUrlValue)
|
||||
}
|
||||
|
||||
|
||||
async #submitAutosaveControllers() {
|
||||
for (const autosave of this.autosaveOutlets) {
|
||||
await autosave.submit()
|
||||
}
|
||||
}
|
||||
|
||||
get #savedCheckedState() {
|
||||
return readCookie("edit_mode") === "true"
|
||||
}
|
||||
}
|
||||
17
app/javascript/controllers/form_controller.js
Normal file
17
app/javascript/controllers/form_controller.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "cancel" ]
|
||||
|
||||
submit() {
|
||||
this.element.requestSubmit()
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.cancelTarget?.click()
|
||||
}
|
||||
|
||||
preventAttachment(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
19
app/javascript/controllers/fullscreen_controller.js
Normal file
19
app/javascript/controllers/fullscreen_controller.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["button"]
|
||||
|
||||
connect() {
|
||||
if (!this.element.requestFullscreen) {
|
||||
this.buttonTarget.remove()
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
} else if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/javascript/controllers/hotkey_controller.js
Normal file
17
app/javascript/controllers/hotkey_controller.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
click(event) {
|
||||
if (this.#isClickable && !this.#shouldIgnore(event)) {
|
||||
this.element.click()
|
||||
}
|
||||
}
|
||||
|
||||
#shouldIgnore(event) {
|
||||
return event.defaultPrevented || event.target.closest("input, textarea")
|
||||
}
|
||||
|
||||
get #isClickable() {
|
||||
return getComputedStyle(this.element).pointerEvents !== "none"
|
||||
}
|
||||
}
|
||||
11
app/javascript/controllers/index.js
Normal file
11
app/javascript/controllers/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// Import and register all your controllers from the importmap under controllers/*
|
||||
|
||||
import { application } from "controllers/application"
|
||||
|
||||
// Eager load all controllers defined in the import map under controllers/**/*_controller
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
|
||||
// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
|
||||
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
// lazyLoadControllersFrom("controllers", application)
|
||||
18
app/javascript/controllers/lightbox_controller.js
Normal file
18
app/javascript/controllers/lightbox_controller.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "image", "dialog", "zoomedImage" ]
|
||||
|
||||
open(event) {
|
||||
this.dialogTarget.showModal()
|
||||
this.#set(event.target.closest("a"))
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.zoomedImageTarget.src = ""
|
||||
}
|
||||
|
||||
#set(target) {
|
||||
this.zoomedImageTarget.src = target.href
|
||||
}
|
||||
}
|
||||
43
app/javascript/controllers/popover_controller.js
Normal file
43
app/javascript/controllers/popover_controller.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
const BOTTOM_THRESHOLD = 0
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "menu" ]
|
||||
static classes = [ "orientationTop" ]
|
||||
|
||||
close() {
|
||||
this.menuTarget.close()
|
||||
this.#orient()
|
||||
}
|
||||
|
||||
open() {
|
||||
this.menuTarget.show()
|
||||
this.#orient()
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.menuTarget.open ? this.close() : this.open()
|
||||
}
|
||||
|
||||
closeOnClickOutside({ target }) {
|
||||
if (!this.element.contains(target)) this.close()
|
||||
}
|
||||
|
||||
#orient() {
|
||||
this.element.classList.toggle(this.orientationTopClass, this.#distanceToBottom < BOTTOM_THRESHOLD)
|
||||
this.menuTarget.style.setProperty("--max-width", this.#maxWidth + "px")
|
||||
}
|
||||
|
||||
get #distanceToBottom() {
|
||||
return window.innerHeight - this.#boundingClientRect.bottom
|
||||
}
|
||||
|
||||
get #maxWidth() {
|
||||
return window.innerWidth - this.#boundingClientRect.left
|
||||
}
|
||||
|
||||
get #boundingClientRect() {
|
||||
return this.menuTarget.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
20
app/javascript/controllers/reading_progress_controller.js
Normal file
20
app/javascript/controllers/reading_progress_controller.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { getReadingProgress } from "helpers/reading_progress_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { bookId: Number }
|
||||
static classes = [ "lastRead" ]
|
||||
|
||||
connect() {
|
||||
this.#markLastReadLeaf()
|
||||
}
|
||||
|
||||
#markLastReadLeaf() {
|
||||
const [ leafId ] = getReadingProgress(this.bookIdValue)
|
||||
const leafElement = leafId && this.element.querySelector(`#leaf_${leafId}`)
|
||||
|
||||
if (leafElement) {
|
||||
leafElement.classList.add(this.lastReadClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/javascript/controllers/reading_tracker_controller.js
Normal file
67
app/javascript/controllers/reading_tracker_controller.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { nextFrame } from "helpers/timing_helpers"
|
||||
import { getReadingProgress, storeReadingProgress } from "helpers/reading_progress_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { bookId: Number, leafId: Number }
|
||||
|
||||
async connect() {
|
||||
this.paragraphs = Array.from(document.querySelectorAll("main p"))
|
||||
|
||||
this.visibleParagraphs = new Set()
|
||||
this.lastReadParagraphIndex = 0
|
||||
|
||||
await nextFrame()
|
||||
this.#scrollToLastReadParagraph()
|
||||
|
||||
await nextFrame()
|
||||
this.#observeReadingProgress()
|
||||
this.#storeProgress()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
|
||||
#scrollToLastReadParagraph() {
|
||||
const [ leafId, lastReadParagraphIndex ] = getReadingProgress(this.bookIdValue)
|
||||
|
||||
if (leafId === this.leafIdValue && lastReadParagraphIndex > 0) {
|
||||
const lastReadParagraph = this.paragraphs[lastReadParagraphIndex]
|
||||
lastReadParagraph?.scrollIntoView({ behavior: "instant", block: "end" })
|
||||
}
|
||||
}
|
||||
|
||||
#observeReadingProgress() {
|
||||
this.observer = new IntersectionObserver(this.#intersectionChange, { threshold: 0.5 })
|
||||
this.paragraphs.forEach(p => this.observer.observe(p))
|
||||
}
|
||||
|
||||
#intersectionChange = (entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.visibleParagraphs.add(entry.target)
|
||||
} else {
|
||||
this.visibleParagraphs.delete(entry.target)
|
||||
}
|
||||
})
|
||||
|
||||
this.#updateLastReadParagraph()
|
||||
this.#storeProgress()
|
||||
}
|
||||
|
||||
#updateLastReadParagraph() {
|
||||
const sortedVisibleParagraphs = Array.from(this.visibleParagraphs).sort((p1, p2) => {
|
||||
return this.paragraphs.indexOf(p1) - this.paragraphs.indexOf(p2)
|
||||
})
|
||||
|
||||
if (sortedVisibleParagraphs.length > 0) {
|
||||
const lastVisibleParagraph = sortedVisibleParagraphs[sortedVisibleParagraphs.length - 1]
|
||||
this.lastReadParagraphIndex = this.paragraphs.indexOf(lastVisibleParagraph)
|
||||
}
|
||||
}
|
||||
|
||||
#storeProgress() {
|
||||
storeReadingProgress(this.bookIdValue, this.leafIdValue, this.lastReadParagraphIndex)
|
||||
}
|
||||
}
|
||||
23
app/javascript/controllers/toc_view_controller.js
Normal file
23
app/javascript/controllers/toc_view_controller.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { readCookie, setCookie } from "helpers/cookie_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "switch" ]
|
||||
static values = { id: String }
|
||||
|
||||
connect() {
|
||||
this.#restoreViewPref(this.idValue)
|
||||
}
|
||||
|
||||
saveViewPref(event) {
|
||||
const viewType = event.target.dataset.tocViewTypeValue
|
||||
setCookie(this.idValue, viewType)
|
||||
}
|
||||
|
||||
#restoreViewPref(id) {
|
||||
const viewType = readCookie(id) || "grid"
|
||||
this.switchTargets.forEach(switchTarget => {
|
||||
switchTarget.checked = switchTarget.dataset.tocViewTypeValue === viewType
|
||||
}
|
||||
)}
|
||||
}
|
||||
54
app/javascript/controllers/touch_controller.js
Normal file
54
app/javascript/controllers/touch_controller.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
const SWIPE_DURATION = 1000
|
||||
const SWIPE_THRESHOLD = 30
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.addEventListener("touchstart", this.#handleTouchStart.bind(this), false)
|
||||
this.element.addEventListener("touchend", this.#handleTouchEnd.bind(this), false)
|
||||
}
|
||||
|
||||
#handleTouchStart(event) {
|
||||
this.startX = event.touches[0].clientX
|
||||
this.startY = event.touches[0].clientY
|
||||
this.startTime = new Date().getTime()
|
||||
}
|
||||
|
||||
#handleTouchEnd(event) {
|
||||
this.endTime = new Date().getTime()
|
||||
|
||||
if (this.#isSelection || this.#exceedsDuration) return
|
||||
|
||||
const endX = event.changedTouches[0].clientX
|
||||
const endY = event.changedTouches[0].clientY
|
||||
const deltaX = Math.abs(endX - this.startX)
|
||||
const deltaY = Math.abs(endY - this.startY)
|
||||
|
||||
if (deltaX > SWIPE_THRESHOLD && deltaY < SWIPE_THRESHOLD) {
|
||||
if (this.startX > endX) {
|
||||
this.#swipedRight()
|
||||
} else {
|
||||
this.#swipedLeft()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#swipedLeft() {
|
||||
this.dispatch("swipe-left")
|
||||
}
|
||||
|
||||
#swipedRight() {
|
||||
this.dispatch("swipe-right")
|
||||
}
|
||||
|
||||
get #exceedsDuration() {
|
||||
const duration = this.endTime - this.startTime
|
||||
return duration > SWIPE_DURATION
|
||||
}
|
||||
|
||||
get #isSelection() {
|
||||
const selection = window.getSelection()
|
||||
return selection.toString().length > 0 && !selection.isCollapsed
|
||||
}
|
||||
}
|
||||
20
app/javascript/controllers/upload_preview_controller.js
Normal file
20
app/javascript/controllers/upload_preview_controller.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { defaultImage: String }
|
||||
static targets = [ "image", "input", "button" ]
|
||||
|
||||
previewImage() {
|
||||
const file = this.inputTarget.files[0]
|
||||
|
||||
if (file) {
|
||||
this.imageTarget.src = URL.createObjectURL(this.inputTarget.files[0]);
|
||||
this.imageTarget.onload = () => { URL.revokeObjectURL(this.imageTarget.src) }
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.imageTarget.src = this.defaultImageValue
|
||||
this.buttonTarget.style.visibility = "hidden"
|
||||
}
|
||||
}
|
||||
36
app/javascript/controllers/web_share_controller.js
Normal file
36
app/javascript/controllers/web_share_controller.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { title: String, text: String, url: String, files: String }
|
||||
|
||||
connect() {
|
||||
this.element.hidden = !navigator.canShare
|
||||
}
|
||||
|
||||
async share() {
|
||||
await navigator.share(await this.#getShareData())
|
||||
}
|
||||
|
||||
async #getShareData() {
|
||||
const data = { title: this.titleValue, text: this.textValue }
|
||||
|
||||
if (this.urlValue) {
|
||||
data.url = this.urlValue
|
||||
}
|
||||
|
||||
if (this.filesValue) {
|
||||
data.files = [ await this.#getFileObject()]
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async #getFileObject() {
|
||||
const response = await fetch(this.filesValue)
|
||||
const blob = await response.blob()
|
||||
const randomPrefix = `Writebook_${Math.random().toString(36).slice(2)}`
|
||||
const fileName = `${randomPrefix}.${blob.type.split('/').pop()}`
|
||||
|
||||
return new File([ blob ], fileName, { type: blob.type })
|
||||
}
|
||||
}
|
||||
22
app/javascript/helpers/cookie_helpers.js
Normal file
22
app/javascript/helpers/cookie_helpers.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export function setCookie(name, value = "", days = 365) {
|
||||
let expires = ""
|
||||
|
||||
const date = new Date()
|
||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
expires = `; expires=${date.toUTCString()}`
|
||||
|
||||
document.cookie = `${name}=${value}${expires}; path=/`
|
||||
}
|
||||
|
||||
export function readCookie(name) {
|
||||
const nameEQ = `${name}=`
|
||||
const ca = document.cookie.split(";")
|
||||
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i]
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length)
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
9
app/javascript/helpers/form_helpers.js
Normal file
9
app/javascript/helpers/form_helpers.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { FetchRequest } from "@rails/request.js"
|
||||
|
||||
export async function submitForm(form) {
|
||||
const request = new FetchRequest(form.method, form.action, {
|
||||
body: new FormData(form)
|
||||
})
|
||||
|
||||
return await request.perform()
|
||||
}
|
||||
16
app/javascript/helpers/reading_progress_helpers.js
Normal file
16
app/javascript/helpers/reading_progress_helpers.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { setCookie, readCookie } from "helpers/cookie_helpers"
|
||||
|
||||
export function getReadingProgress(bookId) {
|
||||
const progress = readCookie(`reading_progress_${bookId}`)
|
||||
|
||||
if (progress) {
|
||||
const [ leafId, lastReadParagraph ] = progress.split("/")
|
||||
return [ parseInt(leafId), parseInt(lastReadParagraph) || 0 ]
|
||||
} else {
|
||||
return [ undefined, 0 ]
|
||||
}
|
||||
}
|
||||
|
||||
export function storeReadingProgress(bookId, leafId, lastReadParagraphIndex) {
|
||||
setCookie(`reading_progress_${bookId}`, `${leafId}/${lastReadParagraphIndex}`)
|
||||
}
|
||||
39
app/javascript/helpers/timing_helpers.js
Normal file
39
app/javascript/helpers/timing_helpers.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export function throttle(fn, delay = 1000) {
|
||||
let timeoutId = null
|
||||
|
||||
return (...args) => {
|
||||
if (!timeoutId) {
|
||||
fn(...args)
|
||||
timeoutId = setTimeout(() => timeoutId = null, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function debounce(fn, delay = 1000) {
|
||||
let timeoutId = null
|
||||
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => fn.apply(this, args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
export function nextEventLoopTick() {
|
||||
return delay(0)
|
||||
}
|
||||
|
||||
export function onNextEventLoopTick(callback) {
|
||||
setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
export function nextFrame() {
|
||||
return new Promise(requestAnimationFrame)
|
||||
}
|
||||
|
||||
export function nextEventNamed(eventName, element = window) {
|
||||
return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true }))
|
||||
}
|
||||
|
||||
export function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
Reference in New Issue
Block a user