mirror of
https://github.com/morris/vanilla-todo.git
synced 2025-08-21 13:21:29 +02:00
first commit
This commit is contained in:
29
public/scripts/AppCollapsible.js
Normal file
29
public/scripts/AppCollapsible.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.AppCollapsible = function (el) {
|
||||
var state = {
|
||||
show: true,
|
||||
};
|
||||
|
||||
el.querySelector('.bar > .toggle').addEventListener('click', function () {
|
||||
update({ show: !state.show });
|
||||
});
|
||||
|
||||
el.appCollapsible = {
|
||||
update: update,
|
||||
};
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
el.querySelector('.bar > .toggle > .app-icon').classList.toggle(
|
||||
'-r180',
|
||||
state.show
|
||||
);
|
||||
|
||||
el.querySelectorAll('.body').forEach(function (el) {
|
||||
el.style.height = state.show ? el.children[0].offsetHeight + 'px' : '0';
|
||||
});
|
||||
}
|
||||
};
|
350
public/scripts/AppDraggable.js
Normal file
350
public/scripts/AppDraggable.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.AppDraggable = function (el, options) {
|
||||
var dragThreshold = options.dragThreshold || 5;
|
||||
var dropRange = options.dropRange || 50;
|
||||
var dropRangeSquared = dropRange * dropRange;
|
||||
|
||||
var originX, originY;
|
||||
var clientX, clientY;
|
||||
var dragging = false;
|
||||
var clicked = false;
|
||||
var data;
|
||||
var image;
|
||||
var imageSource;
|
||||
var imageX, imageY;
|
||||
var currentTarget;
|
||||
|
||||
if (window.navigator.pointerEnabled) {
|
||||
el.addEventListener('pointerdown', start);
|
||||
} else if (window.navigator.msPointerEnabled) {
|
||||
el.addEventListener('MSPointerDown', start);
|
||||
} else {
|
||||
el.addEventListener('mousedown', start);
|
||||
el.addEventListener('touchstart', start);
|
||||
}
|
||||
|
||||
// maybe prevent click
|
||||
el.addEventListener(
|
||||
'click',
|
||||
function (e) {
|
||||
if (dragging || clicked) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
function start(e) {
|
||||
if (el.classList.contains('_nodrag')) return;
|
||||
if (e.type === 'mousedown' && e.button !== 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
var p = getPositionHost(e);
|
||||
clientX = originX = p.clientX || p.pageX;
|
||||
clientY = originY = p.clientY || p.pageY;
|
||||
|
||||
if (window.navigator.pointerEnabled) {
|
||||
el.addEventListener('pointermove', move);
|
||||
el.addEventListener('pointerup', end);
|
||||
} else if (window.navigator.msPointerEnabled) {
|
||||
el.addEventListener('MSPointerMove', move);
|
||||
el.addEventListener('MSPointerUp', end);
|
||||
} else {
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', end);
|
||||
el.addEventListener('touchmove', move);
|
||||
el.addEventListener('touchend', end);
|
||||
}
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var p = getPositionHost(e);
|
||||
clientX = p.clientX || p.pageX;
|
||||
clientY = p.clientY || p.pageY;
|
||||
|
||||
if (dragging) return;
|
||||
|
||||
var deltaX = clientX - originX;
|
||||
var deltaY = clientY - originY;
|
||||
|
||||
if (Math.abs(deltaX) < dragThreshold && Math.abs(deltaY) < dragThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchStart();
|
||||
dispatchLoop();
|
||||
dispatchOver();
|
||||
}
|
||||
|
||||
function end(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (!dragging) {
|
||||
e.target.click();
|
||||
clicked = true;
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
dragging = false;
|
||||
clicked = false;
|
||||
|
||||
if (window.navigator.pointerEnabled) {
|
||||
el.removeEventListener('pointermove', move);
|
||||
el.removeEventListener('pointerup', end);
|
||||
} else if (window.navigator.msPointerEnabled) {
|
||||
el.removeEventListener('MSPointerMove', move);
|
||||
el.removeEventListener('MSPointerUp', end);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', end);
|
||||
el.removeEventListener('touchmove', move);
|
||||
el.removeEventListener('touchend', end);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
function dispatchStart() {
|
||||
dragging = true;
|
||||
data = {};
|
||||
|
||||
setImage(el);
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('draggableStart', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchLoop() {
|
||||
dispatchDrag();
|
||||
dispatchTarget();
|
||||
|
||||
if (dragging) {
|
||||
requestAnimationFrame(dispatchLoop);
|
||||
} else {
|
||||
dispatchEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchDrag() {
|
||||
image.dispatchEvent(
|
||||
new CustomEvent('draggableDrag', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchTarget() {
|
||||
var nextTarget = getTarget();
|
||||
|
||||
if (nextTarget === currentTarget) return;
|
||||
|
||||
if (currentTarget) {
|
||||
currentTarget.addEventListener('draggableLeave', removeDropClassOnce);
|
||||
currentTarget.dispatchEvent(
|
||||
new CustomEvent('draggableLeave', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (nextTarget) {
|
||||
nextTarget.addEventListener('draggableEnter', addDropClassOnce);
|
||||
nextTarget.dispatchEvent(
|
||||
new CustomEvent('draggableEnter', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
currentTarget = nextTarget;
|
||||
}
|
||||
|
||||
function dispatchEnd() {
|
||||
if (currentTarget) {
|
||||
currentTarget.addEventListener('draggableDrop', cleanUpOnce);
|
||||
currentTarget.dispatchEvent(
|
||||
new CustomEvent('draggableDrop', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
image.dispatchEvent(
|
||||
new CustomEvent('draggableCancel', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchOver() {
|
||||
if (!dragging) return;
|
||||
|
||||
if (currentTarget) {
|
||||
currentTarget.dispatchEvent(
|
||||
new CustomEvent('draggableOver', {
|
||||
detail: buildDetail(),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(dispatchOver, 50);
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
function buildDetail() {
|
||||
var detail = {
|
||||
el: el,
|
||||
data: data,
|
||||
image: image,
|
||||
imageSource: imageSource,
|
||||
originX: originX,
|
||||
originY: originY,
|
||||
clientX: clientX,
|
||||
clientY: clientY,
|
||||
imageX: imageX,
|
||||
imageY: imageY,
|
||||
setImage: function (source) {
|
||||
setImage(source);
|
||||
detail.image = image;
|
||||
},
|
||||
};
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
function setImage(source) {
|
||||
if (imageSource === source) return;
|
||||
imageSource = source;
|
||||
|
||||
removeImage();
|
||||
|
||||
image = imageSource.cloneNode(true);
|
||||
image.style.position = 'fixed';
|
||||
image.style.left = '0';
|
||||
image.style.top = '0';
|
||||
image.style.width = imageSource.offsetWidth + 'px';
|
||||
image.style.height = imageSource.offsetHeight + 'px';
|
||||
image.style.margin = '0';
|
||||
image.style.zIndex = 9999;
|
||||
image.classList.add('-dragging');
|
||||
|
||||
var rect = source.getBoundingClientRect();
|
||||
imageX = originX - rect.left;
|
||||
imageY = originY - rect.top;
|
||||
|
||||
image.addEventListener('draggableDrag', function (e) {
|
||||
var x = e.detail.clientX - e.detail.imageX;
|
||||
var y = e.detail.clientY - e.detail.imageY;
|
||||
image.style.transition = 'none';
|
||||
image.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
|
||||
});
|
||||
|
||||
image.addEventListener('draggableCancel', cleanUp);
|
||||
|
||||
document.body.appendChild(image);
|
||||
}
|
||||
|
||||
function addDropClassOnce(e) {
|
||||
e.target.removeEventListener(e.type, addDropClassOnce);
|
||||
e.target.classList.add('-drop');
|
||||
}
|
||||
|
||||
function removeDropClassOnce(e) {
|
||||
e.target.removeEventListener(e.type, addDropClassOnce);
|
||||
e.target.classList.remove('-drop');
|
||||
}
|
||||
|
||||
function cleanUpOnce(e) {
|
||||
e.target.removeEventListener(e.type, cleanUpOnce);
|
||||
cleanUp();
|
||||
}
|
||||
|
||||
function cleanUp() {
|
||||
if (currentTarget) {
|
||||
currentTarget.classList.remove('-drop');
|
||||
}
|
||||
|
||||
removeImage();
|
||||
|
||||
data = null;
|
||||
image = null;
|
||||
imageSource = null;
|
||||
currentTarget = null;
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
if (image && image.parentNode) {
|
||||
image.parentNode.removeChild(image);
|
||||
}
|
||||
}
|
||||
|
||||
function getTarget() {
|
||||
var candidates = [];
|
||||
|
||||
document.querySelectorAll(options.dropSelector).forEach(function (el) {
|
||||
var rect = el.getBoundingClientRect();
|
||||
var distanceSquared = pointDistanceToRectSquared(clientX, clientY, rect);
|
||||
|
||||
if (distanceSquared > dropRangeSquared) return;
|
||||
|
||||
candidates.push({
|
||||
el: el,
|
||||
distance2: distanceSquared,
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort(function (a, b) {
|
||||
if (a.distance2 === 0 && b.distance2 === 0) {
|
||||
// in this case, the client position is inside both rectangles
|
||||
// if A contains B, B is the correct target and vice versa
|
||||
// TODO sort by z-index somehow?
|
||||
return a.el.contains(b.el) ? -1 : b.el.contains(a.el) ? 1 : 0;
|
||||
}
|
||||
|
||||
// sort by distance, ascending
|
||||
return a.distance2 - b.distance2;
|
||||
});
|
||||
|
||||
return candidates.length > 0 ? candidates[0].el : null;
|
||||
}
|
||||
|
||||
function pointDistanceToRectSquared(x, y, rect) {
|
||||
var dx =
|
||||
x < rect.left ? x - rect.left : x > rect.right ? x - rect.right : 0;
|
||||
var dy =
|
||||
y < rect.top ? y - rect.top : y > rect.bottom ? y - rect.bottom : 0;
|
||||
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
function getPositionHost(e) {
|
||||
if (e.targetTouches && e.targetTouches.length > 0) {
|
||||
return e.targetTouches[0];
|
||||
}
|
||||
|
||||
if (e.changedTouches && e.changedTouches.length > 0) {
|
||||
return e.changedTouches[0];
|
||||
}
|
||||
|
||||
return e;
|
||||
}
|
||||
};
|
197
public/scripts/AppFlip.js
Normal file
197
public/scripts/AppFlip.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.AppFlip = function (el, options) {
|
||||
var enabled = options.initialDelay === 0;
|
||||
var first;
|
||||
var level = 0;
|
||||
|
||||
// enable animations only after an initial delay
|
||||
setTimeout(function () {
|
||||
enabled = true;
|
||||
}, options.initialDelay || 100);
|
||||
|
||||
// take a snapshot before any HTML changes
|
||||
// do this only for the first beforeFlip event in the current cycle
|
||||
el.addEventListener('beforeFlip', function () {
|
||||
if (!enabled) return;
|
||||
if (++level > 1) return;
|
||||
|
||||
first = snapshot();
|
||||
});
|
||||
|
||||
// take a snapshot after HTML changes, calculate and play animations
|
||||
// do this only for the last flip event in the current cycle
|
||||
el.addEventListener('flip', function () {
|
||||
if (!enabled) return;
|
||||
if (--level > 0) return;
|
||||
|
||||
var last = snapshot();
|
||||
var toRemove = invertForRemoval(first, last);
|
||||
var toAnimate = invertForAnimation(first, last);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
remove(toRemove);
|
||||
animate(toAnimate);
|
||||
|
||||
first = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// build a snapshot of the current HTML's client rectangles
|
||||
// includes original transforms and hierarchy
|
||||
function snapshot() {
|
||||
var map = new Map();
|
||||
|
||||
el.querySelectorAll(options.selector).forEach(function (el) {
|
||||
var key = el.getAttribute('data-key') || el;
|
||||
|
||||
// parse original transform
|
||||
// i.e. strip inverse transform using "scale(1)" marker
|
||||
var transform = el.style.transform
|
||||
? el.style.transform.replace(/^.*scale\(1\)/, '')
|
||||
: '';
|
||||
|
||||
map.set(key, {
|
||||
key: key,
|
||||
el: el,
|
||||
rect: el.getBoundingClientRect(),
|
||||
ancestor: null,
|
||||
transform: transform,
|
||||
});
|
||||
});
|
||||
|
||||
resolveAncestors(map);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveAncestors(map) {
|
||||
map.forEach(function (entry) {
|
||||
var current = entry.el.parentNode;
|
||||
|
||||
while (current && current !== el) {
|
||||
var ancestor = map.get(current.getAttribute('data-key') || current);
|
||||
|
||||
if (ancestor) {
|
||||
entry.ancestor = ancestor;
|
||||
return;
|
||||
}
|
||||
|
||||
current = current.parentNode;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// reinsert removed elements at their original position
|
||||
function invertForRemoval(first, last) {
|
||||
var toRemove = [];
|
||||
|
||||
first.forEach(function (entry) {
|
||||
if (entry.el.classList.contains('_noflip')) return;
|
||||
if (!needsRemoval(entry)) return;
|
||||
|
||||
entry.el.style.position = 'fixed';
|
||||
entry.el.style.left = entry.rect.left + 'px';
|
||||
entry.el.style.top = entry.rect.top + 'px';
|
||||
entry.el.style.width = entry.rect.right - entry.rect.left + 'px';
|
||||
entry.el.style.transition = 'none';
|
||||
entry.el.style.transform = '';
|
||||
|
||||
el.appendChild(entry.el);
|
||||
toRemove.push(entry);
|
||||
});
|
||||
|
||||
return toRemove;
|
||||
|
||||
function needsRemoval(entry) {
|
||||
if (entry.ancestor && needsRemoval(entry.ancestor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !last.has(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
// set position of moved elements to their original position
|
||||
// or set opacity to zero for new elements to appear nicely
|
||||
function invertForAnimation(first, last) {
|
||||
var toAnimate = [];
|
||||
|
||||
last.forEach(function (entry) {
|
||||
if (entry.el.classList.contains('_noflip')) return;
|
||||
|
||||
calculate(entry);
|
||||
|
||||
if (entry.appear) {
|
||||
entry.el.style.transition = 'none';
|
||||
entry.el.style.opacity = '0';
|
||||
toAnimate.push(entry);
|
||||
} else if (entry.deltaX !== 0 || entry.deltaY !== 0) {
|
||||
// set inverted transform with "scale(1)" marker, see above
|
||||
entry.el.style.transition = 'none';
|
||||
entry.el.style.transform =
|
||||
'translate(' +
|
||||
entry.deltaX +
|
||||
'px, ' +
|
||||
entry.deltaY +
|
||||
'px) scale(1) ' +
|
||||
entry.transform;
|
||||
toAnimate.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
return toAnimate;
|
||||
|
||||
// calculate inverse transform relative to any animated ancestors
|
||||
function calculate(entry) {
|
||||
if (entry.calculated) return;
|
||||
entry.calculated = true;
|
||||
|
||||
var b = first.get(entry.key);
|
||||
|
||||
if (b) {
|
||||
entry.deltaX = b.rect.left - entry.rect.left;
|
||||
entry.deltaY = b.rect.top - entry.rect.top;
|
||||
|
||||
if (entry.ancestor) {
|
||||
calculate(entry.ancestor);
|
||||
|
||||
entry.deltaX -= entry.ancestor.deltaX;
|
||||
entry.deltaY -= entry.ancestor.deltaY;
|
||||
}
|
||||
} else {
|
||||
entry.appear = true;
|
||||
entry.deltaX = 0;
|
||||
entry.deltaY = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// play remove animations and remove elements after timeout
|
||||
function remove(entries) {
|
||||
entries.forEach(function (entry) {
|
||||
entry.el.style.transition = '';
|
||||
entry.el.style.opacity = '0';
|
||||
});
|
||||
|
||||
setTimeout(function () {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.el.parentNode) {
|
||||
entry.el.parentNode.removeChild(entry.el);
|
||||
}
|
||||
});
|
||||
}, options.removeTimeout);
|
||||
}
|
||||
|
||||
// play move/appear animations
|
||||
function animate(entries) {
|
||||
entries.forEach(function (entry) {
|
||||
entry.el.style.transition = '';
|
||||
entry.el.style.transform = entry.transform;
|
||||
entry.el.style.opacity = '';
|
||||
});
|
||||
}
|
||||
};
|
24
public/scripts/AppIcon.js
Normal file
24
public/scripts/AppIcon.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.AppIcon = function (el) {
|
||||
if (el.children.length > 0) return;
|
||||
|
||||
var id = el.getAttribute('data-id');
|
||||
var promise = VT.AppIcon.cache[id];
|
||||
|
||||
if (!promise) {
|
||||
var url = VT.AppIcon.baseUrl + id + '.svg';
|
||||
promise = VT.AppIcon.cache[id] = fetch(url).then(function (r) {
|
||||
return r.text();
|
||||
});
|
||||
}
|
||||
|
||||
promise.then(function (svg) {
|
||||
el.innerHTML = el.classList.contains('-double') ? svg + svg : svg;
|
||||
});
|
||||
};
|
||||
|
||||
VT.AppIcon.baseUrl =
|
||||
'https://rawcdn.githack.com/primer/octicons/ff7f6eee63fa2f2d24d02e3aa76a87db48e4b6f6/icons/';
|
||||
VT.AppIcon.cache = {};
|
39
public/scripts/AppLateBlur.js
Normal file
39
public/scripts/AppLateBlur.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
/**
|
||||
* Enables `lateBlur` events on the target element.
|
||||
* After an actual `blur` event, subsequent interactions with the window
|
||||
* (e.g. focus, select, mouseup etc.) will dispatch a `lateBlur` event.
|
||||
*/
|
||||
VT.AppLateBlur = function (el) {
|
||||
el.addEventListener('blur', function () {
|
||||
window.addEventListener('focus', dispatch);
|
||||
window.addEventListener('select', dispatch);
|
||||
|
||||
if (window.navigator.pointerEnabled) {
|
||||
window.addEventListener('pointerup', dispatch);
|
||||
} else if (window.navigator.msPointerEnabled) {
|
||||
window.addEventListener('MSPointerUp', dispatch);
|
||||
} else {
|
||||
window.addEventListener('mouseup', dispatch);
|
||||
window.addEventListener('touchend', dispatch);
|
||||
}
|
||||
});
|
||||
|
||||
function dispatch() {
|
||||
window.removeEventListener('focus', dispatch);
|
||||
window.removeEventListener('select', dispatch);
|
||||
|
||||
if (window.navigator.pointerEnabled) {
|
||||
window.removeEventListener('pointerup', dispatch);
|
||||
} else if (window.navigator.msPointerEnabled) {
|
||||
window.removeEventListener('MSPointerUp', dispatch);
|
||||
} else {
|
||||
window.removeEventListener('mouseup', dispatch);
|
||||
window.removeEventListener('touchend', dispatch);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new CustomEvent('lateBlur', { bubbles: true }));
|
||||
}
|
||||
};
|
146
public/scripts/AppSortable.js
Normal file
146
public/scripts/AppSortable.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.AppSortable = function (el, options) {
|
||||
var placeholder;
|
||||
var placeholderSource;
|
||||
var horizontal = options.direction === 'horizontal';
|
||||
var currentIndex = -1;
|
||||
|
||||
el.addEventListener('draggableStart', function (e) {
|
||||
e.detail.image.addEventListener('draggableCancel', cleanUp);
|
||||
});
|
||||
|
||||
el.addEventListener('draggableOver', function (e) {
|
||||
maybeDispatchUpdate(calculateIndex(e.detail.image), e);
|
||||
});
|
||||
|
||||
el.addEventListener('draggableLeave', function (e) {
|
||||
maybeDispatchUpdate(-1, e);
|
||||
});
|
||||
|
||||
el.addEventListener('draggableDrop', function (e) {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('sortableDrop', {
|
||||
detail: buildDetail(e),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
el.addEventListener('sortableUpdate', function (e) {
|
||||
if (!placeholder) {
|
||||
e.detail.setPlaceholder(e.detail.originalEvent.detail.imageSource);
|
||||
}
|
||||
|
||||
if (e.detail.index >= 0) {
|
||||
insertPlaceholder(e.detail.index);
|
||||
} else {
|
||||
removePlaceholder();
|
||||
}
|
||||
|
||||
removeByKey(e.detail.data.key);
|
||||
});
|
||||
|
||||
el.addEventListener('sortableDrop', cleanUp);
|
||||
|
||||
function maybeDispatchUpdate(index, originalEvent) {
|
||||
if (index !== currentIndex) {
|
||||
currentIndex = index;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('sortableUpdate', {
|
||||
detail: buildDetail(originalEvent),
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanUp() {
|
||||
removePlaceholder();
|
||||
placeholder = null;
|
||||
placeholderSource = null;
|
||||
currentIndex = -1;
|
||||
}
|
||||
|
||||
function buildDetail(e) {
|
||||
var detail = {
|
||||
data: e.detail.data,
|
||||
index: currentIndex,
|
||||
placeholder: placeholder,
|
||||
setPlaceholder: function (source) {
|
||||
setPlaceholder(source);
|
||||
detail.placeholder = placeholder;
|
||||
},
|
||||
originalEvent: e,
|
||||
};
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
function setPlaceholder(source) {
|
||||
if (placeholderSource === source) return;
|
||||
placeholderSource = source;
|
||||
|
||||
removePlaceholder();
|
||||
|
||||
placeholder = placeholderSource.cloneNode(true);
|
||||
placeholder.classList.add('-placeholder');
|
||||
placeholder.removeAttribute('data-key');
|
||||
}
|
||||
|
||||
function insertPlaceholder(index) {
|
||||
if (placeholder && el.children[index] !== placeholder) {
|
||||
if (placeholder.parentNode === el) el.removeChild(placeholder);
|
||||
el.insertBefore(placeholder, el.children[index]);
|
||||
}
|
||||
}
|
||||
|
||||
function removePlaceholder() {
|
||||
if (placeholder && placeholder.parentNode) {
|
||||
placeholder.parentNode.removeChild(placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
function removeByKey(key) {
|
||||
for (var i = 0, l = el.children.length; i < l; ++i) {
|
||||
var child = el.children[i];
|
||||
|
||||
if (child && child.getAttribute('data-key') === key) {
|
||||
el.removeChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateIndex(image) {
|
||||
if (el.children.length === 0) return 0;
|
||||
|
||||
var isBefore = horizontal ? isLeft : isAbove;
|
||||
var rect = image.getBoundingClientRect();
|
||||
var p = 0;
|
||||
|
||||
for (var i = 0, l = el.children.length; i < l; ++i) {
|
||||
var child = el.children[i];
|
||||
|
||||
if (isBefore(rect, child.getBoundingClientRect())) return i - p;
|
||||
if (child === placeholder) p = 1;
|
||||
}
|
||||
|
||||
return el.children.length - p;
|
||||
}
|
||||
|
||||
function isAbove(rectA, rectB) {
|
||||
return (
|
||||
rectA.top + (rectA.bottom - rectA.top) / 2 <=
|
||||
rectB.top + (rectB.bottom - rectB.top) / 2
|
||||
);
|
||||
}
|
||||
|
||||
function isLeft(rectA, rectB) {
|
||||
return (
|
||||
rectA.left + (rectA.right - rectA.left) / 2 <=
|
||||
rectB.left + (rectB.right - rectB.left) / 2
|
||||
);
|
||||
}
|
||||
};
|
123
public/scripts/TodoApp.js
Normal file
123
public/scripts/TodoApp.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoApp = function (el) {
|
||||
var state = {
|
||||
items: [],
|
||||
customLists: [],
|
||||
date: VT.formatDateId(new Date()),
|
||||
index: 0,
|
||||
showLists: true,
|
||||
};
|
||||
|
||||
el.innerHTML = [
|
||||
'<header class="app-header">',
|
||||
' <h1 class="title">VANILLA TODO</h1>',
|
||||
'</header>',
|
||||
'<div class="todo-frame -days"></div>',
|
||||
'<div class="app-collapsible">',
|
||||
' <p class="bar">',
|
||||
' <button class="app-button -circle toggle"><i class="app-icon" data-id="chevron-up-24"></i></button>',
|
||||
' </p>',
|
||||
' <div class="body">',
|
||||
' <div class="todo-frame -custom"></div>',
|
||||
' </div>',
|
||||
'</div>',
|
||||
'<footer class="app-footer">',
|
||||
' <p>',
|
||||
' VANILLA TODO © 2020 <a href="https://morrisbrodersen.de">Morris Brodersen</a>',
|
||||
' — A case study on viable techniques for vanilla web development.',
|
||||
' <a href="https://github.com/morris/vanilla-todo">About</a>',
|
||||
' </p>',
|
||||
'</footer>',
|
||||
].join('\n');
|
||||
|
||||
VT.AppFlip(el, {
|
||||
selector: '.todo-item, .todo-item-input, .todo-day, .todo-custom-list',
|
||||
removeTimeout: 200,
|
||||
});
|
||||
VT.TodoStore(el);
|
||||
|
||||
el.querySelectorAll('.app-collapsible').forEach(VT.AppCollapsible);
|
||||
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||
|
||||
VT.TodoFrameDays(el.querySelector('.todo-frame.-days'));
|
||||
VT.TodoFrameCustom(el.querySelector('.todo-frame.-custom'));
|
||||
|
||||
// each of these events make changes to the HTML to be animated using FLIP
|
||||
// listening to them using "capture" dispatches "beforeFlip" before any changes
|
||||
el.addEventListener('todoData', beforeFlip, true);
|
||||
el.addEventListener('sortableUpdate', beforeFlip, true);
|
||||
el.addEventListener('draggableCancel', beforeFlip, true);
|
||||
el.addEventListener('draggableDrop', beforeFlip, true);
|
||||
|
||||
// some necessary work to orchestrate drag & drop with FLIP animations
|
||||
el.addEventListener('draggableStart', function (e) {
|
||||
e.detail.image.classList.add('_noflip');
|
||||
el.appendChild(e.detail.image);
|
||||
});
|
||||
|
||||
el.addEventListener('draggableCancel', function (e) {
|
||||
e.detail.image.classList.remove('_noflip');
|
||||
update();
|
||||
});
|
||||
|
||||
el.addEventListener('draggableDrop', function (e) {
|
||||
e.detail.image.classList.remove('_noflip');
|
||||
});
|
||||
|
||||
el.addEventListener('sortableUpdate', function (e) {
|
||||
e.detail.placeholder.classList.add('_noflip');
|
||||
});
|
||||
|
||||
// listen to the TodoStore's data
|
||||
// this is the main update
|
||||
// everything else is related to drag & drop or FLIP animations
|
||||
el.addEventListener('todoData', function (e) {
|
||||
update(e.detail);
|
||||
});
|
||||
|
||||
// dispatch "flip" after HTML changes from these events
|
||||
// this plays the FLIP animations
|
||||
el.addEventListener('todoData', flip);
|
||||
el.addEventListener('sortableUpdate', flip);
|
||||
el.addEventListener('draggableCancel', flip);
|
||||
el.addEventListener('draggableDrop', flip);
|
||||
|
||||
el.todoStore.load();
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
el.querySelector('.todo-frame.-days').todoFrameDays.update({
|
||||
items: state.items,
|
||||
at: state.at,
|
||||
});
|
||||
|
||||
el.querySelector('.todo-frame.-custom').todoFrameCustom.update({
|
||||
lists: state.customLists,
|
||||
items: state.items,
|
||||
at: state.customAt,
|
||||
});
|
||||
|
||||
el.querySelectorAll('.app-collapsible').forEach(function (el) {
|
||||
el.appCollapsible.update();
|
||||
});
|
||||
}
|
||||
|
||||
function beforeFlip() {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('beforeFlip', {
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function flip() {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('flip', {
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
125
public/scripts/TodoCustomList.js
Normal file
125
public/scripts/TodoCustomList.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoCustomList = function (el) {
|
||||
var state = {
|
||||
list: null,
|
||||
editing: false,
|
||||
};
|
||||
var focus = false;
|
||||
|
||||
el.innerHTML = [
|
||||
'<div class="header">',
|
||||
' <h3 class="title"></h3>',
|
||||
' <p class="form">',
|
||||
' <input type="text" class="input">',
|
||||
' <button class="app-button delete"><i class="app-icon" data-id="trashcan-16"></i></button>',
|
||||
' </p>',
|
||||
'</div>',
|
||||
'<div class="todo-list"></div>',
|
||||
].join('\n');
|
||||
|
||||
var titleEl = el.querySelector('.title');
|
||||
var inputEl = el.querySelector('.input');
|
||||
var deleteEl = el.querySelector('.delete');
|
||||
|
||||
VT.AppDraggable(titleEl, {
|
||||
dropSelector: '.todo-frame.-custom .container',
|
||||
});
|
||||
VT.AppLateBlur(inputEl);
|
||||
VT.TodoList(el.querySelector('.todo-list'));
|
||||
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||
|
||||
titleEl.addEventListener('click', function () {
|
||||
focus = true;
|
||||
update({ editing: true });
|
||||
});
|
||||
|
||||
inputEl.addEventListener('input', function () {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('saveList', {
|
||||
detail: { list: state.list, title: inputEl.value.trim() },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
inputEl.addEventListener('lateBlur', function () {
|
||||
update({ editing: false });
|
||||
});
|
||||
|
||||
inputEl.addEventListener('keypress', function (e) {
|
||||
if (e.keyCode === 13) {
|
||||
update({ editing: false });
|
||||
}
|
||||
});
|
||||
|
||||
deleteEl.addEventListener('click', function () {
|
||||
if (state.list.items.length > 0) {
|
||||
if (
|
||||
!confirm(
|
||||
'Deleting this list will delete its items as well. Are you sure?'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('deleteList', {
|
||||
detail: state.list,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
el.addEventListener('draggableStart', function (e) {
|
||||
if (e.target !== titleEl) return;
|
||||
|
||||
e.detail.data.list = state.list;
|
||||
e.detail.data.key = state.list.id;
|
||||
|
||||
// update image (default would only be title element)
|
||||
e.detail.setImage(el);
|
||||
|
||||
// override for horizontal dragging only
|
||||
e.detail.image.addEventListener('draggableDrag', function (e) {
|
||||
var x = e.detail.clientX - e.detail.imageX;
|
||||
var y = e.detail.originY - e.detail.imageY;
|
||||
e.detail.image.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
|
||||
});
|
||||
});
|
||||
|
||||
el.addEventListener('addItem', function (e) {
|
||||
e.detail.listId = state.list.id;
|
||||
});
|
||||
|
||||
el.addEventListener('moveItem', function (e) {
|
||||
e.detail.listId = state.list.id;
|
||||
e.detail.index = e.detail.index || 0;
|
||||
});
|
||||
|
||||
el.todoCustomList = {
|
||||
update: update,
|
||||
};
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
titleEl.innerText = state.list.title || '...';
|
||||
inputEl.value = state.list.title;
|
||||
el.querySelector('.todo-list').todoList.update({ items: state.list.items });
|
||||
el.querySelector('.todo-list > .todo-item-input').setAttribute(
|
||||
'data-key',
|
||||
'todo-item-input-' + state.list.id
|
||||
);
|
||||
|
||||
el.classList.toggle('-editing', state.editing);
|
||||
|
||||
if (state.editing && focus) {
|
||||
inputEl.focus();
|
||||
inputEl.select();
|
||||
focus = false;
|
||||
}
|
||||
}
|
||||
};
|
51
public/scripts/TodoDay.js
Normal file
51
public/scripts/TodoDay.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoDay = function (el) {
|
||||
var state = {
|
||||
dateId: el.getAttribute('data-key'),
|
||||
items: [],
|
||||
};
|
||||
|
||||
el.innerHTML = [
|
||||
'<div class="header">',
|
||||
' <h3 class="dayofweek"></h3>',
|
||||
' <h6 class="date"></h6>',
|
||||
'</div>',
|
||||
'<div class="todo-list"></div>',
|
||||
].join('\n');
|
||||
|
||||
VT.TodoList(el.querySelector('.todo-list'));
|
||||
|
||||
el.addEventListener('addItem', function (e) {
|
||||
e.detail.listId = state.dateId;
|
||||
});
|
||||
|
||||
el.addEventListener('moveItem', function (e) {
|
||||
e.detail.listId = state.dateId;
|
||||
e.detail.index = e.detail.index || 0;
|
||||
});
|
||||
|
||||
el.todoDay = {
|
||||
update: update,
|
||||
};
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
var date = new Date(state.dateId);
|
||||
var today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
var tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
el.classList.toggle('-past', date < today);
|
||||
el.classList.toggle('-today', date >= today && date < tomorrow);
|
||||
|
||||
el.querySelector('.header > .dayofweek').innerText = VT.formatDayOfWeek(
|
||||
date
|
||||
);
|
||||
el.querySelector('.header > .date').innerText = VT.formatDate(date);
|
||||
el.querySelector('.todo-list').todoList.update({ items: state.items });
|
||||
}
|
||||
};
|
163
public/scripts/TodoFrameCustom.js
Normal file
163
public/scripts/TodoFrameCustom.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoFrameCustom = function (el) {
|
||||
var state = {
|
||||
lists: [],
|
||||
items: [],
|
||||
at: 0,
|
||||
show: true,
|
||||
};
|
||||
|
||||
el.innerHTML = [
|
||||
'<div class="leftcontrols">',
|
||||
' <p><button class="app-button -circle -xl back"><i class="app-icon" data-id="chevron-left-24"></i></button></p>',
|
||||
'</div>',
|
||||
'<div class="container"></div>',
|
||||
'<div class="rightcontrols">',
|
||||
' <p><button class="app-button -circle -xl forward"><i class="app-icon" data-id="chevron-right-24"></i></button></p>',
|
||||
' <p><button class="app-button -circle -xl add"><i class="app-icon" data-id="plus-circle-24"></i></button></p>',
|
||||
'</div>',
|
||||
].join('\n');
|
||||
|
||||
VT.AppSortable(el.querySelector('.container'), { direction: 'horizontal' });
|
||||
|
||||
setTimeout(function () {
|
||||
el.classList.add('-animated');
|
||||
}, 200);
|
||||
|
||||
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||
|
||||
el.querySelector('.back').addEventListener('click', function () {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('customSeek', { detail: -1, bubbles: true })
|
||||
);
|
||||
});
|
||||
|
||||
el.querySelector('.forward').addEventListener('click', function () {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('customSeek', { detail: 1, bubbles: true })
|
||||
);
|
||||
});
|
||||
|
||||
el.querySelector('.add').addEventListener('click', function () {
|
||||
el.dispatchEvent(new CustomEvent('addList', { detail: {}, bubbles: true }));
|
||||
// TODO seek if not at end
|
||||
});
|
||||
|
||||
el.addEventListener('sortableDrop', function (e) {
|
||||
if (!e.detail.data.list) return;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('moveList', {
|
||||
detail: {
|
||||
list: e.detail.data.list,
|
||||
index: e.detail.index,
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
el.addEventListener('draggableOver', function (e) {
|
||||
if (!e.detail.data.list) return;
|
||||
|
||||
updateTranslation();
|
||||
});
|
||||
|
||||
el.todoFrameCustom = {
|
||||
update: update,
|
||||
};
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
var lists = getLists();
|
||||
var container = el.querySelector('.container');
|
||||
var obsolete = new Set(container.children);
|
||||
|
||||
var children = lists.map(function (list) {
|
||||
var child = container.querySelector(
|
||||
'.todo-custom-list[data-key="' + list.id + '"]'
|
||||
);
|
||||
|
||||
if (child) {
|
||||
obsolete.delete(child);
|
||||
} else {
|
||||
child = document.createElement('div');
|
||||
child.className = 'card todo-custom-list';
|
||||
child.setAttribute('data-key', list.id);
|
||||
VT.TodoCustomList(child);
|
||||
}
|
||||
|
||||
child.todoCustomList.update({ list: list });
|
||||
|
||||
return child;
|
||||
});
|
||||
|
||||
obsolete.forEach(function (child) {
|
||||
container.removeChild(child);
|
||||
});
|
||||
|
||||
children.forEach(function (child, index) {
|
||||
if (child !== container.children[index]) {
|
||||
container.insertBefore(child, container.children[index]);
|
||||
}
|
||||
});
|
||||
|
||||
updateTranslation();
|
||||
updateHeight();
|
||||
}
|
||||
|
||||
function updateTranslation() {
|
||||
el.querySelectorAll('.container > *').forEach(function (child, index) {
|
||||
child.style.transform = 'translateX(' + (index - state.at) * 100 + '%)';
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeight() {
|
||||
var height = 400;
|
||||
var container = el.querySelector('.container');
|
||||
|
||||
var i, l;
|
||||
|
||||
for (i = 0, l = container.children.length; i < l; ++i) {
|
||||
height = Math.max(container.children[i].offsetHeight, height);
|
||||
}
|
||||
|
||||
el.style.height = height + 50 + 'px';
|
||||
|
||||
for (i = 0, l = container.children.length; i < l; ++i) {
|
||||
container.children[i].style.height = height + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function getLists() {
|
||||
var lists = state.lists.map(function (list) {
|
||||
return {
|
||||
id: list.id,
|
||||
index: list.index,
|
||||
title: list.title,
|
||||
items: getItemsForList(list.id),
|
||||
};
|
||||
});
|
||||
|
||||
lists.sort(function (a, b) {
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return lists;
|
||||
}
|
||||
|
||||
function getItemsForList(listId) {
|
||||
var items = state.items.filter(function (item) {
|
||||
return item.listId === listId;
|
||||
});
|
||||
|
||||
items.sort(function (a, b) {
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
};
|
135
public/scripts/TodoFrameDays.js
Normal file
135
public/scripts/TodoFrameDays.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoFrameDays = function (el) {
|
||||
var RANGE = 14;
|
||||
var state = {
|
||||
items: [],
|
||||
at: VT.formatDateId(new Date()),
|
||||
};
|
||||
|
||||
el.innerHTML = [
|
||||
'<nav class="leftcontrols">',
|
||||
' <p><button class="app-button -circle -xl backward"><i class="app-icon" data-id="chevron-left-24"></i></button></p>',
|
||||
' <p><button class="app-button fastbackward"><i class="app-icon -double" data-id="chevron-left-16"></i></i></button></p>',
|
||||
' <p><button class="app-button home"><i class="app-icon" data-id="home-16"></i></button></p>',
|
||||
'</nav>',
|
||||
'<div class="container"></div>',
|
||||
'<nav class="rightcontrols">',
|
||||
' <p><button class="app-button -circle -xl forward"><i class="app-icon" data-id="chevron-right-24"></i></button></p>',
|
||||
' <p><button class="app-button fastforward"><i class="app-icon -double" data-id="chevron-right-16"></i></button></p>',
|
||||
'</nav>',
|
||||
].join('\n');
|
||||
|
||||
setTimeout(function () {
|
||||
el.classList.add('-animated');
|
||||
}, 200);
|
||||
|
||||
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||
|
||||
el.querySelector('.backward').addEventListener('click', function () {
|
||||
el.dispatchEvent(new CustomEvent('seek', { detail: -1, bubbles: true }));
|
||||
});
|
||||
|
||||
el.querySelector('.forward').addEventListener('click', function () {
|
||||
el.dispatchEvent(new CustomEvent('seek', { detail: 1, bubbles: true }));
|
||||
});
|
||||
|
||||
el.querySelector('.fastbackward').addEventListener('click', function () {
|
||||
el.dispatchEvent(new CustomEvent('seek', { detail: -5, bubbles: true }));
|
||||
});
|
||||
|
||||
el.querySelector('.fastforward').addEventListener('click', function () {
|
||||
el.dispatchEvent(new CustomEvent('seek', { detail: 5, bubbles: true }));
|
||||
});
|
||||
|
||||
el.querySelector('.home').addEventListener('click', function () {
|
||||
el.dispatchEvent(new CustomEvent('seekHome', { bubbles: true }));
|
||||
});
|
||||
|
||||
el.todoFrameDays = {
|
||||
update: update,
|
||||
};
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
var days = getDays();
|
||||
|
||||
var container = el.querySelector('.container');
|
||||
var obsolete = new Set(container.children);
|
||||
|
||||
var children = days.map(function (day) {
|
||||
var child = container.querySelector(
|
||||
'.todo-day[data-key="' + day.id + '"]'
|
||||
);
|
||||
|
||||
if (child) {
|
||||
obsolete.delete(child);
|
||||
} else {
|
||||
child = document.createElement('div');
|
||||
child.className = 'card todo-day';
|
||||
child.setAttribute('data-key', day.id);
|
||||
VT.TodoDay(child);
|
||||
}
|
||||
|
||||
child.todoDay.update(day);
|
||||
child.style.transform = 'translateX(' + day.position * 100 + '%)';
|
||||
|
||||
return child;
|
||||
});
|
||||
|
||||
obsolete.forEach(function (child) {
|
||||
container.removeChild(child);
|
||||
});
|
||||
|
||||
children.forEach(function (child, index) {
|
||||
if (child !== container.children[index]) {
|
||||
container.insertBefore(child, container.children[index]);
|
||||
}
|
||||
});
|
||||
|
||||
updateHeight();
|
||||
}
|
||||
|
||||
function updateHeight() {
|
||||
var height = 400;
|
||||
var container = el.querySelector('.container');
|
||||
|
||||
for (var i = 0, l = container.children.length; i < l; ++i) {
|
||||
height = Math.max(container.children[i].offsetHeight, height);
|
||||
}
|
||||
|
||||
el.style.height = height + 50 + 'px';
|
||||
}
|
||||
|
||||
function getDays() {
|
||||
var days = [];
|
||||
|
||||
for (var i = 0; i < 2 * RANGE; ++i) {
|
||||
var t = new Date(state.at);
|
||||
t.setDate(t.getDate() - RANGE + i);
|
||||
var id = VT.formatDateId(t);
|
||||
|
||||
days.push({
|
||||
id: id,
|
||||
items: getItemsForDay(id),
|
||||
position: -RANGE + i,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function getItemsForDay(dateId) {
|
||||
var items = state.items.filter(function (item) {
|
||||
return item.listId === dateId;
|
||||
});
|
||||
|
||||
items.sort(function (a, b) {
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
};
|
115
public/scripts/TodoItem.js
Normal file
115
public/scripts/TodoItem.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoItem = function (el) {
|
||||
var state = {
|
||||
item: null,
|
||||
editing: false,
|
||||
};
|
||||
var focus = false;
|
||||
|
||||
el.innerHTML = [
|
||||
'<div class="checkbox">',
|
||||
' <input type="checkbox">',
|
||||
'</div>',
|
||||
'<p class="label"></p>',
|
||||
'<p class="form">',
|
||||
' <input class="input" type="text">',
|
||||
' <button class="app-button save"><i class="app-icon" data-id="check-16"></i></button>',
|
||||
'</p>',
|
||||
].join('\n');
|
||||
|
||||
var inputEl = el.querySelector('.input');
|
||||
var labelEl = el.querySelector('.label');
|
||||
|
||||
VT.AppDraggable(el, {
|
||||
dropSelector: '.todo-list > .items',
|
||||
});
|
||||
VT.AppLateBlur(inputEl);
|
||||
|
||||
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||
|
||||
el.querySelector('.checkbox').addEventListener('click', function () {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('checkItem', {
|
||||
detail: {
|
||||
item: state.item,
|
||||
done: !state.item.done,
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
labelEl.addEventListener('click', function () {
|
||||
focus = true;
|
||||
update({ editing: true });
|
||||
});
|
||||
|
||||
el.querySelector('.save').addEventListener('click', function () {
|
||||
save();
|
||||
});
|
||||
|
||||
inputEl.addEventListener('keypress', function (e) {
|
||||
if (e.keyCode === 13) save();
|
||||
});
|
||||
|
||||
inputEl.addEventListener('lateBlur', function () {
|
||||
save();
|
||||
update({ editing: false });
|
||||
});
|
||||
|
||||
el.addEventListener('draggableStart', function (e) {
|
||||
e.detail.data.item = state.item;
|
||||
e.detail.data.key = state.item.id;
|
||||
});
|
||||
|
||||
el.todoItem = {
|
||||
update: update,
|
||||
};
|
||||
|
||||
function save() {
|
||||
var label = inputEl.value.trim();
|
||||
|
||||
if (label === '') {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('deleteItem', {
|
||||
detail: state.item,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('saveItem', {
|
||||
detail: {
|
||||
item: state.item,
|
||||
label: label,
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
update({ editing: false });
|
||||
}
|
||||
|
||||
function update(next) {
|
||||
// TODO optimize
|
||||
Object.assign(state, next);
|
||||
|
||||
el.classList.toggle('-done', state.item.done);
|
||||
el.querySelector('.checkbox > input').checked = state.item.done;
|
||||
labelEl.innerText = state.item.label;
|
||||
inputEl.value = state.item.label;
|
||||
el.classList.toggle('-editing', state.editing);
|
||||
el.classList.toggle('_nodrag', state.editing);
|
||||
|
||||
if (state.editing && focus) {
|
||||
el.querySelector('.input').focus();
|
||||
el.querySelector('.input').select();
|
||||
focus = false;
|
||||
}
|
||||
}
|
||||
};
|
48
public/scripts/TodoItemInput.js
Normal file
48
public/scripts/TodoItemInput.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoItemInput = function (el) {
|
||||
el.innerHTML = [
|
||||
'<input class="input" type="text">',
|
||||
'<button class="app-button save"><i class="app-icon" data-id="plus-24"></i></button>',
|
||||
].join('\n');
|
||||
|
||||
var inputEl = el.querySelector('.input');
|
||||
var saveEl = el.querySelector('.save');
|
||||
|
||||
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||
|
||||
saveEl.addEventListener('click', save);
|
||||
inputEl.addEventListener('keypress', handleKeypress);
|
||||
|
||||
function handleKeypress(e) {
|
||||
switch (e.keyCode) {
|
||||
case 13: // enter
|
||||
save();
|
||||
break;
|
||||
case 27: // escape
|
||||
clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
var label = inputEl.value.trim();
|
||||
|
||||
if (label === '') return;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('addItem', {
|
||||
detail: { label: inputEl.value },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
inputEl.value = '';
|
||||
}
|
||||
|
||||
function clear() {
|
||||
inputEl.value = '';
|
||||
inputEl.blur();
|
||||
}
|
||||
};
|
67
public/scripts/TodoList.js
Normal file
67
public/scripts/TodoList.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoList = function (el) {
|
||||
var state = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
el.innerHTML = [
|
||||
'<div class="items"></div>',
|
||||
'<div class="todo-item-input"></div>',
|
||||
].join('\n');
|
||||
|
||||
VT.AppSortable(el.querySelector('.items'), {});
|
||||
VT.TodoItemInput(el.querySelector('.todo-item-input'));
|
||||
|
||||
el.addEventListener('sortableDrop', function (e) {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('moveItem', {
|
||||
detail: {
|
||||
item: e.detail.data.item,
|
||||
index: e.detail.index,
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
|
||||
var container = el.querySelector('.items');
|
||||
var obsolete = new Set(container.children);
|
||||
var children = state.items.map(function (item) {
|
||||
var child = container.querySelector(
|
||||
'.todo-item[data-key="' + item.id + '"]'
|
||||
);
|
||||
|
||||
if (child) {
|
||||
obsolete.delete(child);
|
||||
} else {
|
||||
child = document.createElement('div');
|
||||
child.classList.add('todo-item');
|
||||
child.setAttribute('data-key', item.id);
|
||||
VT.TodoItem(child);
|
||||
}
|
||||
|
||||
child.todoItem.update({ item: item });
|
||||
|
||||
return child;
|
||||
});
|
||||
|
||||
obsolete.forEach(function (child) {
|
||||
container.removeChild(child);
|
||||
});
|
||||
|
||||
children.forEach(function (child, index) {
|
||||
if (child !== container.children[index]) {
|
||||
container.insertBefore(child, container.children[index]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
el.todoList = {
|
||||
update: update,
|
||||
};
|
||||
};
|
197
public/scripts/TodoStore.js
Normal file
197
public/scripts/TodoStore.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoStore = function (el) {
|
||||
var state = {
|
||||
items: [],
|
||||
customLists: [],
|
||||
at: VT.formatDateId(new Date()),
|
||||
customAt: 0,
|
||||
};
|
||||
var storeTimeout;
|
||||
|
||||
el.addEventListener('addItem', function (e) {
|
||||
var index = 0;
|
||||
|
||||
state.items.forEach(function (item) {
|
||||
if (item.listId === e.detail.listId) {
|
||||
index = Math.max(index, item.index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
state.items.push({
|
||||
id: VT.uuid(),
|
||||
listId: e.detail.listId,
|
||||
index: index,
|
||||
label: e.detail.label,
|
||||
done: false,
|
||||
});
|
||||
|
||||
update({ items: state.items });
|
||||
});
|
||||
|
||||
el.addEventListener('checkItem', function (e) {
|
||||
if (e.detail.item.done === e.detail.done) return;
|
||||
|
||||
e.detail.item.done = e.detail.done;
|
||||
update({ items: state.items });
|
||||
});
|
||||
|
||||
el.addEventListener('saveItem', function (e) {
|
||||
if (e.detail.item.label === e.detail.label) return;
|
||||
|
||||
e.detail.item.label = e.detail.label;
|
||||
update({ items: state.items });
|
||||
});
|
||||
|
||||
el.addEventListener('moveItem', function (e) {
|
||||
var movedItem = state.items.find(function (item) {
|
||||
return item.id === e.detail.item.id;
|
||||
});
|
||||
|
||||
var listItems = state.items.filter(function (item) {
|
||||
return item.listId === e.detail.listId && item !== movedItem;
|
||||
});
|
||||
|
||||
listItems.sort(function (a, b) {
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
movedItem.listId = e.detail.listId;
|
||||
listItems.splice(e.detail.index, 0, movedItem);
|
||||
|
||||
listItems.forEach(function (item, index) {
|
||||
item.index = index;
|
||||
});
|
||||
|
||||
update({ items: state.items });
|
||||
});
|
||||
|
||||
el.addEventListener('deleteItem', function (e) {
|
||||
update({
|
||||
items: state.items.filter(function (item) {
|
||||
return item.id !== e.detail.id;
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
el.addEventListener('addList', function (e) {
|
||||
var index = 0;
|
||||
|
||||
state.customLists.forEach(function (customList) {
|
||||
index = Math.max(index, customList.index + 1);
|
||||
});
|
||||
|
||||
state.customLists.push({
|
||||
id: VT.uuid(),
|
||||
index: index,
|
||||
title: e.detail.title || '',
|
||||
});
|
||||
|
||||
update({ customLists: state.customLists });
|
||||
});
|
||||
|
||||
el.addEventListener('saveList', function (e) {
|
||||
var list = state.customLists.find(function (l) {
|
||||
return l.id === e.detail.list.id;
|
||||
});
|
||||
|
||||
if (list.title === e.detail.title) return;
|
||||
|
||||
list.title = e.detail.title;
|
||||
|
||||
update({ customLists: state.customLists });
|
||||
});
|
||||
|
||||
el.addEventListener('moveList', function (e) {
|
||||
var movedListIndex = state.customLists.findIndex(function (list) {
|
||||
return list.id === e.detail.list.id;
|
||||
});
|
||||
var movedList = state.customLists[movedListIndex];
|
||||
|
||||
state.customLists.splice(movedListIndex, 1);
|
||||
state.customLists.sort(function (a, b) {
|
||||
return a.index - b.index;
|
||||
});
|
||||
state.customLists.splice(e.detail.index, 0, movedList);
|
||||
|
||||
state.customLists.forEach(function (item, index) {
|
||||
item.index = index;
|
||||
});
|
||||
|
||||
update({ customLists: state.customLists });
|
||||
});
|
||||
|
||||
el.addEventListener('deleteList', function (e) {
|
||||
update({
|
||||
customLists: state.customLists.filter(function (customList) {
|
||||
return customList.id !== e.detail.id;
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
el.addEventListener('seek', function (e) {
|
||||
var t = new Date(state.at);
|
||||
t.setDate(t.getDate() + e.detail);
|
||||
|
||||
update({
|
||||
at: VT.formatDateId(t),
|
||||
});
|
||||
});
|
||||
|
||||
el.addEventListener('seekHome', function () {
|
||||
update({
|
||||
at: VT.formatDateId(new Date()),
|
||||
});
|
||||
});
|
||||
|
||||
el.addEventListener('customSeek', function (e) {
|
||||
update({
|
||||
customAt: Math.max(
|
||||
0,
|
||||
Math.min(state.customLists.length - 1, state.customAt + e.detail)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
function update(next) {
|
||||
Object.assign(state, next);
|
||||
store();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('todoData', {
|
||||
detail: state,
|
||||
bubbles: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (!localStorage || !localStorage.todo) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
update(JSON.parse(localStorage.todo));
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
function store() {
|
||||
clearTimeout(storeTimeout);
|
||||
|
||||
storeTimeout = setTimeout(function () {
|
||||
try {
|
||||
localStorage.todo = JSON.stringify(state);
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
el.todoStore = {
|
||||
update: update,
|
||||
load: load,
|
||||
};
|
||||
};
|
15
public/scripts/TodoTrash.js
Normal file
15
public/scripts/TodoTrash.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.TodoTrash = function (el) {
|
||||
el.innerHTML = '<i class="app-icon" data-id="trashbin-24"></i>';
|
||||
|
||||
el.addEventListener('draggableDrop', function (e) {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('deleteItem', {
|
||||
detail: e.detail.data.item,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
82
public/scripts/util.js
Normal file
82
public/scripts/util.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/* global VT */
|
||||
window.VT = window.VT || {};
|
||||
|
||||
VT.uuid = function () {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c == 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
VT.formatDateId = function (date) {
|
||||
var y = date.getFullYear();
|
||||
var m = date.getMonth() + 1;
|
||||
var d = date.getDate();
|
||||
|
||||
return (
|
||||
y.toString().padStart(4, '0') +
|
||||
'-' +
|
||||
m.toString().padStart(2, '0') +
|
||||
'-' +
|
||||
d.toString().padStart(2, '0')
|
||||
);
|
||||
};
|
||||
|
||||
VT.formatDate = function (date) {
|
||||
return (
|
||||
VT.formatMonth(date) +
|
||||
' ' +
|
||||
VT.formatDayOfMonth(date) +
|
||||
' ' +
|
||||
date.getFullYear().toString().padStart(4, '0')
|
||||
);
|
||||
};
|
||||
|
||||
VT.formatDayOfMonth = function (date) {
|
||||
var d = date.getDate();
|
||||
var t = d % 10;
|
||||
|
||||
return d === 11 || d === 12 || d === 13
|
||||
? d + 'th'
|
||||
: t === 1
|
||||
? d + 'st'
|
||||
: t === 2
|
||||
? d + 'nd'
|
||||
: t === 3
|
||||
? d + 'rd'
|
||||
: d + 'th';
|
||||
};
|
||||
|
||||
VT.DAY_NAMES = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
VT.formatDayOfWeek = function (date) {
|
||||
return VT.DAY_NAMES[date.getDay()];
|
||||
};
|
||||
|
||||
VT.MONTH_NAMES = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
VT.formatMonth = function (date) {
|
||||
return VT.MONTH_NAMES[date.getMonth()];
|
||||
};
|
Reference in New Issue
Block a user