Add todo widget

This commit is contained in:
Svilen Markov
2025-05-08 16:59:02 +01:00
parent 4ed8bef562
commit dd91a506fa
16 changed files with 800 additions and 2 deletions

View File

@@ -28,6 +28,23 @@ pre {
font: inherit;
}
input[type="text"] {
width: 100%;
border: 0;
background: none;
font: inherit;
color: inherit;
}
button {
font: inherit;
border: 0;
cursor: pointer;
background: none;
color: inherit;
}
::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight);

View File

@@ -496,6 +496,57 @@ details[open] .summary::after {
color: var(--color-primary);
}
.drag-and-drop-container {
position: relative;
}
.drag-and-drop-decoy {
outline: 1px dashed var(--color-primary);
opacity: 0.25;
border-radius: var(--border-radius);
}
.drag-and-drop-draggable {
position: absolute;
cursor: grabbing !important;
}
.drag-and-drop-draggable:empty {
display: none;
}
.drag-and-drop-draggable * {
cursor: grabbing !important;
}
.auto-scaling-textarea-container {
position: relative;
}
.auto-scaling-textarea {
position: absolute;
inset: 0;
background: none;
border: none;
font: inherit;
resize: none;
color: inherit;
overflow: hidden;
}
.auto-scaling-textarea:focus {
outline: none;
}
.auto-scaling-textarea-mimic {
white-space: pre-wrap;
min-height: 1lh;
user-select: none;
word-wrap: break-word;
font: inherit;
visibility: hidden;
}
.cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; }

View File

@@ -0,0 +1,129 @@
.todo-widget {
padding-top: 4rem;
}
.todo-plus-icon {
--icon-color: var(--color-text-subdue);
position: relative;
width: 1.4rem;
height: 1.4rem;
}
.todo-plus-icon::before, .todo-plus-icon::after {
content: "";
position: absolute;
background-color: var(--icon-color);
transition: background-color .2s;
}
.todo-plus-icon::before {
width: 2px;
inset-block: 0.2rem;
left: 50%;
transform: translateX(-50%);
}
.todo-plus-icon::after {
height: 2px;
inset-inline: 0.2rem;
top: 50%;
transform: translateY(-50%);
}
.todo-input textarea::placeholder {
color: var(--color-text-base-muted);
}
.todo-input {
position: relative;
color: var(--color-text-highlight);
}
.todo-input:focus-within .todo-plus-icon {
--icon-color: var(--color-text-base);
}
.todo-item {
transform-origin: center;
padding: 0.5rem 0;
}
.todo-item-checkbox {
-webkit-appearance: none;
appearance: none;
border: 2px solid var(--color-text-subdue);
width: 1.4rem;
height: 1.4rem;
position: relative;
cursor: pointer;
border-radius: 0.3rem;
transition: border-color .2s;
}
.todo-item-checkbox::before {
content: "";
inset: -1rem;
position: absolute;
}
.todo-item-checkbox::after {
content: '';
position: absolute;
inset: 0.3rem;
border-radius: 0.1rem;
opacity: 0;
transition: opacity .2s;
}
.todo-item-checkbox:checked::after {
background: var(--color-primary);
opacity: 1;
}
.todo-item-checkbox:focus-visible {
outline: none;
border-color: var(--color-primary);
}
.todo-item-text {
color: var(--color-text-base);
transition: color .35s;
}
.todo-item-text:focus {
color: var(--color-text-highlight);
}
.todo-item-drag-handle {
position: absolute;
top: -0.5rem;
inset-inline: 0;
height: 1rem;
cursor: grab;
}
.todo-item.is-being-dragged .todo-item-drag-handle {
height: 3rem;
top: -1.5rem;
}
.todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
text-decoration: line-through;
color: var(--color-text-subdue);
}
.todo-item-delete {
width: 1.5rem;
height: 1.5rem;
opacity: 0;
transition: opacity .2s;
outline-offset: .5rem;
}
.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
opacity: 1;
}
.todo-item.is-being-dragged .todo-item-delete {
opacity: 0;
}

View File

@@ -14,6 +14,7 @@
@import "widget-twitch.css";
@import "widget-videos.css";
@import "widget-weather.css";
@import "widget-todo.css";
@import "forum-posts.css";

View File

@@ -31,3 +31,28 @@ export function slideFade({
},
};
}
export function animateReposition(
element,
onAnimEnd,
animOptions = { duration: 400, easing: easeOutQuint }
) {
const rectBefore = element.getBoundingClientRect();
return () => {
const rectAfter = element.getBoundingClientRect();
const offsetY = rectBefore.y - rectAfter.y;
const offsetX = rectBefore.x - rectAfter.x;
element.animate({
keyframes: [
{ transform: `translate(${offsetX}px, ${offsetY}px)` },
{ transform: 'none' }
],
options: animOptions
}, onAnimEnd);
return rectAfter;
}
}

