mirror of
https://github.com/morris/vanilla-todo.git
synced 2025-08-22 21:52:54 +02:00
backup es5 version
This commit is contained in:
24
es5/.eslintrc.js
Normal file
24
es5/.eslintrc.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['eslint:recommended', 'plugin:compat/recommended'],
|
||||||
|
globals: {
|
||||||
|
Set: 'readonly',
|
||||||
|
Map: 'readonly',
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 5,
|
||||||
|
},
|
||||||
|
rules: {},
|
||||||
|
settings: {
|
||||||
|
polyfills: [
|
||||||
|
'Set',
|
||||||
|
'Map',
|
||||||
|
'fetch',
|
||||||
|
'Object.assign',
|
||||||
|
'requestAnimationFrame',
|
||||||
|
'performance.now',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
831
es5/README.md
Normal file
831
es5/README.md
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
# VANILLA TODO (ES5)
|
||||||
|
|
||||||
|
_You are reading the ES5-based version of this document, originally published
|
||||||
|
in 2019. The [current version](https://github.com/morris/vanilla-todo) is
|
||||||
|
based on modern JavaScript._
|
||||||
|
|
||||||
|
A [TeuxDeux](https://teuxdeux.com) clone in plain HTML, CSS and JavaScript
|
||||||
|
(no build steps). It's fully animated and runs smoothly at 60 FPS
|
||||||
|
with a total transfer size of **44KB** (unminified).
|
||||||
|
|
||||||
|
**[Try it online →](https://raw.githack.com/morris/vanilla-todo/main/es5/public/index.html)**
|
||||||
|
|
||||||
|
More importantly, it's a case study showing that **vanilla web development** is
|
||||||
|
viable in terms of [maintainability](#521-the-good),
|
||||||
|
and worthwhile in terms of [user experience](#51-user-experience)
|
||||||
|
(**100%** faster loads and **90%** less bandwidth in this case).
|
||||||
|
|
||||||
|
**There's no custom framework invented here.**
|
||||||
|
Instead, the case study was [designed](#22-rules) to discover
|
||||||
|
minimum viable [patterns](#321-mount-functions) that are truly vanilla.
|
||||||
|
The result is maintainable, albeit [verbose](#522-the-verbose) and with
|
||||||
|
considerable duplication.
|
||||||
|
|
||||||
|
If anything, the case study validates the value of build steps and frameworks,
|
||||||
|
but also demonstrates that standard web technologies can be used effectively and
|
||||||
|
there are only a few [critical areas](#523-the-bad) where a vanilla approach is
|
||||||
|
clearly inferior (especially in browser testing).
|
||||||
|
|
||||||
|
_Intermediate understanding of the web platform is required to follow through._
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [1. Motivation](#1-motivation)
|
||||||
|
- [2. Method](#2-method)
|
||||||
|
- [2.1. Subject](#21-subject)
|
||||||
|
- [2.2. Rules](#22-rules)
|
||||||
|
- [2.3. Goals](#23-goals)
|
||||||
|
- [2.3.1. User Experience](#231-user-experience)
|
||||||
|
- [2.3.2. Code Quality](#232-code-quality)
|
||||||
|
- [2.3.3. Generality of Patterns](#233-generality-of-patterns)
|
||||||
|
- [3. Implementation](#3-implementation)
|
||||||
|
- [3.1. Basic Structure](#31-basic-structure)
|
||||||
|
- [3.2. JavaScript Architecture](#32-javascript-architecture)
|
||||||
|
- [3.2.1. Mount Functions](#321-mount-functions)
|
||||||
|
- [3.2.2. Data Flow](#322-data-flow)
|
||||||
|
- [3.2.3. Rendering](#323-rendering)
|
||||||
|
- [3.2.4. Reconciliation](#324-reconciliation)
|
||||||
|
- [3.3. Drag & Drop](#33-drag--drop)
|
||||||
|
- [3.4. Animations](#34-animations)
|
||||||
|
- [4. Testing](#4-testing)
|
||||||
|
- [5. Assessment](#5-assessment)
|
||||||
|
- [5.1. User Experience](#51-user-experience)
|
||||||
|
- [5.2. Code Quality](#52-code-quality)
|
||||||
|
- [5.2.1. The Good](#521-the-good)
|
||||||
|
- [5.2.2. The Verbose](#522-the-verbose)
|
||||||
|
- [5.2.3. The Bad](#523-the-bad)
|
||||||
|
- [5.3. Generality of Patterns](#53-generality-of-patterns)
|
||||||
|
- [6. Conclusion](#6-conclusion)
|
||||||
|
- [7. What's Next?](#7-whats-next)
|
||||||
|
- [8. Appendix](#8-appendix)
|
||||||
|
- [8.1. Links](#81-links)
|
||||||
|
- [8.2. Response](#82-response)
|
||||||
|
- [9. Changelog](#9-changelog)
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
I believe too little has been invested in researching
|
||||||
|
practical, scalable methods for building web applications
|
||||||
|
without third party dependencies.
|
||||||
|
|
||||||
|
It's not enough to describe how to create DOM nodes
|
||||||
|
or how to toggle a class without a framework.
|
||||||
|
It's also rather harmful to write an article
|
||||||
|
saying you don't need library X, and then proceed in describing how
|
||||||
|
to roll your own untested, inferior version of X.
|
||||||
|
|
||||||
|
What's missing are thorough examples of complex web applications
|
||||||
|
built only with standard web technologies, covering as many aspects of
|
||||||
|
the development process as possible.
|
||||||
|
|
||||||
|
This case study is an attempt to fill this gap, at least a little bit,
|
||||||
|
and inspire further research in the area.
|
||||||
|
|
||||||
|
## 2. Method
|
||||||
|
|
||||||
|
The method for this case study is as follows:
|
||||||
|
|
||||||
|
- Pick an interesting subject.
|
||||||
|
- Implement it using only standard web technologies.
|
||||||
|
- Document techniques and patterns found during the process.
|
||||||
|
- Assess the results by common quality standards.
|
||||||
|
|
||||||
|
This section describes the method in more detail.
|
||||||
|
|
||||||
|
### 2.1. Subject
|
||||||
|
|
||||||
|
I've chosen to build a functionally equivalent clone of
|
||||||
|
[TeuxDeux](https://teuxdeux.com) for this study.
|
||||||
|
The user interface has interesting challenges,
|
||||||
|
in particular performant drag & drop when combined with animations.
|
||||||
|
|
||||||
|
_The original TeuxDeux app deserves praise here. In my opinion it has the
|
||||||
|
best over-all concept and UX of all the to-do apps out there.
|
||||||
|
[Thank you!](https://fictivekin.com/)_
|
||||||
|
|
||||||
|
The user interface is arguably small (which is good for a case study)
|
||||||
|
but large enough to require thought on its architecture.
|
||||||
|
|
||||||
|
However, it is lacking in some key areas:
|
||||||
|
|
||||||
|
- Routing
|
||||||
|
- Asynchronous resource requests
|
||||||
|
- Server-side rendering
|
||||||
|
|
||||||
|
### 2.2. Rules
|
||||||
|
|
||||||
|
To produce valid vanilla solutions, and because constraints spark creativity,
|
||||||
|
I came up with a set of rules to follow throughout the process:
|
||||||
|
|
||||||
|
- Only use standard web technologies.
|
||||||
|
- Only use widely supported JS features unless they can be polyfilled (1).
|
||||||
|
- No runtime JS dependencies (except polyfills).
|
||||||
|
- No build steps.
|
||||||
|
- No general-purpose utility functions related to the DOM/UI (2).
|
||||||
|
|
||||||
|
(1) This is a moving target; I used ES5 for maximum support.
|
||||||
|
|
||||||
|
(2) These usually end up becoming a custom micro-framework,
|
||||||
|
thereby questioning why you didn't use one of the
|
||||||
|
established and tested libraries/frameworks in the first place.
|
||||||
|
|
||||||
|
### 2.3. Goals
|
||||||
|
|
||||||
|
The results are going to be assessed by three major concerns:
|
||||||
|
|
||||||
|
#### 2.3.1. User Experience
|
||||||
|
|
||||||
|
The resulting product should be comparable to or better
|
||||||
|
than the original regarding functionality, performance and design.
|
||||||
|
|
||||||
|
This includes testing major browsers and devices.
|
||||||
|
|
||||||
|
#### 2.3.2. Code Quality
|
||||||
|
|
||||||
|
The resulting implementation should adhere to
|
||||||
|
established code quality standards in the industry.
|
||||||
|
|
||||||
|
This will be difficult to assess objectively, as we will see later.
|
||||||
|
|
||||||
|
#### 2.3.3. Generality of Patterns
|
||||||
|
|
||||||
|
The discovered techniques and patterns should be applicable in a wide
|
||||||
|
range of scenarios.
|
||||||
|
|
||||||
|
## 3. Implementation
|
||||||
|
|
||||||
|
This section walks through the resulting implementation, highlighting techniques
|
||||||
|
and problems found during the process. You're encouraged to inspect the
|
||||||
|
[source code](./public) alongside this section.
|
||||||
|
|
||||||
|
### 3.1. Basic Structure
|
||||||
|
|
||||||
|
Since build steps are ruled out, the codebase is organized around
|
||||||
|
plain HTML, CSS and JS files. The HTML and CSS mostly follows
|
||||||
|
[rscss](https://rscss.io) (devised by [Rico Sta. Cruz](https://ricostacruz.com))
|
||||||
|
which yields an intuitive, component-oriented structure.
|
||||||
|
|
||||||
|
The stylesheets are slightly verbose.
|
||||||
|
I missed [SCSS](https://sass-lang.com/) or [LESS](http://lesscss.org/) here
|
||||||
|
and I think one of these is a must-have for bigger projects.
|
||||||
|
|
||||||
|
ES6 modules are ruled out so all JavaScript lives under
|
||||||
|
a global namespace (`VT`). This works everywhere but has some downsides
|
||||||
|
e.g. cannot be statically analyzed and may miss code completion.
|
||||||
|
|
||||||
|
Polyfills are directly fetched from [polyfill.io](https://polyfill.io/).
|
||||||
|
I've set the `nomodule` script attribute so polyfills are only fetched
|
||||||
|
for older browsers.
|
||||||
|
|
||||||
|
Basic code quality (code style, linting) is guided by
|
||||||
|
[Prettier](https://prettier.io), [stylelint](https://stylelint.io) and
|
||||||
|
[ESLint](https://eslint.org).
|
||||||
|
I've set the ESLint parser to ES5 to ensure only ES5 code is allowed.
|
||||||
|
|
||||||
|
Note that I've opted out of web components completely.
|
||||||
|
I can't clearly articulate what I dislike about them
|
||||||
|
but I never missed them throughout this study.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The basic structure comes with some boilerplate,
|
||||||
|
e.g. referencing all the individual stylesheets and scripts from the HTML;
|
||||||
|
probably enough to justify a simple build step.
|
||||||
|
|
||||||
|
It is otherwise straight-forward and trivial to understand
|
||||||
|
(literally just a bunch of HTML, CSS and JS files).
|
||||||
|
|
||||||
|
### 3.2. JavaScript Architecture
|
||||||
|
|
||||||
|
Naturally, the JavaScript architecture is the most interesting part of this study.
|
||||||
|
|
||||||
|
I found that using a combination of functions,
|
||||||
|
query selectors and DOM events is sufficient
|
||||||
|
to build a scalable, maintainable codebase,
|
||||||
|
albeit with some trade-offs as we will see later.
|
||||||
|
|
||||||
|
Conceptually, the proposed architecture loosely maps
|
||||||
|
CSS selectors to JS functions which are _mounted_ (i.e. called) once
|
||||||
|
per matching element. This yields a simple mental model and synergizes
|
||||||
|
with the DOM and styles:
|
||||||
|
|
||||||
|
```
|
||||||
|
.todo-list -> VT.TodoList
|
||||||
|
scripts/TodoList.js
|
||||||
|
styles/todo-list.css
|
||||||
|
|
||||||
|
.app-collapsible -> VT.AppCollapsible
|
||||||
|
scripts/AppCollapsible.js
|
||||||
|
styles/app-collapsible.css
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This proved to be a useful, repeatable pattern throughout all of the
|
||||||
|
implementation process.
|
||||||
|
|
||||||
|
#### 3.2.1. Mount Functions
|
||||||
|
|
||||||
|
_Mount functions_ take a DOM element as their (only) argument.
|
||||||
|
Their responsibility is to set up initial state, event listeners, and
|
||||||
|
provide behavior and rendering for the target element.
|
||||||
|
|
||||||
|
Here's a "Hello, World!" example of mount functions:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// safely initialize namespace
|
||||||
|
window.MYAPP = window.MYAPP || {};
|
||||||
|
|
||||||
|
// define mount function
|
||||||
|
// loosely mapped to ".hello-world"
|
||||||
|
MYAPP.HelloWorld = function (el) {
|
||||||
|
// define initial state
|
||||||
|
var state = {
|
||||||
|
title: 'Hello, World!',
|
||||||
|
description: 'An example vanilla component',
|
||||||
|
counter: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// set rigid base HTML
|
||||||
|
// no ES6 template literals :(
|
||||||
|
el.innerHTML = [
|
||||||
|
'<h1 class="title"></h1>',
|
||||||
|
'<p class="description"></p>',
|
||||||
|
'<div class="my-counter"></div>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// mount sub-components
|
||||||
|
el.querySelectorAll('.my-counter').forEach(MYAPP.MyCounter);
|
||||||
|
|
||||||
|
// attach event listeners
|
||||||
|
el.addEventListener('modifyCounter', function (e) {
|
||||||
|
update({ counter: state.counter + e.detail });
|
||||||
|
});
|
||||||
|
|
||||||
|
// expose public interface
|
||||||
|
// use lower-case function name
|
||||||
|
el.helloWorld = {
|
||||||
|
update: update,
|
||||||
|
};
|
||||||
|
|
||||||
|
// initial update
|
||||||
|
update();
|
||||||
|
|
||||||
|
// define idempotent update function
|
||||||
|
function update(next) {
|
||||||
|
// update state
|
||||||
|
// optionally optimize, e.g. bail out if state hasn't changed
|
||||||
|
Object.assign(state, next);
|
||||||
|
|
||||||
|
// update own HTML
|
||||||
|
el.querySelector('.title').innerText = state.title;
|
||||||
|
el.querySelector('.description').innerText = state.description;
|
||||||
|
|
||||||
|
// pass data to sub-scomponents
|
||||||
|
el.querySelector('.my-counter').myCounter.update({
|
||||||
|
value: state.counter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// define another component
|
||||||
|
// loosely mapped to ".my-counter"
|
||||||
|
MYAPP.MyCounter = function (el) {
|
||||||
|
// define initial state
|
||||||
|
var state = {
|
||||||
|
value: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// set rigid base HTML
|
||||||
|
// no ES6 template literals :(
|
||||||
|
el.innerHTML = [
|
||||||
|
'<p>',
|
||||||
|
' <span class="value"></span>',
|
||||||
|
' <button class="increment">Increment</button>',
|
||||||
|
' <button class="decrement">Decrement</button>',
|
||||||
|
'</p>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// attach event listeners
|
||||||
|
el.querySelector('.increment').addEventListener('click', function () {
|
||||||
|
// dispatch an action
|
||||||
|
// use .detail to transport data
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('modifyCounter', {
|
||||||
|
detail: 1,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.querySelector('.decrement').addEventListener('click', function () {
|
||||||
|
// dispatch an action
|
||||||
|
// use .detail to transport data
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('modifyCounter', {
|
||||||
|
detail: -1,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// expose public interface
|
||||||
|
// use lower-case function name
|
||||||
|
el.myCounter = {
|
||||||
|
update: update,
|
||||||
|
};
|
||||||
|
|
||||||
|
// define idempotent update function
|
||||||
|
function update(next) {
|
||||||
|
Object.assign(state, next);
|
||||||
|
|
||||||
|
el.querySelector('.value').innerText = state.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// mount HelloWorld component(s)
|
||||||
|
// any <div class="hello-world"></div> in the document will be mounted
|
||||||
|
document.querySelectorAll('.hello-world').forEach(MYAPP.HelloWorld);
|
||||||
|
```
|
||||||
|
|
||||||
|
This comes with quite some boilerplate but has useful properties,
|
||||||
|
as we will see in the following sections.
|
||||||
|
|
||||||
|
Note that any part of a mount function is entirely optional.
|
||||||
|
For example, a mount function does not have to set any base HTML,
|
||||||
|
and may instead only set event listeners to enable some behavior.
|
||||||
|
|
||||||
|
Also note that an element can be mounted with multiple mount functions.
|
||||||
|
For example, to-do items are mounted with `VT.TodoItem` and `VT.AppDraggable`.
|
||||||
|
|
||||||
|
Compared to React components, mount functions provide interesting flexibility as
|
||||||
|
components and behaviors can be implemented using the same idiom and combined
|
||||||
|
arbitrarily.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- [AppIcon.js](./public/scripts/AppIcon.js)
|
||||||
|
- [TodoItem.js](./public/scripts/TodoItem.js)
|
||||||
|
- [TodoItemInput.js](./public/scripts/TodoItemInput.js)
|
||||||
|
|
||||||
|
#### 3.2.2. Data Flow
|
||||||
|
|
||||||
|
I found it effective to implement one-way data flow similar to React's approach.
|
||||||
|
|
||||||
|
- **Data flows downwards** from parent components to child components
|
||||||
|
through their public interfaces (usually `update` functions).
|
||||||
|
- **Actions flow upwards** through custom DOM events (bubbling up),
|
||||||
|
usually resulting in some parent component state change which is in turn
|
||||||
|
propagated downwards through `update` functions.
|
||||||
|
|
||||||
|
The data store is factored into a separate behavior (`VT.TodoStore`).
|
||||||
|
It only receives and dispatches events, and encapsulates all of the data logic.
|
||||||
|
|
||||||
|
Listening to and dispatching events is slightly verbose with standard APIs and
|
||||||
|
certainly justifies introducing helpers.
|
||||||
|
I didn't need event delegation à la jQuery for this study
|
||||||
|
but I believe it's a useful concept that is difficult to do
|
||||||
|
concisely with standard APIs.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- [TodoDay.js](./public/scripts/TodoDay.js)
|
||||||
|
- [TodoStore.js](./public/scripts/TodoStore.js)
|
||||||
|
|
||||||
|
#### 3.2.3. Rendering
|
||||||
|
|
||||||
|
Naively re-rendering a whole component using `.innerHTML` should be avoided
|
||||||
|
as this may hurt performance and will likely break important functionality such
|
||||||
|
as input state, focus, text selection etc. which browsers have already been
|
||||||
|
optimizing for decades.
|
||||||
|
|
||||||
|
As seen in [3.2.1.](#321-mount-functions), rendering is therefore split into
|
||||||
|
some rigid base HTML and an idempotent, complete update function which only
|
||||||
|
makes necessary changes.
|
||||||
|
|
||||||
|
- **Idempotency** is key here, i.e. update functions may be called at any time
|
||||||
|
and should always render the component correctly.
|
||||||
|
- **Completeness** is equally important, i.e. update functions should render
|
||||||
|
the whole component, regardless of what triggered an update.
|
||||||
|
|
||||||
|
In effect, this means almost all DOM manipulation is done in update functions,
|
||||||
|
which greatly contributes to robustness and readability of the codebase.
|
||||||
|
|
||||||
|
As seen above this approach is quite verbose and ugly compared to JSX, for
|
||||||
|
example. However, it's very performant and can be further optimized
|
||||||
|
by checking for data changes, caching selectors, etc.
|
||||||
|
It is also simple to understand.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- [TodoItem.js](./public/scripts/TodoItem.js)
|
||||||
|
- [TodoCustomList.js](./public/scripts/TodoCustomList.js)
|
||||||
|
|
||||||
|
#### 3.2.4. Reconciliation
|
||||||
|
|
||||||
|
Expectedly, the hardest part of the study was rendering a variable
|
||||||
|
amount of dynamic components efficiently. Here's a commented example
|
||||||
|
from the implementation outlining the reconciliation algorithm:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.TodoList = function (el) {
|
||||||
|
var state = {
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
el.innerHTML = '<div class="items"></div>';
|
||||||
|
|
||||||
|
function update(next) {
|
||||||
|
Object.assign(state, next);
|
||||||
|
|
||||||
|
var container = el.querySelector('.items');
|
||||||
|
|
||||||
|
// mark current children for removal
|
||||||
|
var obsolete = new Set(container.children);
|
||||||
|
|
||||||
|
// map current children by data-key
|
||||||
|
var childrenByKey = new Map();
|
||||||
|
|
||||||
|
obsolete.forEach(function (child) {
|
||||||
|
childrenByKey.set(child.getAttribute('data-key'), child);
|
||||||
|
});
|
||||||
|
|
||||||
|
// build new list of child elements from data
|
||||||
|
var children = state.items.map(function (item) {
|
||||||
|
// find existing child by data-key
|
||||||
|
var child = childrenByKey.get(item.id);
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
// if child exists, keep it
|
||||||
|
obsolete.delete(child);
|
||||||
|
} else {
|
||||||
|
// otherwise, create new child
|
||||||
|
child = document.createElement('div');
|
||||||
|
child.classList.add('todo-item');
|
||||||
|
|
||||||
|
// set data-key
|
||||||
|
child.setAttribute('data-key', item.id);
|
||||||
|
|
||||||
|
// mount component
|
||||||
|
VT.TodoItem(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update child
|
||||||
|
child.todoItem.update({ item: item });
|
||||||
|
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
// remove obsolete children
|
||||||
|
obsolete.forEach(function (child) {
|
||||||
|
container.removeChild(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (re-)insert new list of children
|
||||||
|
children.forEach(function (child, index) {
|
||||||
|
if (child !== container.children[index]) {
|
||||||
|
container.insertBefore(child, container.children[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
el.todoList = {
|
||||||
|
update: update,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
It's very verbose and has lots of opportunity to introduce bugs.
|
||||||
|
Compared to a simple loop in JSX, this seems insane.
|
||||||
|
It is quite performant as it does minimal work but is otherwise messy;
|
||||||
|
definitely a candidate for a utility function or library.
|
||||||
|
|
||||||
|
### 3.3. Drag & Drop
|
||||||
|
|
||||||
|
Implementing drag & drop from scratch was challenging,
|
||||||
|
especially regarding browser/device consistency.
|
||||||
|
|
||||||
|
Using a library would have been a lot more cost-effective initially.
|
||||||
|
However, having a customized implementation paid off once I started
|
||||||
|
introducing animations as both had to be coordinated closely.
|
||||||
|
I can imagine this would have been a difficult problem
|
||||||
|
when using third party code for either.
|
||||||
|
|
||||||
|
The drag & drop implementation is (again) based on DOM events and integrates
|
||||||
|
well with the remaining architecture.
|
||||||
|
It's clearly the most complex part of the study but I was able to implement it
|
||||||
|
without changing existing code besides mounting behaviors and
|
||||||
|
adding event handlers.
|
||||||
|
|
||||||
|
I suspect the drag & drop implementation to have some subtle problems on
|
||||||
|
touch devices, as I haven't extensively tested them. Using a library for
|
||||||
|
identifying the gestures could be more sensible and would reduce costs in
|
||||||
|
testing browsers and devices.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- [AppDraggable.js](./public/scripts/AppDraggable.js)
|
||||||
|
- [AppSortable.js](./public/scripts/AppSortable.js)
|
||||||
|
- [TodoList.js](./public/scripts/TodoList.js)
|
||||||
|
|
||||||
|
### 3.4. Animations
|
||||||
|
|
||||||
|
For the final product I wanted smooth animations for most user interactions.
|
||||||
|
This is a cross-cutting concern which was implemented using the
|
||||||
|
[FLIP](https://aerotwist.com/blog/flip-your-animations/) technique as devised
|
||||||
|
by [Paul Lewis](https://twitter.com/aerotwist).
|
||||||
|
|
||||||
|
Implementing FLIP animations without a large refactoring was the biggest
|
||||||
|
challenge of this case study, especially in combination with drag & drop.
|
||||||
|
After days of work I was able to implement the algorithm in isolation and
|
||||||
|
coordinate it with other concerns at the application's root level.
|
||||||
|
The `useCapture` mode of `addEventListener` proved to be very useful
|
||||||
|
in this case.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
|
||||||
|
- [AppFlip.js](./public/scripts/AppFlip.js)
|
||||||
|
- [TodoApp.js](./public/scripts/TodoApp.js)
|
||||||
|
|
||||||
|
## 4. Testing
|
||||||
|
|
||||||
|
_TODO_
|
||||||
|
|
||||||
|
## 5. Assessment
|
||||||
|
|
||||||
|
### 5.1. User Experience
|
||||||
|
|
||||||
|
Most important features from the original TeuxDeux application are implemented
|
||||||
|
and usable:
|
||||||
|
|
||||||
|
- Daily to-do lists
|
||||||
|
- Add/edit/delete to-do items
|
||||||
|
- Custom to-do lists
|
||||||
|
- Add/edit/delete custom to-do lists
|
||||||
|
- Drag & drop to-do items across lists
|
||||||
|
- Reorder custom to-do lists via drag & drop
|
||||||
|
- Local Storage persistence
|
||||||
|
|
||||||
|
Additionally, most interactions are smoothly animated at 60 frames per second.
|
||||||
|
In particular, dragging and dropping gives proper visual feedback
|
||||||
|
when elements are reordered.
|
||||||
|
|
||||||
|
_The latter was an improvement over the original application when I started
|
||||||
|
working on the case study some weeks ago. In the meantime, the TeuxDeux
|
||||||
|
team released an update with a much better drag & drop experience. Great job!_
|
||||||
|
|
||||||
|
One notable missing feature is Markdown support. It would be insensible
|
||||||
|
to implement Markdown from scratch; this is a valid candidate for using
|
||||||
|
an external library as it is entirely orthogonal to the remaining codebase.
|
||||||
|
|
||||||
|
The application has been tested on latest Chrome, Firefox, Safari,
|
||||||
|
and Safari on iOS.
|
||||||
|
|
||||||
|
_TODO Test more browsers and devices._
|
||||||
|
|
||||||
|
A fresh load of the original TeuxDeux application transfers around **435 KB** and
|
||||||
|
finishes loading at around **1000 ms**, sometimes up to 2000ms
|
||||||
|
(measured on 10/21 2020).
|
||||||
|
Reloads finish at around **500ms**.
|
||||||
|
|
||||||
|
With a transferred size of around **44 KB**, the vanilla application consistently
|
||||||
|
loads in **300-500 ms**—not minified and with each script, stylesheet and icon
|
||||||
|
served as an individual file. Reloads finish at **100-200ms**; again, not
|
||||||
|
optimized at all (with e.g. asset hashing/indefinite caching).
|
||||||
|
|
||||||
|
_To be fair, my implementation misses quite a few features from the original.
|
||||||
|
I suspect a fully equivalent clone to be well below 100 KB transfer, though._
|
||||||
|
|
||||||
|
_TODO Run more formal performance tests and add figures for the results._
|
||||||
|
|
||||||
|
### 5.2. Code Quality
|
||||||
|
|
||||||
|
Unfortunately, it is quite hard to find undisputed, objective measurements
|
||||||
|
for code quality (besides trivialities like code style, linting, etc.).
|
||||||
|
The only generally accepted assessment seems to be peer reviewal.
|
||||||
|
|
||||||
|
To have at least some degree of assessment of the code's quality,
|
||||||
|
the following sections summarize relevant facts about the codebase
|
||||||
|
and some opinionated statements based on my experience in the industry.
|
||||||
|
|
||||||
|
#### 5.2.1. The Good
|
||||||
|
|
||||||
|
- No build steps
|
||||||
|
- No external dependencies at runtime besides polyfills
|
||||||
|
- No dependency maintenance
|
||||||
|
- No breaking changes to monitor
|
||||||
|
- Used only standard technologies:
|
||||||
|
- Plain HTML, CSS and JavaScript
|
||||||
|
- Standard DOM APIs
|
||||||
|
- Very few concepts introduced:
|
||||||
|
- Mount functions (loosely mapped by CSS class names)
|
||||||
|
- State separated from the DOM
|
||||||
|
- Idempotent updates
|
||||||
|
- Data flow using custom events
|
||||||
|
- Compare the proposed architecture to the API/conceptual surface of Angular or React...
|
||||||
|
- Progressive developer experience
|
||||||
|
- Markup, style, and behavior are orthogonal and can be developed separately.
|
||||||
|
- Adding behavior has little impact on the markup besides adding classes.
|
||||||
|
- Debugging is straight-forward using modern browser developer tools.
|
||||||
|
- The app can be naturally enhanced from the outside by handling/dispatching
|
||||||
|
events (just like you can naturally animate some existing HTML).
|
||||||
|
- Little indirection
|
||||||
|
- Low coupling
|
||||||
|
- The result is literally just a bunch of HTML, CSS, and JS files.
|
||||||
|
|
||||||
|
All source files (HTML, CSS and JS) combine to **under 2500 lines of code**,
|
||||||
|
including comments and empty lines.
|
||||||
|
|
||||||
|
For comparison, prettifying the original TeuxDeux's minified JS application
|
||||||
|
bundle yields **48787 LOC** (10/21 2020).
|
||||||
|
|
||||||
|
_To be fair, my implementation misses quite a few features from the original.
|
||||||
|
I suspect a fully equivalent clone to be well below 10000 LOC, though._
|
||||||
|
|
||||||
|
#### 5.2.2. The Verbose
|
||||||
|
|
||||||
|
- Stylesheets are a bit verbose. SCSS would help here.
|
||||||
|
- Simple components require quite some boilerplate code.
|
||||||
|
- Writing HTML templates as an array of lines is ugly (and sub-optimal).
|
||||||
|
- ES5 is generally a lot more verbose than ES6.
|
||||||
|
- Especially arrow functions, template literals,
|
||||||
|
and async/await would make the code more readable.
|
||||||
|
- ES6 modules would eliminate the need for a global namespace.
|
||||||
|
- `el.querySelectorAll(':scope ...')` is somewhat default/expected and
|
||||||
|
would justify a helper.
|
||||||
|
- Listening to and dispatching events is slightly verbose.
|
||||||
|
- Although not used in this study,
|
||||||
|
event delegation is not trivial to implement without code duplication.
|
||||||
|
|
||||||
|
Eliminating verbosities through build steps and a minimal set of helpers
|
||||||
|
would reduce the comparably low code size (see above) even further.
|
||||||
|
|
||||||
|
#### 5.2.3. The Bad
|
||||||
|
|
||||||
|
- The separation between base HTML and dynamic rendering is not ideal
|
||||||
|
when compared to JSX, for example.
|
||||||
|
- JSX/virtual DOM techniques provide much better development ergonomics.
|
||||||
|
- Reconciliation is verbose, brittle and repetitive.
|
||||||
|
I wouldn't recommend the proposed technique
|
||||||
|
without a well-tested helper function, at least.
|
||||||
|
- You have to remember mounting behaviors correctly when
|
||||||
|
creating new elements. It would be helpful to automate this somehow,
|
||||||
|
e.g. watch elements of selector X (at all times) and ensure the desired
|
||||||
|
behaviors are mounted once on them.
|
||||||
|
- No type safety. I've always been a proponent of dynamic languages
|
||||||
|
but since TypeScripts' type system provides the best of both worlds,
|
||||||
|
I cannot recommend using it enough.
|
||||||
|
- We're effectively locked out of using NPM dependencies that don't provide
|
||||||
|
browser builds as we cannot use CommonJS or ES6 modules.
|
||||||
|
- Most frameworks handle a lot of browser inconsistencies **for free** and
|
||||||
|
continuously monitor regressions with extensive test suites.
|
||||||
|
The cost of browser testing is surely a lot higher
|
||||||
|
when using a vanilla approach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Besides the issues described above, I believe the codebase is well organized
|
||||||
|
and there are clear paths for bugfixes and feature development.
|
||||||
|
Since there's no third party code, bugs are easy to find and fix,
|
||||||
|
and there are no dependency limitations to work around.
|
||||||
|
|
||||||
|
A certain degree of DOM API knowledge is required but I believe this
|
||||||
|
should be a goal for any web developer.
|
||||||
|
|
||||||
|
### 5.3. Generality of Patterns
|
||||||
|
|
||||||
|
Assessing the generality of the discovered techniques objectively is
|
||||||
|
not really possible without production usage. From my experience, however,
|
||||||
|
I can't imagine any scenario where mount functions, event-based data flow etc.
|
||||||
|
are not applicable. The underlying principles power the established frameworks,
|
||||||
|
after all:
|
||||||
|
|
||||||
|
- State is separated from the DOM (React, Angular, Vue).
|
||||||
|
- Rendering is idempotent and complete (React's pure `render` function).
|
||||||
|
- One-way data flow (React)
|
||||||
|
|
||||||
|
## 6. Conclusion
|
||||||
|
|
||||||
|
The result of this study is a working todo application with decent UI/UX and
|
||||||
|
most of the functionality of the original TeuxDeux app,
|
||||||
|
built using only standard web technologies.
|
||||||
|
It comes with better overall performance
|
||||||
|
at a fraction of the code size and bandwidth.
|
||||||
|
|
||||||
|
The codebase seems manageable through a handful of simple concepts,
|
||||||
|
although it is quite verbose and even messy in some areas.
|
||||||
|
This could be mitigated by a small number of helper functions and
|
||||||
|
simple build steps (e.g. SCSS and TypeScript).
|
||||||
|
|
||||||
|
The study's method helped discovering patterns and techniques that
|
||||||
|
are at least on par with a framework-based approach for the given subject,
|
||||||
|
without diverging into building a custom framework.
|
||||||
|
|
||||||
|
A notable exception to the latter is rendering variable numbers of elements
|
||||||
|
in a concise way. I was unable to eliminate the verbosity involved
|
||||||
|
in basic but efficient reconciliation.
|
||||||
|
Further research is needed in this area, but for now this appears to be
|
||||||
|
a valid candidate for a (possibly external) general-purpose utility.
|
||||||
|
|
||||||
|
When looking at the downsides, remember that all of the individual parts are
|
||||||
|
self-contained, highly decoupled, portable, and congruent to the web platform.
|
||||||
|
The resulting implementation cannot "rust", by definition, as no dependencies
|
||||||
|
can become out of date.
|
||||||
|
|
||||||
|
Another thought to be taken with a grain of salt: I believe frameworks
|
||||||
|
make simple tasks even simpler, but hard tasks (e.g. implementing cross-cutting
|
||||||
|
concerns or performance optimizations) often more difficult.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Setting some constraints up-front forced me to challenge
|
||||||
|
my assumptions and preconceptions about vanilla web development.
|
||||||
|
It was quite liberating to avoid general-purpose utilities and
|
||||||
|
get things done with what's readily available.
|
||||||
|
|
||||||
|
As detailed in the assessment,
|
||||||
|
the study would likely be more convincing if build steps were allowed.
|
||||||
|
Modern JavaScript and SCSS could reduce most of
|
||||||
|
the unnecessarily verbose parts to a minimum.
|
||||||
|
|
||||||
|
Finally, this case study does not question using dependencies or frameworks
|
||||||
|
in general—they do provide lots of value in many areas.
|
||||||
|
It was a constrained experiment designed to discover novel methods
|
||||||
|
for vanilla web development and, hopefully,
|
||||||
|
inspire innovation and further research in the area.
|
||||||
|
|
||||||
|
## 7. What's Next?
|
||||||
|
|
||||||
|
I'd love to hear feedback and ideas on any aspect of the case study.
|
||||||
|
It's still lacking in some important areas, e.g. testing techniques.
|
||||||
|
|
||||||
|
Pull requests, questions, and bug reports are more than welcome!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Here are a few ideas I'd like to see explored in the future:
|
||||||
|
|
||||||
|
- Run another case study with TypeScript, SCSS, and build steps (seems promising).
|
||||||
|
- Research validation rules for utility functions and external dependencies.
|
||||||
|
- Experiment with architectures based on virtual DOM rendering and standard DOM events.
|
||||||
|
- Compile discovered rules, patterns and techniques into a comprehensive guide.
|
||||||
|
|
||||||
|
Case studies constrained by a set of formal rules are an effective way to find
|
||||||
|
new patterns and techniques in a wide range of domains.
|
||||||
|
I'd love to see similar experiments in the future.
|
||||||
|
|
||||||
|
## 8. Appendix
|
||||||
|
|
||||||
|
### 8.1. Links
|
||||||
|
|
||||||
|
General resources I've used extensively:
|
||||||
|
|
||||||
|
- [MDN Web Docs](https://developer.mozilla.org) as a reference for DOM APIs
|
||||||
|
- [Can I use...](https://caniuse.com) as a reference for browser support
|
||||||
|
- [React](https://reactjs.org) as inspiration for the architecture
|
||||||
|
|
||||||
|
Useful articles regarding FLIP animations:
|
||||||
|
|
||||||
|
- [FLIP Your Animations (aerotwist.com)](https://aerotwist.com/blog/flip-your-animations)
|
||||||
|
- [Animating Layouts with the FLIP Technique (css-tricks.com)](https://css-tricks.com/animating-layouts-with-the-flip-technique)
|
||||||
|
- [Animating the Unanimatable (medium.com)](https://medium.com/developers-writing/animating-the-unanimatable-1346a5aab3cd)
|
||||||
|
|
||||||
|
Projects I've inspected for drag & drop architecture:
|
||||||
|
|
||||||
|
- [React DnD](https://github.com/react-dnd/react-dnd/)
|
||||||
|
- [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd)
|
||||||
|
- [dragula](https://github.com/bevacqua/dragula)
|
||||||
|
|
||||||
|
### 8.2. Response
|
||||||
|
|
||||||
|
#### 10/2020
|
||||||
|
|
||||||
|
- Trending on [Hacker News](https://news.ycombinator.com/item?id=24893247)
|
||||||
|
- [Lobsters](https://lobste.rs/s/5gcrxh/case_study_on_vanilla_web_development)
|
||||||
|
- [@desandro (Twitter)](https://twitter.com/desandro/status/1321095247091433473)
|
||||||
|
(developer for the original TeuxDeux)
|
||||||
|
- [Reddit](https://www.reddit.com/r/javascript/comments/jj10k9/vanillatodo_a_case_study_on_viable_techniques_for/)
|
||||||
|
|
||||||
|
Thanks!
|
||||||
|
|
||||||
|
## 9. Changelog
|
||||||
|
|
||||||
|
### 01/2021
|
||||||
|
|
||||||
|
- Added [response section](#82-response)
|
||||||
|
|
||||||
|
### 10/2020
|
||||||
|
|
||||||
|
- Refactored for `dataset` [#2](https://github.com/morris/vanilla-todo/issues/2) —
|
||||||
|
[@opethrocks](https://github.com/opethrocks)
|
||||||
|
- Fixed [#3](https://github.com/morris/vanilla-todo/issues/3) (navigation bug) —
|
||||||
|
[@anchepiece](https://github.com/anchepiece),
|
||||||
|
[@jcoussard](https://github.com/jcoussard)
|
||||||
|
- Fixed [#4](https://github.com/morris/vanilla-todo/issues/4) (double item creation) —
|
||||||
|
[@n0nick](https://github.com/n0nick)
|
||||||
|
- Fixed [#1](https://github.com/morris/vanilla-todo/issues/4) (bad links) —
|
||||||
|
[@roryokane](https://github.com/roryokane)
|
||||||
|
- Initial version.
|
53
es5/public/index.html
Normal file
53
es5/public/index.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<title>VANILLA TODO</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="styles/base.css" />
|
||||||
|
<link rel="stylesheet" href="styles/app-button.css" />
|
||||||
|
<link rel="stylesheet" href="styles/app-collapsible.css" />
|
||||||
|
<link rel="stylesheet" href="styles/app-footer.css" />
|
||||||
|
<link rel="stylesheet" href="styles/app-header.css" />
|
||||||
|
<link rel="stylesheet" href="styles/app-icon.css" />
|
||||||
|
<link rel="stylesheet" href="styles/todo-custom-list.css" />
|
||||||
|
<link rel="stylesheet" href="styles/todo-day.css" />
|
||||||
|
<link rel="stylesheet" href="styles/todo-frame.css" />
|
||||||
|
<link rel="stylesheet" href="styles/todo-item-input.css" />
|
||||||
|
<link rel="stylesheet" href="styles/todo-item.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="todo-app"></div>
|
||||||
|
|
||||||
|
<script
|
||||||
|
nomodule
|
||||||
|
crossorigin
|
||||||
|
src="https://polyfill.io/v3/polyfill.min.js?features=Set%2CMap%2CObject.assign%2Cfetch%2CrequestAnimationFrame%2CNodeList.prototype.forEach%2CElement.prototype.classList%2Cperformance.now%2CNode.prototype.contains%2CElement.prototype.dataset"
|
||||||
|
></script>
|
||||||
|
<script src="scripts/AppCollapsible.js"></script>
|
||||||
|
<script src="scripts/AppDraggable.js"></script>
|
||||||
|
<script src="scripts/AppFlip.js"></script>
|
||||||
|
<script src="scripts/AppFps.js"></script>
|
||||||
|
<script src="scripts/AppIcon.js"></script>
|
||||||
|
<script src="scripts/AppSortable.js"></script>
|
||||||
|
<script src="scripts/TodoApp.js"></script>
|
||||||
|
<script src="scripts/TodoCustomList.js"></script>
|
||||||
|
<script src="scripts/TodoDay.js"></script>
|
||||||
|
<script src="scripts/TodoFrameCustom.js"></script>
|
||||||
|
<script src="scripts/TodoFrameDays.js"></script>
|
||||||
|
<script src="scripts/TodoItemInput.js"></script>
|
||||||
|
<script src="scripts/TodoItem.js"></script>
|
||||||
|
<script src="scripts/TodoList.js"></script>
|
||||||
|
<script src="scripts/TodoStore.js"></script>
|
||||||
|
<script src="scripts/util.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
VT.TodoApp(document.querySelector('.todo-app'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
29
es5/public/scripts/AppCollapsible.js
Normal file
29
es5/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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
349
es5/public/scripts/AppDraggable.js
Normal file
349
es5/public/scripts/AppDraggable.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/* 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 startTime;
|
||||||
|
var dragging = false;
|
||||||
|
var clicked = false;
|
||||||
|
var data;
|
||||||
|
var image;
|
||||||
|
var imageSource;
|
||||||
|
var imageX, imageY;
|
||||||
|
var currentTarget;
|
||||||
|
|
||||||
|
el.addEventListener('touchstart', start);
|
||||||
|
el.addEventListener('mousedown', 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;
|
||||||
|
if (e.touches && e.touches.length > 1) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var p = getPositionHost(e);
|
||||||
|
clientX = originX = p.clientX || p.pageX;
|
||||||
|
clientY = originY = p.clientY || p.pageY;
|
||||||
|
startTime = Date.now();
|
||||||
|
|
||||||
|
startListening();
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var p = getPositionHost(e);
|
||||||
|
clientX = p.clientX || p.pageX;
|
||||||
|
clientY = p.clientY || p.pageY;
|
||||||
|
|
||||||
|
if (dragging) {
|
||||||
|
dispatchDrag();
|
||||||
|
dispatchTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deltaX = clientX - originX;
|
||||||
|
var deltaY = clientY - originY;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < dragThreshold && Math.abs(deltaY) < dragThreshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent unintentional dragging on touch devices
|
||||||
|
if (e.touches && Date.now() - startTime < 50) {
|
||||||
|
stopListening();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging = true;
|
||||||
|
data = {};
|
||||||
|
|
||||||
|
dispatchStart();
|
||||||
|
dispatchDrag();
|
||||||
|
dispatchTarget();
|
||||||
|
dispatchOverContinuously();
|
||||||
|
}
|
||||||
|
|
||||||
|
function end(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!dragging) {
|
||||||
|
e.target.click();
|
||||||
|
clicked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopListening();
|
||||||
|
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
clicked = false;
|
||||||
|
|
||||||
|
if (dragging) {
|
||||||
|
dispatchTarget();
|
||||||
|
dispatchEnd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startListening() {
|
||||||
|
el.addEventListener('touchmove', move);
|
||||||
|
el.addEventListener('touchend', end);
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopListening() {
|
||||||
|
el.removeEventListener('touchmove', move);
|
||||||
|
el.removeEventListener('touchend', end);
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', end);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
function dispatchStart() {
|
||||||
|
setImage(el);
|
||||||
|
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('draggableStart', {
|
||||||
|
detail: buildDetail(),
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchDrag() {
|
||||||
|
image.dispatchEvent(
|
||||||
|
new CustomEvent('draggableDrag', {
|
||||||
|
detail: buildDetail(),
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchTarget() {
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
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 dispatchOverContinuously() {
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
dispatchOver();
|
||||||
|
setTimeout(dispatchOver, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchOver() {
|
||||||
|
if (currentTarget) {
|
||||||
|
currentTarget.dispatchEvent(
|
||||||
|
new CustomEvent('draggableOver', {
|
||||||
|
detail: buildDetail(),
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(dispatchOver, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
es5/public/scripts/AppFlip.js
Normal file
197
es5/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.dataset.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.dataset.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 = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
40
es5/public/scripts/AppFps.js
Normal file
40
es5/public/scripts/AppFps.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.AppFps = function (el) {
|
||||||
|
var sampleSize = 20;
|
||||||
|
var times = [];
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
times.push(performance.now());
|
||||||
|
|
||||||
|
if (times.length <= sampleSize) return;
|
||||||
|
|
||||||
|
var min = Infinity;
|
||||||
|
var max = 0;
|
||||||
|
var sum = 0;
|
||||||
|
|
||||||
|
for (var i = 1; i < sampleSize + 1; ++i) {
|
||||||
|
var delta = times[i] - times[i - 1];
|
||||||
|
min = Math.min(min, delta);
|
||||||
|
max = Math.max(max, delta);
|
||||||
|
sum += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fps = (sampleSize / sum) * 1000;
|
||||||
|
|
||||||
|
el.innerText =
|
||||||
|
fps.toFixed(0) +
|
||||||
|
' fps (' +
|
||||||
|
min.toFixed(0) +
|
||||||
|
' ms - ' +
|
||||||
|
max.toFixed(0) +
|
||||||
|
' ms)';
|
||||||
|
|
||||||
|
times = [];
|
||||||
|
}
|
||||||
|
};
|
24
es5/public/scripts/AppIcon.js
Normal file
24
es5/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.dataset.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 = {};
|
146
es5/public/scripts/AppSortable.js
Normal file
146
es5/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.dataset.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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
135
es5/public/scripts/TodoApp.js
Normal file
135
es5/public/scripts/TodoApp.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.TodoApp = function (el) {
|
||||||
|
var state = {
|
||||||
|
items: [],
|
||||||
|
customLists: [],
|
||||||
|
at: VT.formatDateId(new Date()),
|
||||||
|
customAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
el.innerHTML = [
|
||||||
|
'<header class="app-header">',
|
||||||
|
' <h1 class="title">VANILLA TODO</h1>',
|
||||||
|
' <p class="app-fps"></p>',
|
||||||
|
'</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);
|
||||||
|
el.querySelectorAll('.app-fps').forEach(VT.AppFps);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// dispatch "focusOther" .use-focus-other inputs if they are not active
|
||||||
|
// ensures only one edit input is active
|
||||||
|
el.addEventListener('focusin', function (e) {
|
||||||
|
if (!e.target.classList.contains('use-focus-other')) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.use-focus-other').forEach(function (el) {
|
||||||
|
if (el === e.target) return;
|
||||||
|
el.dispatchEvent(new CustomEvent('focusOther'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
148
es5/public/scripts/TodoCustomList.js
Normal file
148
es5/public/scripts/TodoCustomList.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.TodoCustomList = function (el) {
|
||||||
|
var state = {
|
||||||
|
list: null,
|
||||||
|
editing: false,
|
||||||
|
};
|
||||||
|
var startEditing = false;
|
||||||
|
var saveOnBlur = true;
|
||||||
|
|
||||||
|
el.innerHTML = [
|
||||||
|
'<div class="header">',
|
||||||
|
' <h3 class="title"></h3>',
|
||||||
|
' <p class="form">',
|
||||||
|
' <input type="text" class="input use-focus-other">',
|
||||||
|
' <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.TodoList(el.querySelector('.todo-list'));
|
||||||
|
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||||
|
|
||||||
|
titleEl.addEventListener('click', function () {
|
||||||
|
startEditing = true;
|
||||||
|
update({ editing: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteEl.addEventListener('touchstart', function () {
|
||||||
|
saveOnBlur = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteEl.addEventListener('mousedown', function () {
|
||||||
|
saveOnBlur = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('blur', function () {
|
||||||
|
if (saveOnBlur) save();
|
||||||
|
saveOnBlur = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('focusOther', function () {
|
||||||
|
if (state.editing) save();
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('keyup', function (e) {
|
||||||
|
switch (e.keyCode) {
|
||||||
|
case 13: // enter
|
||||||
|
save();
|
||||||
|
break;
|
||||||
|
case 27: // escape
|
||||||
|
cancelEdit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 save() {
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('saveList', {
|
||||||
|
detail: { list: state.list, title: inputEl.value.trim() },
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
update({ editing: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
saveOnBlur = false;
|
||||||
|
update({ editing: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(next) {
|
||||||
|
Object.assign(state, next);
|
||||||
|
|
||||||
|
titleEl.innerText = state.list.title || '...';
|
||||||
|
|
||||||
|
el.querySelector('.todo-list').todoList.update({ items: state.list.items });
|
||||||
|
el.querySelector('.todo-list > .todo-item-input').dataset.key =
|
||||||
|
'todo-item-input' + state.list.id;
|
||||||
|
|
||||||
|
el.classList.toggle('-editing', state.editing);
|
||||||
|
|
||||||
|
if (state.editing && startEditing) {
|
||||||
|
inputEl.value = state.list.title;
|
||||||
|
inputEl.focus();
|
||||||
|
inputEl.select();
|
||||||
|
startEditing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
51
es5/public/scripts/TodoDay.js
Normal file
51
es5/public/scripts/TodoDay.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.TodoDay = function (el) {
|
||||||
|
var state = {
|
||||||
|
dateId: el.dataset.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 });
|
||||||
|
}
|
||||||
|
};
|
166
es5/public/scripts/TodoFrameCustom.js
Normal file
166
es5/public/scripts/TodoFrameCustom.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
updatePositions();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 childrenByKey = new Map();
|
||||||
|
|
||||||
|
obsolete.forEach(function (child) {
|
||||||
|
childrenByKey.set(child.dataset.key, child);
|
||||||
|
});
|
||||||
|
|
||||||
|
var children = lists.map(function (list) {
|
||||||
|
var child = childrenByKey.get(list.id);
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
obsolete.delete(child);
|
||||||
|
} else {
|
||||||
|
child = document.createElement('div');
|
||||||
|
child.className = 'card todo-custom-list';
|
||||||
|
child.dataset.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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePositions();
|
||||||
|
updateHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositions() {
|
||||||
|
el.querySelectorAll('.container > *').forEach(function (child, index) {
|
||||||
|
child.style.transform = 'translateX(' + (index - state.at) * 100 + '%)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeight() {
|
||||||
|
var height = 280;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
138
es5/public/scripts/TodoFrameDays.js
Normal file
138
es5/public/scripts/TodoFrameDays.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/* 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 childrenByKey = new Map();
|
||||||
|
|
||||||
|
obsolete.forEach(function (child) {
|
||||||
|
childrenByKey.set(child.dataset.key, child);
|
||||||
|
});
|
||||||
|
|
||||||
|
var children = days.map(function (day) {
|
||||||
|
var child = childrenByKey.get(day.id);
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
obsolete.delete(child);
|
||||||
|
} else {
|
||||||
|
child = document.createElement('div');
|
||||||
|
child.className = 'card todo-day';
|
||||||
|
child.dataset.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 = 280;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
152
es5/public/scripts/TodoItem.js
Normal file
152
es5/public/scripts/TodoItem.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.TodoItem = function (el) {
|
||||||
|
var state = {
|
||||||
|
item: null,
|
||||||
|
editing: false,
|
||||||
|
};
|
||||||
|
var startEditing = false;
|
||||||
|
var saveOnBlur = true;
|
||||||
|
|
||||||
|
el.innerHTML = [
|
||||||
|
'<div class="checkbox">',
|
||||||
|
' <input type="checkbox">',
|
||||||
|
'</div>',
|
||||||
|
'<p class="label"></p>',
|
||||||
|
'<p class="form">',
|
||||||
|
' <input type="text" class="input use-focus-other">',
|
||||||
|
' <button class="app-button save"><i class="app-icon" data-id="check-16"></i></button>',
|
||||||
|
'</p>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
var checkboxEl = el.querySelector('.checkbox');
|
||||||
|
var labelEl = el.querySelector('.label');
|
||||||
|
var inputEl = el.querySelector('.input');
|
||||||
|
var saveEl = el.querySelector('.save');
|
||||||
|
|
||||||
|
VT.AppDraggable(el, {
|
||||||
|
dropSelector: '.todo-list > .items',
|
||||||
|
});
|
||||||
|
|
||||||
|
el.querySelectorAll('.app-icon').forEach(VT.AppIcon);
|
||||||
|
|
||||||
|
checkboxEl.addEventListener('touchstart', function () {
|
||||||
|
saveOnBlur = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkboxEl.addEventListener('mousedown', function () {
|
||||||
|
saveOnBlur = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkboxEl.addEventListener('click', function () {
|
||||||
|
if (state.editing) save();
|
||||||
|
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('checkItem', {
|
||||||
|
detail: {
|
||||||
|
item: state.item,
|
||||||
|
done: !state.item.done,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
labelEl.addEventListener('click', function () {
|
||||||
|
startEditing = true;
|
||||||
|
update({ editing: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('keyup', function (e) {
|
||||||
|
switch (e.keyCode) {
|
||||||
|
case 13: // enter
|
||||||
|
save();
|
||||||
|
break;
|
||||||
|
case 27: // escape
|
||||||
|
cancelEdit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('blur', function () {
|
||||||
|
if (saveOnBlur) save();
|
||||||
|
saveOnBlur = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('focusOther', function () {
|
||||||
|
if (state.editing) save();
|
||||||
|
});
|
||||||
|
|
||||||
|
saveEl.addEventListener('mousedown', function () {
|
||||||
|
saveOnBlur = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
saveEl.addEventListener('click', save);
|
||||||
|
|
||||||
|
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 === '') {
|
||||||
|
// deferred deletion prevents a bug at reconciliation in TodoList:
|
||||||
|
// Failed to execute 'removeChild' on 'Node': The node to be removed is
|
||||||
|
// no longer a child of this node. Perhaps it was moved in a 'blur'
|
||||||
|
// event handler?
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
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 cancelEdit() {
|
||||||
|
saveOnBlur = false;
|
||||||
|
update({ editing: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(next) {
|
||||||
|
// TODO optimize
|
||||||
|
Object.assign(state, next);
|
||||||
|
|
||||||
|
el.classList.toggle('-done', state.item.done);
|
||||||
|
checkboxEl.querySelector('input').checked = state.item.done;
|
||||||
|
labelEl.innerText = state.item.label;
|
||||||
|
|
||||||
|
el.classList.toggle('-editing', state.editing);
|
||||||
|
el.classList.toggle('_nodrag', state.editing);
|
||||||
|
|
||||||
|
if (state.editing && startEditing) {
|
||||||
|
inputEl.value = state.item.label;
|
||||||
|
inputEl.focus();
|
||||||
|
inputEl.select();
|
||||||
|
startEditing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
63
es5/public/scripts/TodoItemInput.js
Normal file
63
es5/public/scripts/TodoItemInput.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/* global VT */
|
||||||
|
window.VT = window.VT || {};
|
||||||
|
|
||||||
|
VT.TodoItemInput = function (el) {
|
||||||
|
var saveOnBlur = true;
|
||||||
|
|
||||||
|
el.innerHTML = [
|
||||||
|
'<input type="text" class="input use-focus-other">',
|
||||||
|
'<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);
|
||||||
|
|
||||||
|
inputEl.addEventListener('keyup', function (e) {
|
||||||
|
switch (e.keyCode) {
|
||||||
|
case 13: // enter
|
||||||
|
save();
|
||||||
|
break;
|
||||||
|
case 27: // escape
|
||||||
|
clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('blur', function () {
|
||||||
|
if (saveOnBlur) save();
|
||||||
|
saveOnBlur = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('focusOther', save);
|
||||||
|
|
||||||
|
saveEl.addEventListener('mousedown', function () {
|
||||||
|
saveOnBlur = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
saveEl.addEventListener('click', function () {
|
||||||
|
save();
|
||||||
|
inputEl.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var label = inputEl.value.trim();
|
||||||
|
|
||||||
|
if (label === '') return;
|
||||||
|
|
||||||
|
inputEl.value = '';
|
||||||
|
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('addItem', {
|
||||||
|
detail: { label: label },
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
inputEl.value = '';
|
||||||
|
inputEl.blur();
|
||||||
|
}
|
||||||
|
};
|
71
es5/public/scripts/TodoList.js
Normal file
71
es5/public/scripts/TodoList.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/* 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 childrenByKey = new Map();
|
||||||
|
|
||||||
|
obsolete.forEach(function (child) {
|
||||||
|
childrenByKey.set(child.dataset.key, child);
|
||||||
|
});
|
||||||
|
|
||||||
|
var children = state.items.map(function (item) {
|
||||||
|
var child = childrenByKey.get(item.id);
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
obsolete.delete(child);
|
||||||
|
} else {
|
||||||
|
child = document.createElement('div');
|
||||||
|
child.classList.add('todo-item');
|
||||||
|
child.dataset.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,
|
||||||
|
};
|
||||||
|
};
|
198
es5/public/scripts/TodoStore.js
Normal file
198
es5/public/scripts/TodoStore.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/* 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({ items: state.items });
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('checkItem', function (e) {
|
||||||
|
if (e.detail.item.done === e.detail.done) return;
|
||||||
|
|
||||||
|
e.detail.item.done = e.detail.done;
|
||||||
|
dispatch({ items: state.items });
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('saveItem', function (e) {
|
||||||
|
if (e.detail.item.label === e.detail.label) return;
|
||||||
|
|
||||||
|
e.detail.item.label = e.detail.label;
|
||||||
|
dispatch({ 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({ items: state.items });
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('deleteItem', function (e) {
|
||||||
|
dispatch({
|
||||||
|
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 || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({ 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;
|
||||||
|
|
||||||
|
dispatch({ 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({ customLists: state.customLists });
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('deleteList', function (e) {
|
||||||
|
dispatch({
|
||||||
|
customLists: state.customLists.filter(function (customList) {
|
||||||
|
return customList.id !== e.detail.id;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('seek', function (e) {
|
||||||
|
var t = new Date(state.at + ' 00:00:00');
|
||||||
|
t.setDate(t.getDate() + e.detail);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
at: VT.formatDateId(t),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('seekHome', function () {
|
||||||
|
dispatch({
|
||||||
|
at: VT.formatDateId(new Date()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('customSeek', function (e) {
|
||||||
|
dispatch({
|
||||||
|
customAt: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(state.customLists.length - 1, state.customAt + e.detail)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function dispatch(next) {
|
||||||
|
Object.assign(state, next);
|
||||||
|
store();
|
||||||
|
|
||||||
|
el.dispatchEvent(
|
||||||
|
new CustomEvent('todoData', {
|
||||||
|
detail: state,
|
||||||
|
bubbles: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (!localStorage || !localStorage.todo) {
|
||||||
|
dispatch(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dispatch(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 = {
|
||||||
|
dispatch: dispatch,
|
||||||
|
load: load,
|
||||||
|
};
|
||||||
|
};
|
82
es5/public/scripts/util.js
Normal file
82
es5/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()];
|
||||||
|
};
|
45
es5/public/styles/app-button.css
Normal file
45
es5/public/styles/app-button.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
.app-button {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1em;
|
||||||
|
background: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25em;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.1s ease-out;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button:active {
|
||||||
|
transform: translate(0, 1px);
|
||||||
|
color: #000;
|
||||||
|
background: #f3f3f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button:focus {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button.-circle {
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button.-xl {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.app-button.-xl {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
16
es5/public/styles/app-collapsible.css
Normal file
16
es5/public/styles/app-collapsible.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.app-collapsible > .bar {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 37px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.75em;
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-collapsible > .bar > .app-button:active {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-collapsible > .body {
|
||||||
|
transition: height 0.2s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
14
es5/public/styles/app-footer.css
Normal file
14
es5/public/styles/app-footer.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.app-footer {
|
||||||
|
border-top: solid 1px #ccc;
|
||||||
|
padding: 2em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
20
es5/public/styles/app-header.css
Normal file
20
es5/public/styles/app-header.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.app-header {
|
||||||
|
background: #001f3f;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header > .title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header > .app-fps {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 20px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
21
es5/public/styles/app-icon.css
Normal file
21
es5/public/styles/app-icon.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.app-icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon > svg {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: bottom;
|
||||||
|
fill: currentColor;
|
||||||
|
transition: transform 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon.-r180 > svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon.-double > svg:nth-child(2) {
|
||||||
|
margin-left: -0.5em;
|
||||||
|
}
|
22
es5/public/styles/base.css
Normal file
22
es5/public/styles/base.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Helvetica, 'Helvetica Neue',
|
||||||
|
Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif;
|
||||||
|
}
|
64
es5/public/styles/todo-custom-list.css
Normal file
64
es5/public/styles/todo-custom-list.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
.todo-custom-list {
|
||||||
|
padding: 0 0.5em;
|
||||||
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out,
|
||||||
|
box-shadow 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list > .header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list > .header > .title {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list > .header > .form {
|
||||||
|
display: none;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list > .header > .form > .input {
|
||||||
|
width: 70%;
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: normal;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list > .header > .form > .delete {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5em;
|
||||||
|
top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list.-editing > .header > .title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list.-editing > .header > .form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list.-dragging {
|
||||||
|
box-shadow: 10px 0 12px -14px rgba(0, 0, 0, 0.3),
|
||||||
|
-10px 0 12px -14px rgba(0, 0, 0, 0.3);
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-custom-list.-placeholder {
|
||||||
|
transition: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
34
es5/public/styles/todo-day.css
Normal file
34
es5/public/styles/todo-day.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
.todo-day {
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-day > .header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-day > .header > .dayofweek {
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 0.25em 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-day > .header > .date {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0.25em 0 0 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-day.-past {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-day.-today > .header > .dayofweek {
|
||||||
|
color: #85144b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-day.-today > .header > .date {
|
||||||
|
color: #000;
|
||||||
|
}
|
87
es5/public/styles/todo-frame.css
Normal file
87
es5/public/styles/todo-frame.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
.todo-frame {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .leftcontrols,
|
||||||
|
.todo-frame > .rightcontrols {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 52px;
|
||||||
|
padding: 1.5em 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .leftcontrols {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .rightcontrols {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .leftcontrols > p,
|
||||||
|
.todo-frame > .rightcontrols > p {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .container {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
top: 0;
|
||||||
|
right: 52px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .container > .card {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame.-animated {
|
||||||
|
transition: height 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.todo-frame > .container {
|
||||||
|
right: 70px;
|
||||||
|
left: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .leftcontrols,
|
||||||
|
.todo-frame > .rightcontrols {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-frame > .container > .card {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.todo-frame > .container > .card {
|
||||||
|
width: 33.333%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.todo-frame > .container > .card {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.todo-frame > .container > .card {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
}
|
26
es5/public/styles/todo-item-input.css
Normal file
26
es5/public/styles/todo-item-input.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.todo-item-input {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 0 30px;
|
||||||
|
padding: 0 30px 0 0;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
transition: transform 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-input > .input {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.25em 0;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-input > .save {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.15em;
|
||||||
|
right: 0;
|
||||||
|
}
|
85
es5/public/styles/todo-item.css
Normal file
85
es5/public/styles/todo-item.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
.todo-item {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25em 0;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > .checkbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 2em;
|
||||||
|
line-height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > .checkbox > input {
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > .label {
|
||||||
|
margin: 0 0 0 30px;
|
||||||
|
padding-bottom: 0.25em;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > .form {
|
||||||
|
display: none;
|
||||||
|
margin: 0 0 0 30px;
|
||||||
|
padding-right: 24px;
|
||||||
|
border-bottom: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > .form > .input {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0 0 0.25em 0;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1.5em;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item > .form > .save {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.15em;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.-done > .label {
|
||||||
|
color: #ccc;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.-editing > .label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.-editing > .form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.-dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.-placeholder {
|
||||||
|
transition: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
Reference in New Issue
Block a user