diff --git a/index.html b/index.html index f502c96..a58a3af 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ href="/img/favicon-16x16.png" /> - + diff --git a/js/components.js b/js/components.js new file mode 100644 index 0000000..11421a6 --- /dev/null +++ b/js/components.js @@ -0,0 +1,380 @@ + +// editable-area component + +class EditableArea extends HTMLElement { + static observedAttributes = ["readonly"]; + + constructor() { + super(); + } + + connectedCallback() { + const text = this.textContent; + this.textContent = ""; + this.displayElement = document.createElement("p", { class: "display" }); + this.inputElement = document.createElement("textarea"); + this.inputElement.value = text; + + this.appendChild(this.displayElement); + this.appendChild(this.inputElement); + + this.inputElement.addEventListener("input", () => this.#sync()); + + this.#updateReadonly(); + this.#sync(); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "readonly") { + this.#updateReadonly(); + } + } + + get value() { + return this.inputElement.value; + } + + set value(text) { + this.inputElement.value = text; + this.#sync(); + } + + focusStart() { + this.inputElement.focus(); + this.inputElement.selectionEnd = 0; + } + + focusEnd() { + this.inputElement.focus(); + this.inputElement.selectionStart = this.inputElement.value.length; + } + + cutTextAfterCursor() { + const cursorPos = this.inputElement.selectionStart; + const text = this.inputElement.value; + this.inputElement.value = text.slice(0, cursorPos); + + this.#sync(); + return text.slice(cursorPos); + } + + append(text) { + const cursorStartPos = this.inputElement.selectionStart; + const cursorEndPos = this.inputElement.selectionEnd; + this.inputElement.value += text; + this.inputElement.selectionStart = cursorStartPos; + this.inputElement.selectionEnd = cursorEndPos; + this.#sync(); + } + + cursorAtStart() { + return this.inputElement.selectionEnd === 0; + } + + cursorAtEnd() { + return this.inputElement.selectionStart === this.inputElement.value.length; + } + + #updateReadonly() { + if (this.inputElement) { + const readonly = ["", "true"].includes(this.attributes.readonly?.value); + this.inputElement.disabled = readonly; + } + } + + #sync() { + this.displayElement.innerHTML = render(this.inputElement.value); + this.inputElement.style.height = this.displayElement.scrollHeight + "px"; + this.inputElement.style.width = this.displayElement.scrollWidth + "px"; + } +} + +function render(text) { + return text.replace(/(?:\r\n|\r|\n)/g, "
") + "
"; +} + +customElements.define("editable-area", EditableArea); + +// task-list component + +class TaskListItem extends HTMLElement { + static observedAttributes = ["checked"]; + + constructor() { + super(); + } + + connectedCallback() { + const text = this.textContent; + this.textContent = ""; + + const template = document.querySelector("#task-list-item").content; + + Array.from(template.children).forEach((child) => { + this.appendChild(child.cloneNode(true)); + }); + + this.taskList = this.closest("task-list"); + this.handleElement = this.querySelector(".handle"); + this.checkboxElement = this.querySelector(".checkbox input"); + this.contentElement = this.querySelector("editable-area"); + this.contentElement.value = text; + this.removeButton = this.querySelector(".remove button"); + + this.removeButton.addEventListener("click", (e) => { + this.dispatchEvent(new Event("removeTaskWithButton", { bubbles: true })); + e.preventDefault(); + }); + + this.contentElement.addEventListener("keydown", (e) => { + if (e.key === "Backspace" && this.contentElement.cursorAtStart()) { + this.dispatchEvent( + new CustomEvent("removeTask", { + bubbles: true, + detail: this.contentElement.cutTextAfterCursor(), + }), + ); + e.preventDefault(); + } else if (e.key === "Enter") { + const textAfterCursor = this.contentElement.cutTextAfterCursor(); + this.dispatchEvent( + new CustomEvent("addTask", { + bubbles: true, + detail: textAfterCursor, + }), + ); + e.preventDefault(); + } else if (e.key === "ArrowUp" && this.contentElement.cursorAtStart()) { + this.dispatchEvent(new Event("moveToPrevTask", { bubbles: true })); + e.preventDefault(); + } else if (e.key === "ArrowDown" && this.contentElement.cursorAtEnd()) { + this.dispatchEvent(new Event("moveToNextTask", { bubbles: true })); + e.preventDefault(); + } + }); + + // drag and drop events + + this.parentNode.addEventListener("dragstart", (e) => { + this.parentNode.classList.add("dragging"); + }); + + this.parentNode.addEventListener("dragend", () => { + this.parentNode.classList.remove("dragging"); + }); + + this.parentNode.addEventListener("touchstart", (e) => { + if (e.target.closest("div").classList.contains("handle")) { + this.parentNode.classList.add("dragging"); + this.taskList.dragActiveElement = this.parentNode; + if (!this.taskList.dragPlaceholder) { + this.taskList.dragPlaceholder = document.createElement("div"); + this.taskList.dragPlaceholder.classList.add("drag-placeholder"); + this.taskList.dragPlaceholder.textContent = ">"; + } + + e.preventDefault(); + } + }); + + const taskListUL = this.taskList.querySelector("ul"); + + this.parentNode.addEventListener("touchmove", (e) => { + if (this.taskList.dragActiveElement) { + const touch = e.touches[0]; + this.parentNode.style.position = "absolute"; + this.parentNode.style.left = `${touch.clientX}px`; + this.parentNode.style.top = `${touch.clientY}px`; + this.parentNode.style.width = "240px"; + + if (this.taskList.dragPlaceholder) { + const afterElement = getDragAfterElement(taskListUL, touch.clientY); + if (afterElement) { + taskListUL.insertBefore( + this.taskList.dragPlaceholder, + afterElement, + ); + } else { + taskListUL.appendChild(this.taskList.dragPlaceholder); + } + } + e.preventDefault(); + } + }); + + this.parentNode.addEventListener("touchend", () => { + if (this.taskList.dragActiveElement) { + this.parentNode.classList.remove("dragging"); + if ( + this.taskList.dragActiveElement && + this.taskList.dragPlaceholder && + this.taskList.dragPlaceholder.parentNode + ) { + this.taskList.dragPlaceholder.parentNode.insertBefore( + this.taskList.dragActiveElement, + this.taskList.dragPlaceholder, + ); + this.taskList.dragPlaceholder.remove(); + this.taskList.dragActiveElement.style.position = "static"; + this.taskList.dragActiveElement.style.left = ""; + this.taskList.dragActiveElement.style.top = ""; + this.taskList.dragActiveElement.style.width = ""; + } + this.taskList.dragActiveElement = null; + this.taskList.dragPlaceholder = null; + } + }); + + this.#updateChecked(); + } + + disconnectedCallback() { + this.textContent = this.contentElement.value; + this.setAttribute("checked", this.checkboxElement.checked); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "checked") { + this.#updateChecked(); + } + } + + append(text) { + this.contentElement.append(text); + } + + get value() { + return { + content: this.contentElement.value, + checked: !!this.checkboxElement.checked, + }; + } + + focusStart() { + this.contentElement.focusStart(); + } + + focusEnd() { + this.contentElement.focusEnd(); + } + + #updateChecked() { + if (this.checkboxElement) { + const checked = ["", "true"].includes(this.attributes.checked?.value); + this.checkboxElement.checked = checked; + } + } +} + +customElements.define("task-list-item", TaskListItem); + +class TaskList extends HTMLElement { + dragPlaceholder = null; + dragActiveElement = null; + + constructor() { + super(); + } + + connectedCallback() { + this.listElement = this.querySelector("ul"); + + this.addEventListener("removeTaskWithButton", (e) => { + const tasksCount = this.querySelectorAll("task-list-item").length; + + if (tasksCount === 1) { + const newLI = document.createElement("li"); + newLI.setAttribute("draggable", "true"); + const newItem = document.createElement("task-list-item"); + newLI.appendChild(newItem); + this.querySelector("ul").appendChild(newLI); + newItem.focusStart(); + } + + const currentLI = e.target.parentNode; + currentLI.remove(); + }); + + this.addEventListener("removeTask", (e) => { + const textToAppend = e.detail || ""; + const currentLI = e.target.parentNode; + const previousItem = + currentLI.previousElementSibling?.querySelector("task-list-item"); + + if (previousItem) { + previousItem.focusEnd(); + previousItem.append(textToAppend); + currentLI.remove(); + } + }); + + this.addEventListener("addTask", (e) => { + const text = e.detail || ""; + const currentLI = e.target.parentNode; + + const newLI = document.createElement("li"); + newLI.setAttribute("draggable", "true"); + const newItem = document.createElement("task-list-item"); + newItem.textContent = text; + newLI.appendChild(newItem); + currentLI.after(newLI); + newItem.focusStart(); + }); + + this.addEventListener("moveToNextTask", (e) => { + const currentLI = e.target.parentNode; + const nextItem = + currentLI.nextElementSibling?.querySelector("task-list-item"); + + if (nextItem) { + nextItem.focusStart(); + } + }); + + this.addEventListener("moveToPrevTask", (e) => { + const currentLI = e.target.parentNode; + const prevItem = + currentLI.previousElementSibling?.querySelector("task-list-item"); + + if (prevItem) { + prevItem.focusEnd(); + } + }); + + // drag and drop + this.listElement.addEventListener("dragover", (e) => { + e.preventDefault(); + + const afterElement = getDragAfterElement(this.listElement, e.clientY); + const draggable = document.querySelector(".dragging"); + + if (afterElement == null) { + this.listElement.appendChild(draggable); + } else { + this.listElement.insertBefore(draggable, afterElement); + } + }); + } +} + +function getDragAfterElement(container, y) { + const draggableElements = [ + ...container.querySelectorAll("li:not(.dragging)"), + ]; + + return draggableElements.reduce( + (closest, containerChild) => { + const box = containerChild.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: containerChild }; + } else { + return closest; + } + }, + { offset: Number.NEGATIVE_INFINITY }, + ).element; +} + +customElements.define("task-list", TaskList); diff --git a/js/jenot.js b/js/jenot.js index 1d3ba79..7109664 100644 --- a/js/jenot.js +++ b/js/jenot.js @@ -1,379 +1 @@ -// editable-area component - -class EditableArea extends HTMLElement { - static observedAttributes = ["readonly"]; - - constructor() { - super(); - } - - connectedCallback() { - const text = this.textContent; - this.textContent = ""; - this.displayElement = document.createElement("p", { class: "display" }); - this.inputElement = document.createElement("textarea"); - this.inputElement.value = text; - - this.appendChild(this.displayElement); - this.appendChild(this.inputElement); - - this.inputElement.addEventListener("input", () => this.#sync()); - - this.#updateReadonly(); - this.#sync(); - } - - attributeChangedCallback(name, oldValue, newValue) { - if (name === "readonly") { - this.#updateReadonly(); - } - } - - get value() { - return this.inputElement.value; - } - - set value(text) { - this.inputElement.value = text; - this.#sync(); - } - - focusStart() { - this.inputElement.focus(); - this.inputElement.selectionEnd = 0; - } - - focusEnd() { - this.inputElement.focus(); - this.inputElement.selectionStart = this.inputElement.value.length; - } - - cutTextAfterCursor() { - const cursorPos = this.inputElement.selectionStart; - const text = this.inputElement.value; - this.inputElement.value = text.slice(0, cursorPos); - - this.#sync(); - return text.slice(cursorPos); - } - - append(text) { - const cursorStartPos = this.inputElement.selectionStart; - const cursorEndPos = this.inputElement.selectionEnd; - this.inputElement.value += text; - this.inputElement.selectionStart = cursorStartPos; - this.inputElement.selectionEnd = cursorEndPos; - this.#sync(); - } - - cursorAtStart() { - return this.inputElement.selectionEnd === 0; - } - - cursorAtEnd() { - return this.inputElement.selectionStart === this.inputElement.value.length; - } - - #updateReadonly() { - if (this.inputElement) { - const readonly = ["", "true"].includes(this.attributes.readonly?.value); - this.inputElement.disabled = readonly; - } - } - - #sync() { - this.displayElement.innerHTML = render(this.inputElement.value); - this.inputElement.style.height = this.displayElement.scrollHeight + "px"; - this.inputElement.style.width = this.displayElement.scrollWidth + "px"; - } -} - -function render(text) { - return text.replace(/(?:\r\n|\r|\n)/g, "
") + "
"; -} - -customElements.define("editable-area", EditableArea); - -// task-list component - -class TaskListItem extends HTMLElement { - static observedAttributes = ["checked"]; - - constructor() { - super(); - } - - connectedCallback() { - const text = this.textContent; - this.textContent = ""; - - const template = document.querySelector("#task-list-item").content; - - Array.from(template.children).forEach((child) => { - this.appendChild(child.cloneNode(true)); - }); - - this.taskList = this.closest("task-list"); - this.handleElement = this.querySelector(".handle"); - this.checkboxElement = this.querySelector(".checkbox input"); - this.contentElement = this.querySelector("editable-area"); - this.contentElement.value = text; - this.removeButton = this.querySelector(".remove button"); - - this.removeButton.addEventListener("click", (e) => { - this.dispatchEvent(new Event("removeTaskWithButton", { bubbles: true })); - e.preventDefault(); - }); - - this.contentElement.addEventListener("keydown", (e) => { - if (e.key === "Backspace" && this.contentElement.cursorAtStart()) { - this.dispatchEvent( - new CustomEvent("removeTask", { - bubbles: true, - detail: this.contentElement.cutTextAfterCursor(), - }), - ); - e.preventDefault(); - } else if (e.key === "Enter") { - const textAfterCursor = this.contentElement.cutTextAfterCursor(); - this.dispatchEvent( - new CustomEvent("addTask", { - bubbles: true, - detail: textAfterCursor, - }), - ); - e.preventDefault(); - } else if (e.key === "ArrowUp" && this.contentElement.cursorAtStart()) { - this.dispatchEvent(new Event("moveToPrevTask", { bubbles: true })); - e.preventDefault(); - } else if (e.key === "ArrowDown" && this.contentElement.cursorAtEnd()) { - this.dispatchEvent(new Event("moveToNextTask", { bubbles: true })); - e.preventDefault(); - } - }); - - // drag and drop events - - this.parentNode.addEventListener("dragstart", (e) => { - this.parentNode.classList.add("dragging"); - }); - - this.parentNode.addEventListener("dragend", () => { - this.parentNode.classList.remove("dragging"); - }); - - this.parentNode.addEventListener("touchstart", (e) => { - if (e.target.closest("div").classList.contains("handle")) { - this.parentNode.classList.add("dragging"); - this.taskList.dragActiveElement = this.parentNode; - if (!this.taskList.dragPlaceholder) { - this.taskList.dragPlaceholder = document.createElement("div"); - this.taskList.dragPlaceholder.classList.add("drag-placeholder"); - this.taskList.dragPlaceholder.textContent = ">"; - } - - e.preventDefault(); - } - }); - - const taskListUL = this.taskList.querySelector("ul"); - - this.parentNode.addEventListener("touchmove", (e) => { - if (this.taskList.dragActiveElement) { - const touch = e.touches[0]; - this.parentNode.style.position = "absolute"; - this.parentNode.style.left = `${touch.clientX}px`; - this.parentNode.style.top = `${touch.clientY}px`; - this.parentNode.style.width = "240px"; - - if (this.taskList.dragPlaceholder) { - const afterElement = getDragAfterElement(taskListUL, touch.clientY); - if (afterElement) { - taskListUL.insertBefore( - this.taskList.dragPlaceholder, - afterElement, - ); - } else { - taskListUL.appendChild(this.taskList.dragPlaceholder); - } - } - e.preventDefault(); - } - }); - - this.parentNode.addEventListener("touchend", () => { - if (this.taskList.dragActiveElement) { - this.parentNode.classList.remove("dragging"); - if ( - this.taskList.dragActiveElement && - this.taskList.dragPlaceholder && - this.taskList.dragPlaceholder.parentNode - ) { - this.taskList.dragPlaceholder.parentNode.insertBefore( - this.taskList.dragActiveElement, - this.taskList.dragPlaceholder, - ); - this.taskList.dragPlaceholder.remove(); - this.taskList.dragActiveElement.style.position = "static"; - this.taskList.dragActiveElement.style.left = ""; - this.taskList.dragActiveElement.style.top = ""; - this.taskList.dragActiveElement.style.width = ""; - } - this.taskList.dragActiveElement = null; - this.taskList.dragPlaceholder = null; - } - }); - - this.#updateChecked(); - } - - disconnectedCallback() { - this.textContent = this.contentElement.value; - this.setAttribute("checked", this.checkboxElement.checked); - } - - attributeChangedCallback(name, oldValue, newValue) { - if (name === "checked") { - this.#updateChecked(); - } - } - - append(text) { - this.contentElement.append(text); - } - - get value() { - return { - content: this.contentElement.value, - checked: !!this.checkboxElement.checked, - }; - } - - focusStart() { - this.contentElement.focusStart(); - } - - focusEnd() { - this.contentElement.focusEnd(); - } - - #updateChecked() { - if (this.checkboxElement) { - const checked = ["", "true"].includes(this.attributes.checked?.value); - this.checkboxElement.checked = checked; - } - } -} - -customElements.define("task-list-item", TaskListItem); - -class TaskList extends HTMLElement { - dragPlaceholder = null; - dragActiveElement = null; - - constructor() { - super(); - } - - connectedCallback() { - this.listElement = this.querySelector("ul"); - - this.addEventListener("removeTaskWithButton", (e) => { - const tasksCount = this.querySelectorAll("task-list-item").length; - - if (tasksCount === 1) { - const newLI = document.createElement("li"); - newLI.setAttribute("draggable", "true"); - const newItem = document.createElement("task-list-item"); - newLI.appendChild(newItem); - this.querySelector("ul").appendChild(newLI); - newItem.focusStart(); - } - - const currentLI = e.target.parentNode; - currentLI.remove(); - }); - - this.addEventListener("removeTask", (e) => { - const textToAppend = e.detail || ""; - const currentLI = e.target.parentNode; - const previousItem = - currentLI.previousElementSibling?.querySelector("task-list-item"); - - if (previousItem) { - previousItem.focusEnd(); - previousItem.append(textToAppend); - currentLI.remove(); - } - }); - - this.addEventListener("addTask", (e) => { - const text = e.detail || ""; - const currentLI = e.target.parentNode; - - const newLI = document.createElement("li"); - newLI.setAttribute("draggable", "true"); - const newItem = document.createElement("task-list-item"); - newItem.textContent = text; - newLI.appendChild(newItem); - currentLI.after(newLI); - newItem.focusStart(); - }); - - this.addEventListener("moveToNextTask", (e) => { - const currentLI = e.target.parentNode; - const nextItem = - currentLI.nextElementSibling?.querySelector("task-list-item"); - - if (nextItem) { - nextItem.focusStart(); - } - }); - - this.addEventListener("moveToPrevTask", (e) => { - const currentLI = e.target.parentNode; - const prevItem = - currentLI.previousElementSibling?.querySelector("task-list-item"); - - if (prevItem) { - prevItem.focusEnd(); - } - }); - - // drag and drop - this.listElement.addEventListener("dragover", (e) => { - e.preventDefault(); - - const afterElement = getDragAfterElement(this.listElement, e.clientY); - const draggable = document.querySelector(".dragging"); - - if (afterElement == null) { - this.listElement.appendChild(draggable); - } else { - this.listElement.insertBefore(draggable, afterElement); - } - }); - } -} - -function getDragAfterElement(container, y) { - const draggableElements = [ - ...container.querySelectorAll("li:not(.dragging)"), - ]; - - return draggableElements.reduce( - (closest, containerChild) => { - const box = containerChild.getBoundingClientRect(); - const offset = y - box.top - box.height / 2; - - if (offset < 0 && offset > closest.offset) { - return { offset: offset, element: containerChild }; - } else { - return closest; - } - }, - { offset: Number.NEGATIVE_INFINITY }, - ).element; -} - -customElements.define("task-list", TaskList); +import "./components.js";