View File

@@ -30,7 +30,7 @@ const [datesEntranceLeft, datesEntranceRight] = directions(
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
export default function(element) {
element.swap(Calendar(
element.swapWith(Calendar(
Number(element.dataset.firstDayOfWeek ?? 1)
));
}

View File

@@ -642,6 +642,16 @@ async function setupCalendars() {
calendar.default(elems[i]);
}
async function setupTodos() {
const elems = document.getElementsByClassName("todo");
if (elems.length == 0) return;
const todo = await import ('./todo.js');
for (let i = 0; i < elems.length; i++)
todo.default(elems[i]);
}
function setupTruncatedElementTitles() {
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
@@ -736,6 +746,7 @@ async function setupPage() {
setupPopovers();
setupClocks()
await setupCalendars();
await setupTodos();
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();

View File

@@ -29,6 +29,15 @@ export function findAll(selector) {
return document.querySelectorAll(selector);
}
HTMLCollection.prototype.map = function(fn) {
return Array.from(this).map(fn);
}
HTMLCollection.prototype.indexOf = function(element) {
return Array.prototype.indexOf.call(this, element);
}
const ep = HTMLElement.prototype;
const fp = DocumentFragment.prototype;
const tp = Text.prototype;
@@ -110,7 +119,7 @@ ep.appendTo = function(parent) {
return this;
}
ep.swap = function(element) {
ep.swapWith = function(element) {
this.replaceWith(element);
return element;
}

View File

@@ -0,0 +1,442 @@
import { elem, fragment } from "./templating.js";
import { animateReposition } from "./animations.js";
import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js";
const trashIconSvg = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
</svg>`;
export default function(element) {
element.swapWith(
Todo(element.dataset.todoId)
)
}
function itemAnim(height, entrance = true) {
const visible = { height: height + "px", opacity: 1 };
const hidden = { height: "0", opacity: 0, padding: "0" };
return {
keyframes: [
entrance ? hidden : visible,
entrance ? visible : hidden
],
options: { duration: 200, easing: "ease" }
}
}
function inputMarginAnim(entrance = true) {
const amount = "1.5rem";
return {
keyframes: [
{ marginBottom: entrance ? "0px" : amount },
{ marginBottom: entrance ? amount : "0" }
],
options: { duration: 200, easing: "ease", fill: "forwards" }
}
}
function loadFromLocalStorage(id) {
return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]");
}
function saveToLocalStorage(id, data) {
localStorage.setItem(`todo-${id}`, JSON.stringify(data));
}
function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) {
let item, input, inputArea;
const serializeable = {
text: unserialize.text || "",
checked: unserialize.checked || false
};
item = elem().classes("todo-item", "flex", "gap-10", "items-center").append(
elem("input")
.classes("todo-item-checkbox", "shrink-0")
.styles({ marginTop: "-0.1rem" })
.attrs({ type: "checkbox" })
.on("change", (e) => {
serializeable.checked = e.target.checked;
onUpdate();
})
.tap(self => self.checked = serializeable.checked),
input = autoScalingTextarea(textarea => inputArea = textarea
.classes("todo-item-text")
.attrs({
placeholder: "empty task",
spellcheck: "false"
})
.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
} else if (e.key === "Escape") {
e.preventDefault();
onEscape();
}
})
.on("input", () => {
serializeable.text = inputArea.value;
onUpdate();
})
).classes("min-width-0", "grow").append(
elem()
.classes("todo-item-drag-handle")
.on("mousedown", (e) => onDragStart(e, item))
),
elem("button")
.classes("todo-item-delete", "shrink-0")
.html(trashIconSvg)
.on("click", () => onDelete(item))
);
input.component.setValue(serializeable.text);
return item.component({
focusInput: () => inputArea.focus(),
serialize: () => serializeable
});
}
function Todo(id) {
let items, input, inputArea, inputContainer, lastAddedItem;
let queuedForRemoval = 0;
let reorderable;
let isDragging = false;
const onDragEnd = () => isDragging = false;
const onDragStart = (event, element) => {
isDragging = true;
reorderable.component.onDragStart(event, element);
};
const saveItems = () => {
if (isDragging) return;
saveToLocalStorage(
id, items.children.map(item => item.component.serialize())
);
};
const onItemRepositioned = () => saveItems();
const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000);
const onItemDelete = (item) => {
if (lastAddedItem === item) lastAddedItem = null;
const height = item.clientHeight;
queuedForRemoval++;
item.animate(itemAnim(height, false), () => {
item.remove();
queuedForRemoval--;
saveItems();
});
if (items.children.length - queuedForRemoval === 0)
inputContainer.animate(inputMarginAnim(false));
};
const newItem = (data) => Item(
data,
debouncedOnItemUpdate,
onItemDelete,
() => inputArea.focus(),
onDragStart
);
const addNewItem = (itemText, prepend) => {
const totalItemsBeforeAppending = items.children.length;
const item = lastAddedItem = newItem({ text: itemText });
prepend ? items.prepend(item) : items.append(item);
saveItems();
const height = item.clientHeight;
item.animate(itemAnim(height));
if (totalItemsBeforeAppending === 0)
inputContainer.animate(inputMarginAnim());
};
const handleInputKeyDown = (e) => {
switch (e.key) {
case "Enter":
e.preventDefault();
const value = e.target.value.trim();
if (value === "") return;
addNewItem(value, e.ctrlKey);
input.component.setValue("");
break;
case "Escape":
e.target.blur();
break;
case "ArrowDown":
if (!lastAddedItem) return;
e.preventDefault();
lastAddedItem.component.focusInput();
break;
}
};
items = elem()
.classes("todo-items")
.append(
...loadFromLocalStorage(id).map(data => newItem(data))
);
return fragment().append(
inputContainer = elem()
.classes("todo-input", "flex", "gap-10", "items-center")
.classesIf(items.children.length > 0, "margin-bottom-15")
.styles({ paddingRight: "2.5rem" })
.append(
elem().classes("todo-plus-icon", "shrink-0"),
input = autoScalingTextarea(textarea => inputArea = textarea
.on("keydown", handleInputKeyDown)
.attrs({
placeholder: "Add a task",
spellcheck: "false"
})
).classes("grow", "min-width-0")
),
reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd),
);
}
// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
export function autoScalingTextarea(yieldTextarea = null) {
let textarea, mimic;
const updateMimic = (newValue) => mimic.text(newValue + ' ');
const container = elem().classes("auto-scaling-textarea-container").append(
textarea = elem("textarea")
.classes("auto-scaling-textarea")
.on("input", () => updateMimic(textarea.value)),
mimic = elem().classes("auto-scaling-textarea-mimic")
)
if (typeof yieldTextarea === "function") yieldTextarea(textarea);
return container.component({ setValue: (newValue) => {
textarea.value = newValue;
updateMimic(newValue);
}});
}
export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) {
const classToAddToDraggedItem = "is-being-dragged";
const currentlyBeingDragged = {
element: null,
initialIndex: null,
clientOffset: Vec2.new(),
};
const decoy = {
element: null,
currentIndex: null,
};
const draggableContainer = {
element: null,
initialRect: null,
};
const lastClientPos = Vec2.new();
let initialScrollY = null;
let addDocumentEvents, removeDocumentEvents;
const handleReposition = (event) => {
if (currentlyBeingDragged.element == null) return;
if (event.clientY !== undefined && event.clientX !== undefined)
lastClientPos.setFromEvent(event);
const client = lastClientPos;
const container = draggableContainer;
const item = currentlyBeingDragged;
const scrollOffset = window.scrollY - initialScrollY;
const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset;
const offsetX = client.x - container.initialRect.x - item.clientOffset.x;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const viewportWidth = window.innerWidth - scrollbarWidth;
const confinedX = clamp(
offsetX,
-container.initialRect.x,
viewportWidth - container.initialRect.x - container.initialRect.width
);
container.element.styles({
transform: `translate(${confinedX}px, ${offsetY}px)`,
});
const containerTop = client.y - item.clientOffset.y;
const containerBottom = client.y + container.initialRect.height - item.clientOffset.y;
let swapWithLast = true;
let swapWithIndex = null;
for (let i = 0; i < itemsContainer.children.length; i++) {
const childRect = itemsContainer.children[i].getBoundingClientRect();
const topThreshold = childRect.top + childRect.height * .6;
const bottomThreshold = childRect.top + childRect.height * .4;
if (containerBottom > topThreshold) {
if (containerTop < bottomThreshold && i != decoy.currentIndex) {
swapWithIndex = i;
swapWithLast = false;
break;
}
continue;
};
swapWithLast = false;
if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break;
swapWithIndex = (i < decoy.currentIndex) ? i : i-1;
break;
}
const lastItemIndex = itemsContainer.children.length - 1;
if (swapWithLast && decoy.currentIndex != lastItemIndex)
swapWithIndex = lastItemIndex;
if (swapWithIndex === null)
return;
const diff = swapWithIndex - decoy.currentIndex;
if (Math.abs(diff) > 1) {
swapWithIndex = decoy.currentIndex + Math.sign(diff);
}
const siblingToSwapWith = itemsContainer.children[swapWithIndex];
if (siblingToSwapWith.isCurrentlyAnimating) return;
const animateDecoy = animateReposition(decoy.element);
const animateChild = animateReposition(
siblingToSwapWith,
() => {
siblingToSwapWith.isCurrentlyAnimating = false;
handleReposition({
clientX: client.x,
clientY: client.y,
});
}
);
siblingToSwapWith.isCurrentlyAnimating = true;
if (swapWithIndex > decoy.currentIndex)
decoy.element.before(siblingToSwapWith);
else
decoy.element.after(siblingToSwapWith);
decoy.currentIndex = itemsContainer.children.indexOf(decoy.element);
animateDecoy();
animateChild();
}
const handleRelease = (event) => {
if (event.buttons != 0) return;
removeDocumentEvents();
const item = currentlyBeingDragged;
const element = item.element;
element.styles({ pointerEvents: "none" });
const animate = animateReposition(element, () => {
item.element = null;
element
.clearClasses(classToAddToDraggedItem)
.clearStyles("pointer-events");
if (typeof onDragEnd === "function") onDragEnd(element);
if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function")
onItemRepositioned(element, item.initialIndex, decoy.currentIndex);
});
decoy.element.swapWith(element);
draggableContainer.element.append(decoy.element);
draggableContainer.element.clearStyles("transform", "width");
item.element = null;
decoy.element.remove();
animate();
}
const preventDefault = (event) => {
event.preventDefault();
};
const handleGrab = (event, element) => {
if (currentlyBeingDragged.element != null) return;
event.preventDefault();
const item = currentlyBeingDragged;
if (item.element != null) return;
addDocumentEvents();
initialScrollY = window.scrollY;
const client = lastClientPos.setFromEvent(event);
const elementRect = element.getBoundingClientRect();
item.element = element;
item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element);
item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y);
// We use getComputedStyle here to get width and height because .clientWidth and .clientHeight
// return integers and not the real float values, which can cause the decoy to be off by a pixel
const elementStyle = getComputedStyle(element);
const initialWidth = elementStyle.width;
decoy.element = elem().classes("drag-and-drop-decoy").styles({
height: elementStyle.height,
width: initialWidth,
});
const container = draggableContainer;
element.swapWith(decoy.element);
container.element.append(element);
element.classes(classToAddToDraggedItem);
decoy.element.animate({
keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }],
options: { duration: 300, easing: "ease" }
})
container.element.styles({ width: initialWidth, transform: "none" });
container.initialRect = container.element.getBoundingClientRect();
const offsetY = elementRect.y - container.initialRect.y;
const offsetX = elementRect.x - container.initialRect.x;
container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` });
}
[addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, {
"mousemove": handleReposition,
"scroll": handleReposition,
"mousedown": preventDefault,
"contextmenu": preventDefault,
"mouseup": handleRelease,
});
return elem().classes("drag-and-drop-container").append(
itemsContainer,
draggableContainer.element = elem().classes("drag-and-drop-draggable")
).component({
onDragStart: handleGrab
});
}

View File

@@ -36,3 +36,46 @@ export function openURLInNewTab(url, focus = true) {
if (focus && newWindow != null) newWindow.focus();
}
export class Vec2 {
constructor(x, y) {
this.x = x;
this.y = y;
}
static new(x = 0, y = 0) {
return new Vec2(x, y);
}
static fromEvent(event) {
return new Vec2(event.clientX, event.clientY);
}
setFromEvent(event) {
this.x = event.clientX;
this.y = event.clientY;
return this;
}
set(x, y) {
this.x = x;
this.y = y;
return this;
}
}
export function toggleableEvents(element, eventToHandlerMap) {
return [
() => {
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
element.addEventListener(event, handler);
}
},
() => {
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
element.removeEventListener(event, handler);
}
}
];
}

View File

@@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="todo" data-todo-id="{{ .TodoID }}"></div>
{{ end }}

View File

@@ -0,0 +1,24 @@
package glance
import (
"html/template"
)
var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html")
type todoWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
TodoID string `yaml:"id"`
}
func (widget *todoWidget) initialize() error {
widget.withTitle("Todo").withError(nil)
widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate)
return nil
}
func (widget *todoWidget) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -79,6 +79,8 @@ func newWidget(widgetType string) (widget, error) {
w = &dockerContainersWidget{}
case "server-stats":
w = &serverStatsWidget{}
case "todo":
w = &todoWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}