This commit is contained in:
2025-11-07 13:34:32 -08:00
commit 1e8c5a972b
436 changed files with 11000 additions and 0 deletions

View File

@@ -0,0 +1 @@
import "actions/scroll_into_view"

View 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)
}
}

View 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"

View 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 }

View 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)
}
}

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.requestSubmit()
}
}

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
remove() {
this.element.remove()
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}
}

View File

@@ -0,0 +1,9 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "menu" ]
open() {
this.menuTarget.showModal()
}
}

View 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"
}
}

View 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()
}
}

View 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()
}
}
}

View 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"
}
}

View 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)

View 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
}
}

View 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()
}
}

View 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)
}
}
}

View 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)
}
}

View 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
}
)}
}

View 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
}
}

View 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"
}
}

View 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 })
}
}

View 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
}

View 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()
}

View 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}`)
}

View 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))
}