Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
5902 lines
150 KiB
JavaScript
5902 lines
150 KiB
JavaScript
define('views/current_time',['require','exports','module','shared/intl_helper','view','common/calc','time_observer'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var IntlHelper = require('shared/intl_helper');
|
|
var View = require('view');
|
|
var createDay = require('common/calc').createDay;
|
|
var timeObserver = require('time_observer');
|
|
|
|
var activeClass = View.ACTIVE;
|
|
|
|
IntlHelper.define('current-time', 'mozdatetime', {
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
dayperiod: false,
|
|
});
|
|
|
|
function CurrentTime(options) {
|
|
this._container = options.container;
|
|
// timespan can be injected later! this is just a convenience
|
|
this.timespan = options.timespan;
|
|
this._sticky = options.sticky;
|
|
this.activate = this.activate.bind(this);
|
|
this._tick = this._tick.bind(this);
|
|
}
|
|
module.exports = CurrentTime;
|
|
|
|
CurrentTime.prototype = {
|
|
_create: function() {
|
|
if (this.element) {
|
|
return;
|
|
}
|
|
|
|
this.element = document.createElement('div');
|
|
this.element.setAttribute('aria-hidden', true);
|
|
this.element.classList.add('md__current-time');
|
|
this._container.appendChild(this.element);
|
|
},
|
|
|
|
refresh: function() {
|
|
this._clearInterval();
|
|
|
|
if (this._previousOverlap) {
|
|
this._previousOverlap.classList.remove('is-hidden');
|
|
}
|
|
|
|
this._unmarkCurrentDay();
|
|
this._hide();
|
|
this.activate();
|
|
},
|
|
|
|
activate: function() {
|
|
if (!this.timespan.containsNumeric(Date.now())) {
|
|
this._maybeActivateInTheFuture();
|
|
return;
|
|
}
|
|
|
|
this._create();
|
|
this.element.classList.add(activeClass);
|
|
this._tick();
|
|
},
|
|
|
|
_maybeActivateInTheFuture: function() {
|
|
var now = Date.now();
|
|
var diff = this.timespan.start - now;
|
|
if (diff >= 0) {
|
|
// if timespan is in the "future" we make sure it will start rendering
|
|
// the current time as soon as it reaches 00:00:00 of the first day
|
|
// inside timespan (eg. current time is 2014-05-22T23:59:50 and user is
|
|
// viewing 2014-05-23 until past midnight)
|
|
this._clearInterval();
|
|
timeObserver.once('day', this.activate);
|
|
}
|
|
},
|
|
|
|
deactivate: function() {
|
|
this._clearInterval();
|
|
this._hide();
|
|
},
|
|
|
|
_hide: function() {
|
|
if (this.element) {
|
|
this.element.classList.remove(activeClass);
|
|
}
|
|
},
|
|
|
|
destroy: function() {
|
|
this.deactivate();
|
|
if (this.element) {
|
|
this._container.removeChild(this.element);
|
|
}
|
|
},
|
|
|
|
_clearInterval: function() {
|
|
timeObserver.off('day', this.activate);
|
|
timeObserver.off('minute', this._tick);
|
|
},
|
|
|
|
_tick: function() {
|
|
this._clearInterval();
|
|
if (!this.timespan.contains(new Date())) {
|
|
this.deactivate();
|
|
this._unmarkCurrentDay();
|
|
return;
|
|
}
|
|
this._render();
|
|
timeObserver.once('minute', this._tick);
|
|
},
|
|
|
|
_render: function() {
|
|
var now = new Date();
|
|
|
|
var formatter = IntlHelper.get('current-time');
|
|
this.element.textContent = formatter.format(now);
|
|
|
|
this.element.dataset.date = now;
|
|
this.element.dataset.l10nDateFormat = 'current-time';
|
|
this.element.id = 'current-time-indicator';
|
|
|
|
var hour = now.getHours();
|
|
var elapsedMinutes = (hour * 60) + now.getMinutes();
|
|
var totalMinutes = 24 * 60;
|
|
var percentage = ((elapsedMinutes / totalMinutes) * 100);
|
|
// we limit the position between 0.5-99.5% to avoid cropping the text
|
|
this.element.style.top = Math.max(Math.min(percentage, 99.5), 0.5) + '%';
|
|
|
|
this._checkOverlap(hour);
|
|
this._markCurrentDay(now);
|
|
},
|
|
|
|
_checkOverlap: function(hour) {
|
|
// we only need to check the current hour (with current design there is
|
|
// no way to overlap previous/next hours)
|
|
var displayHour = this._container.querySelector(
|
|
`.md__hour-${hour} .md__display-hour`
|
|
);
|
|
|
|
displayHour.classList.toggle('is-hidden', this._intersect(displayHour));
|
|
|
|
// just in case last time it checked was against a different hour
|
|
if (this._previousOverlap && this._previousOverlap !== displayHour) {
|
|
this._previousOverlap.classList.remove('is-hidden');
|
|
}
|
|
|
|
this._previousOverlap = displayHour;
|
|
},
|
|
|
|
_intersect: function(displayHour) {
|
|
var b1 = this.element.getBoundingClientRect();
|
|
var b2 = displayHour.getBoundingClientRect();
|
|
|
|
return (
|
|
b1.left <= b2.right &&
|
|
b2.left <= b1.right &&
|
|
b1.top <= b2.bottom &&
|
|
b2.top <= b1.bottom
|
|
);
|
|
},
|
|
|
|
_markCurrentDay: function(date) {
|
|
if (!this._sticky) {
|
|
return;
|
|
}
|
|
|
|
var day = createDay(date);
|
|
var selector = `.md__allday[data-date="${day}"] .md__day-name`;
|
|
var header = this._sticky.querySelector(selector);
|
|
|
|
if (header) {
|
|
header.classList.add('is-today');
|
|
}
|
|
|
|
if (this._previousHeader !== header) {
|
|
this._unmarkCurrentDay();
|
|
}
|
|
|
|
this._previousHeader = header;
|
|
},
|
|
|
|
_unmarkCurrentDay: function() {
|
|
if (this._previousHeader) {
|
|
this._previousHeader.classList.remove('is-today');
|
|
}
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
/* global Buffer */
|
|
define('querystring',['require','exports','module'],function(require, exports) {
|
|
'use strict';
|
|
|
|
// Copyright Joyent, Inc. and other Node contributors.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
// copy of this software and associated documentation files (the
|
|
// "Software"), to deal in the Software without restriction, including
|
|
// without limitation the rights to use, copy, modify, merge, publish,
|
|
// distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
// persons to whom the Software is furnished to do so, subject to the
|
|
// following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included
|
|
// in all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
|
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
// USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
// If obj.hasOwnProperty has been overridden, then calling
|
|
// obj.hasOwnProperty(prop) will break.
|
|
// See: https://github.com/joyent/node/issues/1707
|
|
function hasOwnProperty(obj, prop) {
|
|
return Object.prototype.hasOwnProperty.call(obj, prop);
|
|
}
|
|
|
|
|
|
function charCode(c) {
|
|
return c.charCodeAt(0);
|
|
}
|
|
|
|
|
|
// a safe fast alternative to decodeURIComponent
|
|
exports.unescapeBuffer = function(s, decodeSpaces) {
|
|
var out = new Buffer(s.length);
|
|
var state = 'CHAR'; // states: CHAR, HEX0, HEX1
|
|
var n, m, hexchar;
|
|
|
|
for (var inIndex = 0, outIndex = 0; inIndex <= s.length; inIndex++) {
|
|
var c = s.charCodeAt(inIndex);
|
|
switch (state) {
|
|
case 'CHAR':
|
|
switch (c) {
|
|
case charCode('%'):
|
|
n = 0;
|
|
m = 0;
|
|
state = 'HEX0';
|
|
break;
|
|
case charCode('+'):
|
|
if (decodeSpaces) {
|
|
c = charCode(' ');
|
|
}
|
|
out[outIndex++] = c;
|
|
break;
|
|
default:
|
|
out[outIndex++] = c;
|
|
}
|
|
break;
|
|
|
|
case 'HEX0':
|
|
state = 'HEX1';
|
|
hexchar = c;
|
|
if (charCode('0') <= c && c <= charCode('9')) {
|
|
n = c - charCode('0');
|
|
} else if (charCode('a') <= c && c <= charCode('f')) {
|
|
n = c - charCode('a') + 10;
|
|
} else if (charCode('A') <= c && c <= charCode('F')) {
|
|
n = c - charCode('A') + 10;
|
|
} else {
|
|
out[outIndex++] = charCode('%');
|
|
out[outIndex++] = c;
|
|
state = 'CHAR';
|
|
break;
|
|
}
|
|
break;
|
|
|
|
case 'HEX1':
|
|
state = 'CHAR';
|
|
if (charCode('0') <= c && c <= charCode('9')) {
|
|
m = c - charCode('0');
|
|
} else if (charCode('a') <= c && c <= charCode('f')) {
|
|
m = c - charCode('a') + 10;
|
|
} else if (charCode('A') <= c && c <= charCode('F')) {
|
|
m = c - charCode('A') + 10;
|
|
} else {
|
|
out[outIndex++] = charCode('%');
|
|
out[outIndex++] = hexchar;
|
|
out[outIndex++] = c;
|
|
break;
|
|
}
|
|
out[outIndex++] = 16 * n + m;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// TODO support returning arbitrary buffers.
|
|
|
|
return out.slice(0, outIndex - 1);
|
|
};
|
|
|
|
|
|
exports.unescape = function(s, decodeSpaces) {
|
|
return exports.unescapeBuffer(s, decodeSpaces).toString();
|
|
};
|
|
|
|
|
|
exports.escape = function(str) {
|
|
return encodeURIComponent(str);
|
|
};
|
|
|
|
var stringifyPrimitive = function(v) {
|
|
switch (typeof v) {
|
|
case 'string':
|
|
return v;
|
|
|
|
case 'boolean':
|
|
return v ? 'true' : 'false';
|
|
|
|
case 'number':
|
|
return isFinite(v) ? v : '';
|
|
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
|
|
exports.stringify = exports.encode = function(obj, sep, eq, name) {
|
|
sep = sep || '&';
|
|
eq = eq || '=';
|
|
if (obj === null) {
|
|
obj = undefined;
|
|
}
|
|
|
|
if (typeof obj === 'object') {
|
|
return Object.keys(obj).map(function(k) {
|
|
var ks = exports.escape(stringifyPrimitive(k)) + eq;
|
|
if (Array.isArray(obj[k])) {
|
|
return obj[k].map(function(v) {
|
|
return ks + exports.escape(stringifyPrimitive(v));
|
|
}).join(sep);
|
|
} else {
|
|
return ks + exports.escape(stringifyPrimitive(obj[k]));
|
|
}
|
|
}).join(sep);
|
|
|
|
}
|
|
|
|
if (!name) {
|
|
return '';
|
|
}
|
|
return exports.escape(stringifyPrimitive(name)) + eq +
|
|
exports.escape(stringifyPrimitive(obj));
|
|
};
|
|
|
|
// Parse a key=val string.
|
|
exports.parse = exports.decode = function(qs, sep, eq, options) {
|
|
sep = sep || '&';
|
|
eq = eq || '=';
|
|
var obj = {};
|
|
|
|
if (typeof qs !== 'string' || qs.length === 0) {
|
|
return obj;
|
|
}
|
|
|
|
var regexp = /\+/g;
|
|
qs = qs.split(sep);
|
|
|
|
var maxKeys = 1000;
|
|
if (options && typeof options.maxKeys === 'number') {
|
|
maxKeys = options.maxKeys;
|
|
}
|
|
|
|
var len = qs.length;
|
|
// maxKeys <= 0 means that we should not limit keys count
|
|
if (maxKeys > 0 && len > maxKeys) {
|
|
len = maxKeys;
|
|
}
|
|
|
|
for (var i = 0; i < len; ++i) {
|
|
var x = qs[i].replace(regexp, '%20'),
|
|
idx = x.indexOf(eq),
|
|
kstr, vstr, k, v;
|
|
|
|
if (idx >= 0) {
|
|
kstr = x.substr(0, idx);
|
|
vstr = x.substr(idx + 1);
|
|
} else {
|
|
kstr = x;
|
|
vstr = '';
|
|
}
|
|
|
|
try {
|
|
k = decodeURIComponent(kstr);
|
|
v = decodeURIComponent(vstr);
|
|
} catch (e) {
|
|
k = exports.unescape(kstr, true);
|
|
v = exports.unescape(vstr, true);
|
|
}
|
|
|
|
if (!hasOwnProperty(obj, k)) {
|
|
obj[k] = v;
|
|
} else if (Array.isArray(obj[k])) {
|
|
obj[k].push(v);
|
|
} else {
|
|
obj[k] = [obj[k], v];
|
|
}
|
|
}
|
|
|
|
return obj;
|
|
};
|
|
|
|
});
|
|
|
|
define('utils/dom',['require','exports','module'],function(require, exports) {
|
|
'use strict';
|
|
|
|
/**
|
|
* Gets the element absolute offset top relative to the window top.
|
|
*/
|
|
exports.absoluteOffsetTop = function(el) {
|
|
var top = 0;
|
|
while (el) {
|
|
var pos = window.getComputedStyle(el).position;
|
|
if (pos === 'absolute' || pos === 'relative') {
|
|
top += el.offsetTop;
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
return top;
|
|
};
|
|
|
|
/**
|
|
* Gets the closest (parent) element that matches the given selector.
|
|
*/
|
|
exports.closest = function(el, selector) {
|
|
while (el) {
|
|
if (matches(el, selector)) {
|
|
return el;
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
};
|
|
|
|
function matches(el, selector) {
|
|
// "matches" is only unprefixed on Fx 34
|
|
return 'matches' in el ?
|
|
el.matches(selector) :
|
|
el.mozMatchesSelector(selector);
|
|
}
|
|
|
|
/**
|
|
* converts HTML string into a DOM element
|
|
*/
|
|
exports.buildElement = function(html) {
|
|
var fragment = document.createElement('div');
|
|
fragment.innerHTML = html;
|
|
return fragment.firstChild;
|
|
};
|
|
|
|
/**
|
|
* detach elements from DOM
|
|
* supports single element or array-like objects
|
|
*/
|
|
exports.removeElements = function(els) {
|
|
if (!els) {
|
|
return;
|
|
}
|
|
els = 'length' in els ? Array.from(els) : Array.from(arguments);
|
|
els.forEach(el => {
|
|
el && el.parentNode && el.parentNode.removeChild(el);
|
|
});
|
|
};
|
|
|
|
});
|
|
|
|
define('views/hour_double_tap',['require','exports','module','querystring','utils/dom','utils/dom','common/calc'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
// this will be replaced later for a better logic (see Bug 992728) but it was
|
|
// too much to do in a single patch, so for now we do a simple double tap
|
|
// without any visual feedback (similar to the old day view behavior)
|
|
|
|
var QueryString = require('querystring');
|
|
var absoluteOffsetTop = require('utils/dom').absoluteOffsetTop;
|
|
var closest = require('utils/dom').closest;
|
|
var createDay = require('common/calc').createDay;
|
|
|
|
function HourDoubleTap(options) {
|
|
this.main = options.main;
|
|
this.daysHolder = options.daysHolder;
|
|
this.alldaysHolder = options.alldaysHolder;
|
|
this.hourHeight = options.hourHeight;
|
|
|
|
this._onDayTap = this._onDayTap.bind(this);
|
|
this._onAllDayTap = this._onAllDayTap.bind(this);
|
|
this.removeAddEventLink = this.removeAddEventLink.bind(this);
|
|
}
|
|
module.exports = HourDoubleTap;
|
|
|
|
HourDoubleTap.prototype = {
|
|
|
|
_isActive: false,
|
|
|
|
_addEventLink: null,
|
|
|
|
setup: function() {
|
|
this._mainOffset = absoluteOffsetTop(this.main);
|
|
this.daysHolder.addEventListener('click', this._onDayTap);
|
|
this.alldaysHolder.addEventListener('click', this._onAllDayTap);
|
|
},
|
|
|
|
destroy: function() {
|
|
this.removeAddEventLink();
|
|
this.daysHolder.removeEventListener('click', this._onDayTap);
|
|
this.alldaysHolder.removeEventListener('click', this._onAllDayTap);
|
|
},
|
|
|
|
/**
|
|
* If the event target is an existing event, the method finds whether the tap
|
|
* was _really_ on the existing event, to account for possible event
|
|
* retargeting that's not desirable in that case.
|
|
*
|
|
* @param {MouseEvent} evt This is the event to check.
|
|
* @returns {Boolean} True if we tapped on a child of a day.
|
|
*/
|
|
_isEventRightOnEventLink(evt) {
|
|
var target = evt.target;
|
|
if (!target.classList.contains('md__day')) {
|
|
// Could be aggressive event fuzzing registering a tap on an existing
|
|
// event or the "add event" UI, where the tap is really below that item.
|
|
// See bug 1210201.
|
|
|
|
// In this case, offsetY is the vertical space between the top of the
|
|
// target element and the actual tap, so if the tap is bigger than the
|
|
// height of the element, or smaller than 0, the tap really happened out
|
|
// of the element.
|
|
var tappedOnExistingEvent =
|
|
// intentionally keeping retargeting behavior for the `add` button.
|
|
target.classList.contains('md__add-event') ||
|
|
target.classList.contains('md__event') &&
|
|
evt.offsetY >= 0 &&
|
|
evt.offsetY <= target.getBoundingClientRect().height;
|
|
|
|
if (tappedOnExistingEvent) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_onDayTap: function(evt) {
|
|
if (this._isEventRightOnEventLink(evt)) {
|
|
return;
|
|
} else {
|
|
// Having this in the `else` block is a hint to the reader that we could
|
|
// remove it if the event retargeting gets smarter and we don't need this
|
|
// `if` anymore.
|
|
|
|
// It works because the event handler for the <a> is actually on `window`
|
|
// and checks if the event was defaultPrevented. Check js/ext/page.js.
|
|
|
|
// Until Bug 1091889 gets fixed we'll still get the :active
|
|
// styles because they're being applied with `touchstart` and we can't
|
|
// defaultPrevent this event without breaking everything.
|
|
evt.preventDefault();
|
|
}
|
|
|
|
var target = evt.target.closest('.md__day');
|
|
|
|
var y = evt.clientY + this.main.scrollTop - this._mainOffset;
|
|
var hour = Math.floor(y / this.hourHeight);
|
|
var baseDate = new Date(target.dataset.date);
|
|
|
|
this._onTap(target, {
|
|
startDate: addHours(baseDate, hour).toString(),
|
|
endDate: addHours(baseDate, hour + 1).toString()
|
|
}, hour);
|
|
},
|
|
|
|
_onAllDayTap: function(evt) {
|
|
var target = evt.target;
|
|
if (!target.classList.contains('md__allday-events')) {
|
|
return;
|
|
}
|
|
|
|
var startDate = new Date(closest(target, '.md__allday').dataset.date);
|
|
|
|
this._onTap(target, {
|
|
isAllDay: true,
|
|
startDate: startDate.toString(),
|
|
endDate: createDay(startDate, startDate.getDate() + 1).toString()
|
|
}, null, evt.mozInputSource);
|
|
},
|
|
|
|
_onTap: function(container, data, hour, source) {
|
|
hour = hour || 0;
|
|
|
|
if (this._addEventLink) {
|
|
this.removeAddEventLink();
|
|
return;
|
|
}
|
|
|
|
var link = document.createElement('a');
|
|
link.href = '/event/add/?' + QueryString.stringify(data);
|
|
link.className = 'md__add-event gaia-icon icon-newadd';
|
|
link.dataset.l10nId = 'multi-day-new-event-link';
|
|
link.style.top = (hour * this.hourHeight) + 'px';
|
|
link.style.opacity = 0;
|
|
|
|
link.addEventListener('click', this.removeAddEventLink);
|
|
|
|
container.appendChild(link);
|
|
this._addEventLink = link;
|
|
|
|
// Initiated by a screen reader on double tap.
|
|
if (source === 0) {
|
|
link.click();
|
|
return;
|
|
}
|
|
|
|
// opacity will trigger transition, needs to happen after nextTick
|
|
setTimeout(() => {
|
|
this._addEventLink && (this._addEventLink.style.opacity = 1);
|
|
});
|
|
},
|
|
|
|
removeAddEventLink: function() {
|
|
var link = this._addEventLink;
|
|
if (!link) {
|
|
return;
|
|
}
|
|
|
|
link.removeEventListener('click', this.removeAddEventLink);
|
|
|
|
link.addEventListener('transitionend', function onTransitionEnd() {
|
|
link.removeEventListener('transitionend', onTransitionEnd);
|
|
link.parentNode && link.parentNode.removeChild(link);
|
|
});
|
|
link.style.opacity = 0;
|
|
|
|
this._addEventLink = null;
|
|
}
|
|
|
|
};
|
|
|
|
function addHours(date, hourDiff) {
|
|
var result = new Date(date);
|
|
result.setHours(result.getHours() + hourDiff);
|
|
return result;
|
|
}
|
|
|
|
});
|
|
|
|
// this module is responsible for the touch/panning of MultiDay views
|
|
define('views/pan',['require','exports','module','ext/eventemitter2','utils/mout','utils/mout','utils/mout'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var EventEmitter2 = require('ext/eventemitter2');
|
|
var clamp = require('utils/mout').clamp;
|
|
var lerp = require('utils/mout').lerp;
|
|
var norm = require('utils/mout').norm;
|
|
|
|
function Pan(options) {
|
|
EventEmitter2.call(this);
|
|
|
|
this.eventTarget = options.eventTarget;
|
|
this.targets = options.targets;
|
|
this.overflows = options.overflows || [];
|
|
|
|
this._gridSize = Math.max(options.gridSize || 0, 1);
|
|
this._visibleCells = Math.max(options.visibleCells || 0, 1);
|
|
this._startMouseX = this._startMouseY = 0;
|
|
this._isVertical = false;
|
|
this._startTime = 0;
|
|
this._touchStart = 0;
|
|
this._dx = 0;
|
|
|
|
// _lockedAxis is used to control if we detected if the movement is
|
|
// vertical/horizontal, very important for ignoring clicks and also to be
|
|
// able to set a threshold for the axis detection
|
|
this._lockedAxis = false;
|
|
|
|
this._onTouchStart = this._onTouchStart.bind(this);
|
|
this._onTouchMove = this._onTouchMove.bind(this);
|
|
this._onTouchEnd = this._onTouchEnd.bind(this);
|
|
this._tick = this._tick.bind(this);
|
|
this._setBaseValues = this._setBaseValues.bind(this);
|
|
this._onTweenEnd = null;
|
|
}
|
|
module.exports = Pan;
|
|
|
|
Pan.prototype = {
|
|
__proto__: EventEmitter2.prototype,
|
|
|
|
TRANSITION_DURATION: 800,
|
|
|
|
setup: function() {
|
|
var element = this.eventTarget;
|
|
element.addEventListener('touchstart', this._onTouchStart);
|
|
element.addEventListener('touchmove', this._onTouchMove);
|
|
element.addEventListener('touchend', this._onTouchEnd);
|
|
element.addEventListener('touchcancel', this._onTouchEnd);
|
|
window.addEventListener('localized', this._setBaseValues);
|
|
this._setBaseValues();
|
|
},
|
|
|
|
_setBaseValues: function() {
|
|
var delta = this._gridSize * this._visibleCells;
|
|
|
|
if (document.documentElement.dir === 'rtl') {
|
|
this._dir = -1;
|
|
this._minX = 0;
|
|
this._maxX = delta * 2;
|
|
} else {
|
|
this._dir = 1;
|
|
this._minX = delta * -2;
|
|
this._maxX = 0;
|
|
}
|
|
|
|
this._origX = this._startX = this._curX = this._destX = delta * -this._dir;
|
|
this._set(this._origX);
|
|
},
|
|
|
|
_onTouchStart: function(evt) {
|
|
this._startMouseX = evt.touches[0].clientX;
|
|
this._startMouseY = evt.touches[0].clientY;
|
|
this._isVertical = false;
|
|
this._lockedAxis = false;
|
|
this._touchStart = Date.now();
|
|
// we need to reset the tween callback because we should only call it
|
|
// once and only if user did not trigger a new touch
|
|
this._onTweenEnd = null;
|
|
},
|
|
|
|
_onTouchMove: function(evt) {
|
|
if (this._isVertical) {
|
|
return;
|
|
}
|
|
|
|
var dx = this._startMouseX - evt.touches[0].clientX;
|
|
var dy = this._startMouseY - evt.touches[0].clientY;
|
|
this._dx = dx;
|
|
|
|
if (!this._lockedAxis) {
|
|
var adx = Math.abs(dx);
|
|
var ady = Math.abs(dy);
|
|
|
|
// we wait until we are sure movement is horizontal before we do anything.
|
|
// if absolute difference between x/y movement is over a threshold (10px)
|
|
// we assume drag follows a single axis.
|
|
if (Math.abs(adx - ady) < 10) {
|
|
return;
|
|
}
|
|
|
|
this._isVertical = adx < ady;
|
|
this._lockedAxis = true;
|
|
|
|
this.emit('start');
|
|
|
|
if (this._isVertical) {
|
|
return;
|
|
}
|
|
|
|
// we should only lock scroll once and only if dragging horizontally
|
|
this._lockScroll();
|
|
}
|
|
|
|
this._updateDestination(this._origX - dx, 0);
|
|
},
|
|
|
|
_lockScroll: function() {
|
|
this.overflows.forEach(el => el.style.overflowY = 'hidden');
|
|
},
|
|
|
|
_unlockScroll: function() {
|
|
this.overflows.forEach(el => el.style.overflowY = 'scroll');
|
|
},
|
|
|
|
_updateDestination: function(x, duration) {
|
|
duration = duration != null ? duration : this.TRANSITION_DURATION;
|
|
|
|
this._startX = this._curX;
|
|
this._destX = clamp(x, this._minX, this._maxX);
|
|
|
|
var now = Date.now();
|
|
this._endTime = now + duration;
|
|
|
|
if (!this._requestId) {
|
|
this._startTime = now;
|
|
this._tween();
|
|
}
|
|
},
|
|
|
|
_tween: function() {
|
|
this._requestId = window.requestAnimationFrame(this._tick);
|
|
},
|
|
|
|
_tick: function() {
|
|
var t = norm(Date.now(), this._startTime, this._endTime);
|
|
|
|
if (t >= 1 || this._curX === this._destX) {
|
|
this._killTween();
|
|
this._set(this._destX);
|
|
return;
|
|
}
|
|
|
|
var x = lerp(ease(t), this._startX, this._destX);
|
|
this._set(x);
|
|
this._tween();
|
|
},
|
|
|
|
_killTween: function() {
|
|
if (this._requestId) {
|
|
window.cancelAnimationFrame(this._requestId);
|
|
this._requestId = null;
|
|
}
|
|
this._onTweenEnd && this._onTweenEnd.call(this);
|
|
},
|
|
|
|
_onTouchEnd: function() {
|
|
// if touch is very fast momentum would be bigger than our threshold,
|
|
// this is very important for click events otherwise they wouldn't open
|
|
// the event details
|
|
if (this._isVertical || !this._lockedAxis) {
|
|
this._unlockScroll();
|
|
return;
|
|
}
|
|
|
|
var duration = Date.now() - this._touchStart;
|
|
var momentum = Math.abs(this._dx) / duration;
|
|
var snap;
|
|
|
|
if (momentum > 0.5) {
|
|
// if the drag was fast we consider it as a swipe (move multiple cells
|
|
// at once)
|
|
var direction = this._dx > 0 ? -1 : 1;
|
|
snap = this._origX + (direction * this._gridSize * this._visibleCells);
|
|
} else {
|
|
// we only round up if very close to the next column, this behavior is
|
|
// better for the user than a regular round/ceil/floor
|
|
snap = Math.round((this._destX / this._gridSize) + 0.2) *
|
|
this._gridSize;
|
|
}
|
|
|
|
// we only unlock the scroll after the tween is complete to make multiple
|
|
// consecutive swipes faster (also avoids flickering y-axis position)
|
|
this._onTweenEnd = this._unlockScroll;
|
|
this._updateDestination(snap);
|
|
|
|
this.emit('release', {
|
|
diff: Math.round((this._origX - this._destX) / this._gridSize) * this._dir
|
|
});
|
|
},
|
|
|
|
_set: function(x) {
|
|
x = clamp(x, this._minX, this._maxX);
|
|
this.targets.forEach(el => {
|
|
el.style.transform = 'translateX(' + x +'px)';
|
|
});
|
|
this._curX = x;
|
|
},
|
|
|
|
refresh: function() {
|
|
var diff = Math.abs(this._curX - this._destX);
|
|
diff *= this._curX < this._destX ? -1 : 1;
|
|
|
|
// we update the position based on the relative distance to keep a smooth
|
|
// transition
|
|
if (diff) {
|
|
this._set(this._origX + diff);
|
|
this._updateDestination(this._origX);
|
|
} else {
|
|
this._set(this._origX);
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
// borrowed from zeh/ztween (expoOut)
|
|
function ease(t) {
|
|
return (t >= 0.999) ? 1 : 1.001 * (-Math.pow(2, -10 * t) + 1);
|
|
}
|
|
|
|
});
|
|
|
|
/**
|
|
* The interval tree is a structure
|
|
* designed and optimized for storing
|
|
* (possibly) overlapping intervals in
|
|
* such a way that we can optimally query them.
|
|
*
|
|
* To store an item in the tree the item
|
|
* must have `START`, `END` and `_id` properties
|
|
* both properties must be numeric and start
|
|
* must always be < then end. ID should be unique
|
|
* to each interval though it should be possible
|
|
* to store multiple intervals with the same
|
|
* start/end times.
|
|
*
|
|
* Trees should be created by providing an
|
|
* array of items sorted by their start
|
|
* times.
|
|
*
|
|
*
|
|
* // _start is a constant see below
|
|
* var list = [
|
|
* { start: 100, end: 200 },
|
|
* { start: 120, end: 150 },
|
|
* ...
|
|
* ];
|
|
*
|
|
* var tree = new Calendar.Node(list);
|
|
*
|
|
*
|
|
* The tree _should-be_ dynamic and you can add and
|
|
* remove items from the tree.
|
|
*
|
|
* For the present the tree will rebuild itself
|
|
* after add/removal before the next query operation.
|
|
*
|
|
*
|
|
* tree.add({ start: 0, end: 50 });
|
|
*
|
|
* // record should be === to record
|
|
* // stored in the tree.
|
|
* tree.remove(savedRecord);
|
|
*
|
|
*
|
|
* TODO: Implement self-balancing and real tree mutations.
|
|
*/
|
|
define('interval_tree',['require','exports','module','common/binsearch','common/compare','common/debug'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var binsearch = require('common/binsearch');
|
|
var compare = require('common/compare');
|
|
var debug = require('common/debug')('interval_tree');
|
|
|
|
const START = '_startDateMS';
|
|
const END = '_endDateMS';
|
|
|
|
function compareObjectStart(a, b) {
|
|
return compare(a[START], b[START]);
|
|
}
|
|
|
|
function compareObjectEnd(a, b) {
|
|
return compare(a[END], b[END]);
|
|
}
|
|
|
|
IntervalTree.compareObjectStart = compareObjectStart;
|
|
IntervalTree.compareObjectEnd = compareObjectEnd;
|
|
|
|
/**
|
|
* Internal function to create an endpoint
|
|
* list. Assumed this is the array to insert
|
|
* endpoint values into.
|
|
*
|
|
* @param {Object} item object with [START] & [END] properties.
|
|
*/
|
|
function buildEndpoints(item) {
|
|
/*jshint validthis:true */
|
|
addOrdered(item[START], this);
|
|
addOrdered(item[END], this);
|
|
}
|
|
|
|
function Node() {
|
|
this.list = [];
|
|
}
|
|
|
|
Node.prototype = {
|
|
|
|
/**
|
|
* Node to the left null or a node.
|
|
*/
|
|
left: null,
|
|
|
|
/**
|
|
* Node to the right null or a node.
|
|
*/
|
|
right: null,
|
|
|
|
/**
|
|
* List of objects that overlap current node.
|
|
*
|
|
* @type Array
|
|
*/
|
|
list: null,
|
|
|
|
/**
|
|
* Center point of this node.
|
|
*/
|
|
median: null,
|
|
|
|
/**
|
|
* Highest value of this node & subnodes.
|
|
*/
|
|
max: null,
|
|
|
|
/**
|
|
* Iterates through matching items.
|
|
* Will be roughly ordered by start
|
|
* time but exact sort order is not
|
|
* guaranteed.
|
|
*
|
|
* @param {Calendar.Timespan} span timespan.
|
|
* @param {Function} callback first argument is matching
|
|
* record second is the node in the
|
|
* tree where record was found.
|
|
*/
|
|
traverse: function(span, fn) {
|
|
if (this.left && (span.start < this.median)) {
|
|
this.left.traverse(span, fn);
|
|
}
|
|
|
|
var i = 0;
|
|
var len = this.list.length;
|
|
var item;
|
|
|
|
for (; i < len; i++) {
|
|
item = this.list[i];
|
|
|
|
if (item[START] > span.end) {
|
|
break;
|
|
}
|
|
|
|
if (span.overlaps(item[START], item[END])) {
|
|
fn(item, this);
|
|
}
|
|
}
|
|
|
|
if (this.right && span.end > this.median) {
|
|
this.right.traverse(span, fn);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Find all overlapping records via a Calendar.Timespan.
|
|
*
|
|
* @param {Calendar.Timespan} span timespan.
|
|
* @return {Array} results sorted by start time.
|
|
*/
|
|
query: function(span) {
|
|
var results = [];
|
|
var seen = Object.create(null);
|
|
|
|
this.traverse(span, function(item) {
|
|
///XXX: we probably want to order
|
|
// these by start time via bin search
|
|
// order by sort at the end.
|
|
if (!seen[item._id]) {
|
|
results.push(item);
|
|
}
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
};
|
|
|
|
IntervalTree.Node = Node;
|
|
|
|
/**
|
|
* Start point for creation of the tree
|
|
* this is optimized for the most balanced tree
|
|
* when possible. The idea is the majority of
|
|
* operations will be read and traversal.
|
|
*
|
|
* NOTE: Currently we discard the tree and
|
|
* mark the object as synced=false after mutations
|
|
* The next time the object is queried the tree is rebuilt.
|
|
*/
|
|
function IntervalTree(list) {
|
|
if (typeof(list) === 'undefined') {
|
|
this.items = [];
|
|
} else {
|
|
this.items = list.concat([]);
|
|
}
|
|
|
|
/**
|
|
* Properties to index by when fields are added.
|
|
*/
|
|
this._indexes = Object.create(null);
|
|
|
|
// method aggregates
|
|
this._indexOnAdd = [];
|
|
this._indexOnRemove = [];
|
|
|
|
this.byId = Object.create(null);
|
|
this.synced = false;
|
|
}
|
|
module.exports = IntervalTree;
|
|
|
|
IntervalTree.prototype = {
|
|
|
|
START: START,
|
|
END: END,
|
|
|
|
build: function() {
|
|
if (!this.synced) {
|
|
this.rootNode = this._nodeFromList(this.items);
|
|
this.synced = true;
|
|
}
|
|
},
|
|
|
|
toArray: function() {
|
|
// create a copy of the items array
|
|
return this.items.concat();
|
|
},
|
|
|
|
_getId: function(item) {
|
|
return item._id;
|
|
},
|
|
|
|
/**
|
|
* Returns all values in the given index.
|
|
*
|
|
* @param {String} property name of index.
|
|
* @param {String} [value] to filter index on (optional).
|
|
* @return {Null|Array}
|
|
*/
|
|
index: function(property, value) {
|
|
var items = this._indexes[property];
|
|
|
|
if (items && value) {
|
|
return items[value];
|
|
}
|
|
|
|
return items;
|
|
},
|
|
|
|
/**
|
|
* Create index on property.
|
|
*
|
|
* @param {String} property to index on.
|
|
*/
|
|
createIndex: function(property) {
|
|
var index = this._indexes[property] = {};
|
|
|
|
// remember this will be invoked later with the context
|
|
// of |this| always...
|
|
function addToIndex(object) {
|
|
var value = object[property];
|
|
|
|
// create array for index possibilities
|
|
if (!index[value]) {
|
|
index[value] = [];
|
|
}
|
|
|
|
// and push single object to index
|
|
index[value].push(object);
|
|
}
|
|
|
|
function removeFromIndex(object) {
|
|
// object given should always be same instance stored.
|
|
var value = object[property];
|
|
var valueGroup = index[value];
|
|
|
|
if (valueGroup) {
|
|
var idx = valueGroup.indexOf(object);
|
|
valueGroup.splice(idx, 1);
|
|
if (valueGroup.length === 0) {
|
|
delete index[value];
|
|
}
|
|
}
|
|
}
|
|
|
|
this._indexOnAdd.push(addToIndex);
|
|
this._indexOnRemove.push(removeFromIndex);
|
|
},
|
|
|
|
/**
|
|
* Adds an item to the tree
|
|
*/
|
|
add: function(item) {
|
|
var id = this._getId(item);
|
|
|
|
if (id in this.byId) {
|
|
return;
|
|
}
|
|
|
|
|
|
if (!item[START] && item.startDate) {
|
|
item[START] = item.startDate.valueOf();
|
|
}
|
|
|
|
if (!item[END] && item.endDate) {
|
|
item[END] = item.endDate.valueOf();
|
|
}
|
|
|
|
if (!item[START] || !item[END]) {
|
|
return debug('Invalid input skipping record: ', item);
|
|
}
|
|
|
|
var idx = binsearch.insert(
|
|
this.items,
|
|
item,
|
|
compareObjectStart
|
|
);
|
|
|
|
this.items.splice(idx, 0, item);
|
|
this.byId[id] = item;
|
|
this.synced = false;
|
|
|
|
var len = this._indexOnAdd.length;
|
|
for (var i = 0; i < len; i++) {
|
|
this._indexOnAdd[i].call(this, item);
|
|
}
|
|
|
|
return item;
|
|
},
|
|
|
|
indexOf: function(item) {
|
|
var query = {};
|
|
query[START] = item[START];
|
|
var idx = binsearch.find(
|
|
this.items,
|
|
query,
|
|
compareObjectStart
|
|
);
|
|
|
|
var prevIdx;
|
|
var current;
|
|
|
|
if (idx !== null) {
|
|
// we want to start idx at earliest
|
|
// point in list that matches start time.
|
|
// When there are multiple start times
|
|
// the binsearch may start us at any point
|
|
// in the range of matching items.
|
|
|
|
|
|
// Iterate backwards.
|
|
if (idx > 0) {
|
|
prevIdx = idx;
|
|
while (prevIdx > -1) {
|
|
prevIdx--;
|
|
current = this.items[prevIdx];
|
|
if (current && current[START] === item[START]) {
|
|
if (current === item) {
|
|
return prevIdx;
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
//Iterate forwards.
|
|
current = this.items[idx];
|
|
while (current) {
|
|
if (current === item) {
|
|
return idx;
|
|
}
|
|
|
|
current = this.items[++idx];
|
|
|
|
if (!current || current[START] !== item[START]) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Removes an item to the list.
|
|
* Must be same === item as as the
|
|
* one you are trying to remove.
|
|
*/
|
|
remove: function(item) {
|
|
|
|
var idx = this.indexOf(item);
|
|
|
|
if (idx !== null) {
|
|
this._removeIds(this.items[idx]);
|
|
|
|
this.items.splice(idx, 1);
|
|
this.synced = false;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_removeIds: function(item) {
|
|
if (Array.isArray(item)) {
|
|
item.forEach(this._removeIds, this);
|
|
} else {
|
|
var len = this._indexOnRemove.length;
|
|
for (var i = 0; i < len; i++) {
|
|
this._indexOnRemove[i].call(this, item);
|
|
}
|
|
|
|
var id = this._getId(item);
|
|
delete this.byId[id];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Remove all intervals that start
|
|
* after a particular time.
|
|
*
|
|
* // assume we have a list of the
|
|
* // following intervals
|
|
* 1-2 4-10 5-10 6-8 8-9
|
|
*
|
|
* tree.removeFutureIntervals(5);
|
|
*
|
|
* // now we have: 1-2, 4-10 5-10
|
|
*
|
|
* @param {Numeric} start last start point.
|
|
*/
|
|
removeFutureIntervals: function(start) {
|
|
var query = {};
|
|
query[START] = start;
|
|
|
|
var idx = binsearch.insert(
|
|
this.items,
|
|
query,
|
|
compareObjectStart
|
|
);
|
|
|
|
var max = this.items.length - 1;
|
|
|
|
if (!this.items[idx]) {
|
|
return;
|
|
}
|
|
|
|
|
|
// for duplicate values we need
|
|
// to find the very last one
|
|
// before the split point.
|
|
while (this.items[idx] && this.items[idx][START] <= start) {
|
|
idx++;
|
|
if (idx === max) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.synced = false;
|
|
var remove = this.items.splice(
|
|
idx, this.items.length - idx
|
|
);
|
|
|
|
this._removeIds(remove);
|
|
return remove;
|
|
},
|
|
|
|
/**
|
|
* Remove all intervals that end
|
|
* before a particular time.
|
|
*
|
|
* For example is you have:
|
|
*
|
|
* // assume we have a list of the
|
|
* // following intervals
|
|
* 1-10, 2-3, 3-4, 4-5
|
|
*
|
|
* tree.removePastIntervals(4);
|
|
*
|
|
* // now we have: 1-10, 4-5
|
|
*
|
|
* @param {Numeric} end last end point.
|
|
*/
|
|
removePastIntervals: function(end) {
|
|
// 1. first re-sort to end dates.
|
|
var items = this.items.sort(compareObjectEnd);
|
|
|
|
// 2. find index of the last date ending
|
|
// on or before end.
|
|
var endQuery = {};
|
|
endQuery[END] = end;
|
|
var idx = binsearch.insert(
|
|
items,
|
|
endQuery,
|
|
compareObjectEnd
|
|
);
|
|
|
|
var max = items.length - 1;
|
|
|
|
if (!items[idx]) {
|
|
return;
|
|
}
|
|
|
|
// for duplicate values we need
|
|
// to find the very last one
|
|
// before the split point.
|
|
while (items[idx][END] <= end) {
|
|
idx++;
|
|
if (idx === max) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.synced = false;
|
|
var remove = items.slice(0, idx);
|
|
this.items = items.slice(idx).sort(
|
|
compareObjectStart
|
|
);
|
|
|
|
this._removeIds(remove);
|
|
|
|
return remove;
|
|
},
|
|
|
|
/**
|
|
* Executes a query on all nodes.
|
|
* Rebuilds tree if in unclean state first.
|
|
*
|
|
* @param {Calendar.Timespan} span timespan.
|
|
*/
|
|
query: function(span) {
|
|
this.build();
|
|
return this.rootNode.query(span);
|
|
},
|
|
|
|
_nodeFromList: function(list) {
|
|
var rootNode = new Node();
|
|
|
|
var left = [];
|
|
var right = [];
|
|
|
|
var median;
|
|
var endpoints = [];
|
|
|
|
//1. build endpoints to calculate median
|
|
// endpoints are the middle value of
|
|
// all start/end points in the current list.
|
|
list.forEach(buildEndpoints, endpoints);
|
|
median = rootNode.median = endpoints[Math.floor(endpoints.length / 2)];
|
|
|
|
list.forEach(function(item) {
|
|
|
|
if (item[END] < median) {
|
|
left.push(item);
|
|
} else if (item[START] > median) {
|
|
right.push(item);
|
|
} else {
|
|
rootNode.list.push(item);
|
|
}
|
|
}, this);
|
|
|
|
// recurse - create left/right nodes.
|
|
if (left.length) {
|
|
rootNode.left = this._nodeFromList(left);
|
|
}
|
|
|
|
if (right.length) {
|
|
rootNode.right = this._nodeFromList(right);
|
|
}
|
|
|
|
return rootNode;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Internal function to add an item
|
|
* to an array via binary search insert.
|
|
* Keeps items in order as they are inserted.
|
|
*
|
|
* @private
|
|
*/
|
|
function addOrdered(item, array) {
|
|
var idx = binsearch.insert(
|
|
array,
|
|
item,
|
|
compare
|
|
);
|
|
|
|
array.splice(idx, 0, item);
|
|
}
|
|
|
|
});
|
|
|
|
/**
|
|
* Representation of conflicts over a span of time, organized into
|
|
* non-overlapping columns tracked by IntervalTree instances.
|
|
*/
|
|
define('conflict_span',['require','exports','module','interval_tree','common/timespan'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var IntervalTree = require('interval_tree');
|
|
var Timespan = require('common/timespan');
|
|
|
|
// Smallest gap interval to use in splitting conflict spans
|
|
var MIN_SPLIT_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// Auto-increment ID for instances
|
|
var _id = 0;
|
|
|
|
function ConflictSpan(parent) {
|
|
this.id = (_id++);
|
|
this.parent = parent;
|
|
this.startTime = null;
|
|
this.endTime = null;
|
|
this.all = new IntervalTree();
|
|
this.columnsByID = {};
|
|
this.columns = [];
|
|
this.addColumn();
|
|
}
|
|
module.exports = ConflictSpan;
|
|
|
|
ConflictSpan.prototype = {
|
|
/**
|
|
* Get a list of all the busytime IDs in this span.
|
|
*
|
|
* @return {Array} List of all the busytime IDs.
|
|
*/
|
|
getIDs: function() {
|
|
return Object.keys(this.all.byId);
|
|
},
|
|
|
|
/**
|
|
* Add a new column tracked by an IntervalTree
|
|
*
|
|
* @return {Object} IntervalTree tracking the column.
|
|
*/
|
|
addColumn: function() {
|
|
var tree = new IntervalTree();
|
|
this.columns.push(tree);
|
|
return tree;
|
|
},
|
|
|
|
/**
|
|
* Find a column where the given busytime fits without conflict, adding a
|
|
* new column if necessary.
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
* @return {Object} IntervalTree column that can accept the busytime.
|
|
*/
|
|
findColumn: function(busytime, skipAdd) {
|
|
var column = null;
|
|
var span = new Timespan(busytime._startDateMS, busytime._endDateMS);
|
|
for (var i = 0; i < this.columns.length; i++) {
|
|
var curr = this.columns[i];
|
|
if (!curr.query(span).length) {
|
|
column = curr;
|
|
break;
|
|
}
|
|
}
|
|
if (!column && !skipAdd) {
|
|
column = this.addColumn();
|
|
}
|
|
return column;
|
|
},
|
|
|
|
/**
|
|
* Add a busytime to the conflict span
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
*/
|
|
add: function(busytime) {
|
|
var id = busytime._id;
|
|
|
|
this.parent.conflicts[id] = this;
|
|
this.all.add(busytime);
|
|
|
|
var column = this.findColumn(busytime);
|
|
column.add(busytime);
|
|
this.columnsByID[id] = column;
|
|
|
|
this._updateTimes(busytime);
|
|
this._updateLayout();
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Remove a busytime from the conflict span
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
* @param {Boolean} skipMaintenance skip post-removal maintenance.
|
|
*/
|
|
remove: function(busytime, skipMaintenance) {
|
|
var id = busytime._id;
|
|
|
|
this.all.remove(busytime);
|
|
var column = this.columnsByID[id];
|
|
if (!column) { return; }
|
|
|
|
column.remove(busytime);
|
|
delete this.columnsByID[id];
|
|
delete this.parent.conflicts[id];
|
|
|
|
// Removing a single item requires maintenance after. But, this can be
|
|
// skipped during a split, which does its own cleanup after multiple
|
|
// removes & adds between spans.
|
|
if (skipMaintenance) { return this; }
|
|
|
|
this._splitIfNecessary();
|
|
var boom = this._selfDestructIfNecessary();
|
|
if (!boom) {
|
|
this._resetTimes();
|
|
this._purgeEmptyColumns();
|
|
this._updateLayout();
|
|
}
|
|
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Absorb the given conflict span into this one
|
|
*
|
|
* @param {Object} ConflictSpan to be absorbed.
|
|
*/
|
|
absorb: function(otherCS) {
|
|
var self = this;
|
|
var otherIDs = otherCS.getIDs();
|
|
otherIDs.forEach(function(otherID) {
|
|
var otherBusytime = self.parent.tree.byId[otherID];
|
|
self.add(otherBusytime);
|
|
// Cheat: skip removing from the other span, since references go away.
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Update the start/end times for this span from a new busytime.
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
*/
|
|
_updateTimes: function(busytime) {
|
|
var start = busytime._startDateMS;
|
|
if (null === this.startTime || start < this.startTime) {
|
|
this.startTime = start;
|
|
}
|
|
var end = busytime._endDateMS;
|
|
if (null === this.endTime || end > this.endTime) {
|
|
this.endTime = end;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reset times with a complete re-scan of all events in the span.
|
|
*/
|
|
_resetTimes: function() {
|
|
this.startTime = this.endTime = null;
|
|
var byId = this.all.byId;
|
|
for (var k in byId) {
|
|
this._updateTimes(byId[k]);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Scan through the events in this span. If a significant gap is found,
|
|
* presumably after a removal, split this span in two.
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
*/
|
|
_splitIfNecessary: function() {
|
|
var start = this.startTime;
|
|
var end = this.endTime;
|
|
|
|
// Scan for the end of the first gap, if any.
|
|
var splitAt = false;
|
|
var prevHits = null;
|
|
for (var top = start; top < end; top += MIN_SPLIT_INTERVAL) {
|
|
var span = new Timespan(top, top + MIN_SPLIT_INTERVAL);
|
|
var hits = this.all.query(span).length;
|
|
if (0 === prevHits && hits > 0) {
|
|
// Transition from empty to non-empty is where we split.
|
|
splitAt = top; break;
|
|
}
|
|
prevHits = hits;
|
|
}
|
|
|
|
// Bail if we never found a gap.
|
|
if (splitAt === false) { return; }
|
|
|
|
// Remove & collect the post-gap items for new split.
|
|
var newItems = [];
|
|
var splitSpan = new Timespan(splitAt, Infinity);
|
|
var splitItems = this.all.query(splitSpan);
|
|
var self = this;
|
|
splitItems.forEach(function(item) {
|
|
self.remove(item, true);
|
|
newItems.push(item);
|
|
});
|
|
|
|
// Perform partial post-removal maintenance
|
|
var boom = this._selfDestructIfNecessary();
|
|
if (!boom) {
|
|
this._resetTimes();
|
|
this._purgeEmptyColumns();
|
|
this._updateLayout();
|
|
}
|
|
|
|
// Bail if there's just one item for new split - no conflict.
|
|
if (newItems.length == 1) {
|
|
this.parent._clearLayout(newItems[0]);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, populate a new span with the conflicting items.
|
|
var newCS = new ConflictSpan(this.parent);
|
|
newItems.forEach(function(item) {
|
|
newCS.add(item);
|
|
});
|
|
|
|
// Finally, recurse into the new span and split further, if necessary.
|
|
newCS._splitIfNecessary();
|
|
},
|
|
|
|
/**
|
|
* If this span has only one event left, then self-destruct because there's
|
|
* no longer a conflict.
|
|
*/
|
|
_selfDestructIfNecessary: function() {
|
|
var keys = this.getIDs();
|
|
if (keys.length > 1) {
|
|
// There's still a conflict, so bail.
|
|
return false;
|
|
}
|
|
if (keys.length == 1) {
|
|
// Exactly one left, so clean up.
|
|
var busytime = this.all.byId[keys[0]];
|
|
this.remove(busytime, true);
|
|
this.parent._clearLayout(busytime);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Purge empty columns from the conflict span.
|
|
*/
|
|
_purgeEmptyColumns: function() {
|
|
var newColumns = [];
|
|
for (var i = 0; i < this.columns.length; i++) {
|
|
var column = this.columns[i];
|
|
if (Object.keys(column.byId).length > 0) {
|
|
newColumns.push(column);
|
|
}
|
|
}
|
|
this.columns = newColumns;
|
|
},
|
|
|
|
/**
|
|
* Update layout for all events participating in this conflict span.
|
|
*/
|
|
_updateLayout: function() {
|
|
var numCols = this.columns.length;
|
|
var width = (100 / numCols);
|
|
for (var cIdx = 0; cIdx < numCols; cIdx++) {
|
|
var column = this.columns[cIdx];
|
|
for (var k in column.byId) {
|
|
var busytime = column.byId[k];
|
|
var el = this.parent.getElement(busytime);
|
|
el.style.width = width + '%';
|
|
el.style.left = (width * cIdx) + '%';
|
|
// we toggle the style based on amount of overlaps
|
|
el.classList.toggle('many-overlaps', numCols > 4);
|
|
el.classList.toggle('has-overlaps', numCols > 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
/**
|
|
* Conflict manager
|
|
*/
|
|
define('utils/overlap',['require','exports','module','conflict_span','interval_tree','common/timespan'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
/**
|
|
* Module dependencies
|
|
*/
|
|
var ConflictSpan = require('conflict_span');
|
|
var IntervalTree = require('interval_tree');
|
|
var Timespan = require('common/timespan');
|
|
|
|
function Overlap() {
|
|
this.reset();
|
|
}
|
|
module.exports = Overlap;
|
|
|
|
Overlap.prototype = {
|
|
reset: function() {
|
|
this.tree = new IntervalTree();
|
|
this.conflicts = {};
|
|
this.elements = {};
|
|
},
|
|
|
|
add: function(myBusytime, element) {
|
|
this.tree.add(myBusytime);
|
|
this.elements[myBusytime._id] = element;
|
|
|
|
// Check for conflicts, bail if none
|
|
var related = this._findRelated(myBusytime);
|
|
if (0 === related.length) {
|
|
return;
|
|
}
|
|
|
|
var myID = myBusytime._id;
|
|
var myCS = this.conflicts[myID];
|
|
|
|
var self = this;
|
|
related.forEach(function(otherBusytime) {
|
|
// Get the other's ID, skip the current
|
|
var otherID = otherBusytime._id;
|
|
if (otherID === myID) {
|
|
return;
|
|
}
|
|
|
|
var otherCS = self.conflicts[otherID];
|
|
if (!myCS && !otherCS) {
|
|
// This is a brand new conflict.
|
|
myCS = new ConflictSpan(self);
|
|
myCS.add(myBusytime).add(otherBusytime);
|
|
} else if (myCS && !otherCS) {
|
|
// Other time can join this one's existing span
|
|
myCS.add(otherBusytime);
|
|
} else if (!myCS && otherCS) {
|
|
// This time can join the other's existing span
|
|
myCS = otherCS.add(myBusytime);
|
|
} else if (myCS && otherCS && myCS != otherCS) {
|
|
// Both already in different spans, so absorb other into this
|
|
myCS.absorb(otherCS);
|
|
}
|
|
});
|
|
|
|
},
|
|
|
|
/**
|
|
* Remove a busytime from the collection.
|
|
* Unlike other methods you must pass a real
|
|
* busytime object.
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
*/
|
|
remove: function(busytime) {
|
|
this._clearLayout(busytime);
|
|
this.tree.remove(busytime);
|
|
delete this.elements[busytime._id];
|
|
var myID = busytime._id;
|
|
var myCS = this.conflicts[myID];
|
|
if (myCS) {
|
|
myCS.remove(busytime);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get the ConflictSpan associated with this busytime, if any.
|
|
*
|
|
* @param {Object|String} busytime id or busytime object.
|
|
* @return {Object} associated ConflictSpan, if any.
|
|
*/
|
|
getConflictSpan: function(busytime) {
|
|
var id = this._busytimeId(busytime);
|
|
return this.conflicts[id];
|
|
},
|
|
|
|
/**
|
|
* @param {Object|String} busytime id or busytime object.
|
|
* @return {HTMLElement} associated dom element.
|
|
*/
|
|
getElement: function(busytime) {
|
|
var id = this._busytimeId(busytime);
|
|
return this.elements[id];
|
|
},
|
|
|
|
/** private */
|
|
|
|
_busytimeId: function(busytime) {
|
|
return (typeof(busytime) === 'string') ? busytime : busytime._id;
|
|
},
|
|
|
|
/**
|
|
* Search tree for busytimes that overlap with the given.
|
|
*/
|
|
_findRelated: function(busytime) {
|
|
//XXX: this is bad encapsulation but
|
|
// we generate these when we insert
|
|
// the points in the tree.
|
|
var span = new Timespan(busytime._startDateMS, busytime._endDateMS);
|
|
return this.tree.query(span);
|
|
},
|
|
|
|
/**
|
|
* Clear the layout from a busytime element, presumably because it has just
|
|
* been removed from conflict.
|
|
*
|
|
* @param {Object} busytime full busytime object.
|
|
*/
|
|
_clearLayout: function(busytime) {
|
|
var el = this.elements[busytime._id];
|
|
el.style.width = '';
|
|
el.style.left = '';
|
|
el.classList.remove('has-overlaps', 'many-overlaps');
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
define('utils/color',['require','exports','module'],function(require, exports) {
|
|
'use strict';
|
|
|
|
// right now the calendar app only displays 8 different colors so it's a good
|
|
// idea to memoize the results of hexToBackground to avoid calculating it for
|
|
// each busytime
|
|
var memoized = {};
|
|
|
|
exports.hexToBackground = function(hex) {
|
|
if (!(hex in memoized)) {
|
|
// we need 20% opacity for background; it's simpler to use rgba than to
|
|
// create a new layer and set opacity:20%
|
|
var {r, g, b} = hexToChannels(hex);
|
|
memoized[hex] = `rgba(${r}, ${g}, ${b}, 0.2)`;
|
|
}
|
|
|
|
return memoized[hex];
|
|
};
|
|
|
|
function hexToChannels(hex) {
|
|
var val = parseInt(hex.replace(/#/, ''), 16);
|
|
return {
|
|
r: val >> 16,
|
|
g: val >> 8 & 0xFF,
|
|
b: val & 0xFF
|
|
};
|
|
}
|
|
|
|
});
|
|
|
|
define('templates/multi_day',['require','exports','module','./date_span','utils/color','template'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var DateSpan = require('./date_span');
|
|
var colorUtils = require('utils/color');
|
|
var create = require('template').create;
|
|
|
|
var multiday = {};
|
|
|
|
multiday.hour = function() {
|
|
var hour = this.arg('hour');
|
|
|
|
var html = DateSpan.hour.render({
|
|
hour: hour,
|
|
format: this.arg('format'),
|
|
addAmPmClass: this.arg('addAmPmClass'),
|
|
className: 'md__display-hour'
|
|
});
|
|
|
|
// we use this temporary element just to grab the textContent
|
|
var tmp = document.createElement('div');
|
|
tmp.innerHTML = html;
|
|
var text = tmp.textContent;
|
|
|
|
return `<li aria-label="${text}" class="md__hour md__hour-${hour}">
|
|
${html}
|
|
</li>`;
|
|
};
|
|
|
|
multiday.day = function() {
|
|
return `<div class="md__day" data-date="${this.h('date')}"></div>`;
|
|
};
|
|
|
|
multiday.allday = function() {
|
|
return `<div class="md__allday" data-date="${this.h('date')}">
|
|
<h1 class="md__day-name" aria-level="2"
|
|
id="md__day-name-${this.h('id')}"></h1>
|
|
<div class="md__allday-events"></div>
|
|
</div>`;
|
|
};
|
|
|
|
multiday.event = function() {
|
|
var labels = [];
|
|
var id = this.h('id');
|
|
var instance = this.h('instance');
|
|
var color = this.h('color');
|
|
var bgColor = colorUtils.hexToBackground(color);
|
|
|
|
var titleId = `md__event-${id}-title-${instance}`;
|
|
labels.push(titleId);
|
|
|
|
var location = this.h('location');
|
|
var locationHtml = '';
|
|
if (location) {
|
|
var locationId = `md__event-${id}-location-${instance}`;
|
|
labels.push(locationId);
|
|
locationHtml = `<bdi class="md__event-location" id="${locationId}">
|
|
${location}
|
|
</bdi>`;
|
|
}
|
|
|
|
var alarmIcon = '';
|
|
if (this.arg('hasAlarms')) {
|
|
var iconId = `md__event-${id}-icon-${instance}`;
|
|
labels.push(iconId);
|
|
alarmIcon = `<i class="gaia-icon icon-calendar-alarm"
|
|
style="color: ${color}" aria-hidden="true" id="${iconId}"
|
|
data-l10n-id="icon-calendar-alarm">
|
|
</i>`;
|
|
}
|
|
|
|
var className = ['md__event'];
|
|
if (alarmIcon) {
|
|
className.push('has-alarms');
|
|
}
|
|
|
|
var descriptionId = `md__event-${id}-description-${instance}`;
|
|
labels.push(descriptionId);
|
|
|
|
return `<a href="/event/show/${id}"
|
|
class="${className.join(' ')}"
|
|
style="border-color: ${color}; background-color: ${bgColor}"
|
|
aria-labelledby="${labels.join(' ')}">
|
|
<bdi class="md__event-title" id="${titleId}">${this.h('title')}</bdi>
|
|
${locationHtml}
|
|
${alarmIcon}
|
|
<span id="${descriptionId}" aria-hidden="true"
|
|
data-l10n-id="${this.h('labelFormat')}"
|
|
data-l10n-args="${this.h('labelFormatArgs')}">
|
|
</span>
|
|
</a>`;
|
|
};
|
|
|
|
module.exports = create(multiday);
|
|
|
|
});
|
|
|
|
/* global IntlHelper */
|
|
|
|
define('views/single_day',['require','exports','module','utils/overlap','utils/dom','core','common/calc','common/calc','common/calc','common/calc','templates/multi_day'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Overlap = require('utils/overlap');
|
|
var buildElement = require('utils/dom').buildElement;
|
|
var core = require('core');
|
|
var isSameDate = require('common/calc').isSameDate;
|
|
var relativeDuration = require('common/calc').relativeDuration;
|
|
var relativeOffset = require('common/calc').relativeOffset;
|
|
var spanOfDay = require('common/calc').spanOfDay;
|
|
var template = require('templates/multi_day');
|
|
|
|
var _id = 0;
|
|
|
|
IntlHelper.define('longDateFormat', 'datetime', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric'
|
|
});
|
|
|
|
function SingleDay(config) {
|
|
this.date = config.date;
|
|
this._hourHeight = config.hourHeight;
|
|
this._daysHolder = config.daysHolder;
|
|
this._allDayIcon = config.allDayIcon;
|
|
this._alldaysHolder = config.alldaysHolder;
|
|
this._oneDayLabelFormat = config.oneDayLabelFormat;
|
|
this._render = this._render.bind(this);
|
|
this._instanceID = _id++;
|
|
this.overlaps = new Overlap();
|
|
}
|
|
module.exports = SingleDay;
|
|
|
|
SingleDay.prototype = {
|
|
_isActive: false,
|
|
_borderWidth: 0.1,
|
|
_attached: false,
|
|
_observer: null,
|
|
|
|
setup: function() {
|
|
this.day = buildElement(template.day.render({
|
|
date: this.date
|
|
}));
|
|
this.allday = buildElement(template.allday.render({
|
|
date: this.date,
|
|
id: this._instanceID
|
|
}));
|
|
this._dayName = this.allday.querySelector('.md__day-name');
|
|
this._alldayEvents = this.allday.querySelector('.md__allday-events');
|
|
|
|
this._updateDayName();
|
|
|
|
this.onactive();
|
|
},
|
|
|
|
_updateDayName: function() {
|
|
// we can't use [data-l10n-date-format] because format might change based
|
|
// on locale
|
|
this._dayName.textContent = this.date.toLocaleString(navigator.languages, {
|
|
weekday: 'short',
|
|
day: 'numeric'
|
|
});
|
|
},
|
|
|
|
handleEvent: function(evt) {
|
|
switch(evt.type) {
|
|
case 'localized':
|
|
this._updateDayName();
|
|
break;
|
|
}
|
|
},
|
|
|
|
append: function() {
|
|
this._daysHolder.appendChild(this.day);
|
|
this._alldaysHolder.appendChild(this.allday);
|
|
this._attached = true;
|
|
},
|
|
|
|
onactive: function() {
|
|
if (this._isActive) {
|
|
return;
|
|
}
|
|
this._observer = core.bridge.observeDay(this.date);
|
|
this._observer.listen(this._render);
|
|
window.addEventListener('localized', this);
|
|
this._isActive = true;
|
|
},
|
|
|
|
_render: function(records) {
|
|
this._alldayEvents.innerHTML = '';
|
|
records.allday.forEach(this._renderAlldayEvent, this);
|
|
this.overlaps.reset();
|
|
this.day.innerHTML = '';
|
|
records.basic.forEach(this._renderEvent, this);
|
|
if (this._alldayEvents.children.length > 0) {
|
|
// If there are all day events, the section acts as a listbox.
|
|
this._alldayEvents.setAttribute('role', 'listbox');
|
|
this._alldayEvents.setAttribute('aria-labelledby', this._allDayIcon.id +
|
|
' ' + this._dayName.id);
|
|
} else {
|
|
// If there are no all day events, the section acts as a create new all
|
|
// day event button.
|
|
this._alldayEvents.setAttribute('role', 'button');
|
|
this._alldayEvents.setAttribute('data-l10n-id', 'create-all-day-event');
|
|
this._alldayEvents.setAttribute('aria-describedby', this._dayName.id);
|
|
}
|
|
},
|
|
|
|
_renderEvent: function(record) {
|
|
var el = this._buildEventElement(record);
|
|
|
|
var {busytime} = record;
|
|
var {startDate, endDate} = busytime;
|
|
|
|
// we don't set the height on the template because we use the same markup
|
|
// for all day events as well
|
|
var duration = relativeDuration(this.date, startDate, endDate);
|
|
// we subtract border to keep a margin between consecutive events
|
|
var hei = duration * this._hourHeight - this._borderWidth;
|
|
|
|
// It doesn't make sense to set height less than 1 px, so we set it to 1 px
|
|
// in order to show events correctly.
|
|
hei = Math.max(1, hei);
|
|
|
|
el.style.height = hei + 'px';
|
|
|
|
if (duration < 1) {
|
|
el.classList.add('is-partial');
|
|
var size = '';
|
|
// we need to toggle layout if event lasts less than 20, 30 and 45min
|
|
if (duration < 0.3) {
|
|
size = 'micro';
|
|
} else if (duration < 0.5) {
|
|
size = 'tiny';
|
|
} else if (duration < 0.75) {
|
|
size = 'small';
|
|
}
|
|
if (size) {
|
|
el.classList.add('is-partial-' + size);
|
|
}
|
|
}
|
|
|
|
var offset = relativeOffset(this.date, startDate);
|
|
el.style.top = (offset * this._hourHeight) + 'px';
|
|
|
|
this.overlaps.add(busytime, el);
|
|
this.day.appendChild(el);
|
|
},
|
|
|
|
_buildEventElement: function(record) {
|
|
var {title, location, alarms} = record.event.remote;
|
|
var {startDate, endDate, _id} = record.busytime;
|
|
|
|
// screen reader should be aware if the event spans multiple dates and also
|
|
// know the event duration without having the open it
|
|
var labelFormat = isSameDate(startDate, endDate) ? this._oneDayLabelFormat :
|
|
'event-multiple-day-duration';
|
|
|
|
var longDateFormat = IntlHelper.get('longDateFormat');
|
|
var shortTimeFormat = IntlHelper.get('shortTimeFormat');
|
|
var labelFormatArgs = JSON.stringify({
|
|
startDate: longDateFormat.format(startDate),
|
|
startTime: shortTimeFormat.format(startDate),
|
|
endDate: longDateFormat.format(endDate),
|
|
endTime: shortTimeFormat.format(endDate)
|
|
});
|
|
|
|
return buildElement(template.event.render({
|
|
id: _id,
|
|
instance: this._instanceID,
|
|
title: title,
|
|
location: location,
|
|
hasAlarms: alarms && alarms.length,
|
|
color: record.color,
|
|
labelFormat: labelFormat,
|
|
labelFormatArgs: labelFormatArgs
|
|
}));
|
|
},
|
|
|
|
_renderAlldayEvent: function(record) {
|
|
var el = this._buildEventElement(record);
|
|
el.classList.add('is-allday');
|
|
el.setAttribute('role', 'option');
|
|
this._alldayEvents.appendChild(el);
|
|
},
|
|
|
|
destroy: function() {
|
|
this.oninactive();
|
|
this._detach();
|
|
},
|
|
|
|
_detach: function() {
|
|
if (this._attached) {
|
|
this._daysHolder.removeChild(this.day);
|
|
this._alldaysHolder.removeChild(this.allday);
|
|
this._attached = false;
|
|
}
|
|
},
|
|
|
|
oninactive: function() {
|
|
if (!this._isActive) {
|
|
return;
|
|
}
|
|
this._observer.cancel();
|
|
window.removeEventListener('localized', this);
|
|
this._isActive = false;
|
|
},
|
|
|
|
setVisibleForScreenReader: function(visibleRange) {
|
|
var visible = visibleRange.contains(spanOfDay(this.date));
|
|
this.day.setAttribute('aria-hidden', !visible);
|
|
this.allday.setAttribute('aria-hidden', !visible);
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
define('utils/animated_scroll',['require','exports','module'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
module.exports = function animatedScroll(opts) {
|
|
var {container, content, scrollTop} = opts;
|
|
|
|
var maxScroll = container.scrollHeight - container.clientHeight;
|
|
// safeguard against negative or `null` scrollTop (0 < scrollTop < maxScroll)
|
|
scrollTop = Math.min(Math.max(scrollTop, 0), maxScroll);
|
|
|
|
var diff = container.scrollTop - scrollTop;
|
|
var seconds = Math.abs(diff) / 500;
|
|
|
|
// hide scrollbar during animation to avoid errors introduced by user trying
|
|
// to scroll while transition is still running; it also avoids weird
|
|
// sensation of content moving while scrollbar is still at same position
|
|
container.style.overflowY = 'hidden';
|
|
|
|
window.requestAnimationFrame(() => {
|
|
content.style.transform = 'translateY(' + diff + 'px)';
|
|
// easeOutQuart borrowed from http://matthewlein.com/ceaser/
|
|
content.style.transition = 'transform ' + seconds + 's ' +
|
|
'cubic-bezier(0.165, 0.840, 0.440, 1.000)';
|
|
});
|
|
|
|
content.addEventListener('transitionend', function setScrollTop() {
|
|
content.removeEventListener('transitionend', setScrollTop);
|
|
content.style.transform = '';
|
|
content.style.transition = '';
|
|
container.scrollTop = scrollTop;
|
|
container.style.overflowY = 'scroll';
|
|
});
|
|
};
|
|
|
|
});
|
|
|
|
define('views/multi_day',['require','exports','module','common/calc','./current_time','./hour_double_tap','shared/intl_helper','./pan','./single_day','common/timespan','view','utils/animated_scroll','core','common/calc','templates/multi_day','utils/mout'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Calc = require('common/calc');
|
|
var CurrentTime = require('./current_time');
|
|
var HourDoubleTap = require('./hour_double_tap');
|
|
var IntlHelper = require('shared/intl_helper');
|
|
var Pan = require('./pan');
|
|
var SingleDay = require('./single_day');
|
|
var Timespan = require('common/timespan');
|
|
var View = require('view');
|
|
var animatedScroll = require('utils/animated_scroll');
|
|
var core = require('core');
|
|
var createDay = require('common/calc').createDay;
|
|
var template = require('templates/multi_day');
|
|
var throttle = require('utils/mout').throttle;
|
|
|
|
IntlHelper.define('hour-format', 'datetime', {
|
|
hour: 'numeric'
|
|
});
|
|
|
|
function MultiDay(opts) {
|
|
this.children = [];
|
|
this._render = throttle(this._render, 200);
|
|
}
|
|
module.exports = MultiDay;
|
|
|
|
MultiDay.prototype = {
|
|
|
|
// override these properties on child classes to change the behavior!
|
|
scale: 'week',
|
|
visibleCells: 5,
|
|
element: null,
|
|
_hourFormat: 'hour-format',
|
|
_oneDayLabelFormat: 'event-one-day-duration',
|
|
_addAmPmClass: false,
|
|
|
|
childClass: SingleDay,
|
|
children: null,
|
|
seen: false,
|
|
_baseDate: null,
|
|
_hourHeight: 0,
|
|
_prevRange: null,
|
|
_visibleRange: null,
|
|
|
|
set baseDate(date) {
|
|
// it's very important that base date doesn't hold hour info otherwise we
|
|
// could create duplicate days (because range wouldn't contain datetime)
|
|
this._baseDate = createDay(date);
|
|
},
|
|
|
|
get baseDate() {
|
|
return this._baseDate;
|
|
},
|
|
|
|
get daysHolder() {
|
|
return this.element.querySelector('.md__days');
|
|
},
|
|
|
|
get alldaysHolder() {
|
|
return this.element.querySelector('.md__alldays');
|
|
},
|
|
|
|
get main() {
|
|
return this.element.querySelector('.md__main');
|
|
},
|
|
|
|
get mainContent() {
|
|
return this.element.querySelector('.md__main-content');
|
|
},
|
|
|
|
get sidebar() {
|
|
return this.element.querySelector('.md__sidebar');
|
|
},
|
|
|
|
get allDayIcon() {
|
|
return this.element.querySelector('.md__all-day');
|
|
},
|
|
|
|
onactive: function() {
|
|
this.element.classList.add(View.ACTIVE);
|
|
|
|
if (!this.seen) {
|
|
this.onfirstseen();
|
|
this.seen = true;
|
|
}
|
|
|
|
var controller = core.timeController;
|
|
controller.scale = this.scale;
|
|
controller.moveToMostRecentDay();
|
|
|
|
var previousBaseDate = this.baseDate;
|
|
this.baseDate = this._calcBaseDate(controller.position);
|
|
this._render();
|
|
|
|
if (window.history.state && 'eventStartHour' in window.history.state) {
|
|
// scroll to last edited event
|
|
this._scrollToHour({
|
|
hour: Math.max(window.history.state.eventStartHour - 1, 0)
|
|
});
|
|
} else if (!(previousBaseDate &&
|
|
Calc.isSameDate(previousBaseDate, this.baseDate))) {
|
|
// Do not scroll when come back from other time views without changing the
|
|
// base date
|
|
this._resetScroll();
|
|
this._scrollToHour();
|
|
}
|
|
|
|
// add listeners afterwards to avoid calling render twice
|
|
controller.on('dayChange', this);
|
|
},
|
|
|
|
_calcBaseDate: function(date) {
|
|
// this is overwritten by week view, and only called during onactivate
|
|
return date;
|
|
},
|
|
|
|
onfirstseen: function() {
|
|
this._setupPan();
|
|
this._setupHours();
|
|
this._setupCurrentTime();
|
|
this._setupDoubleTap();
|
|
this.allDayIcon.id = 'md__all-day-icon-' + this.scale;
|
|
// we keep the localized listener even when view is inactive to avoid
|
|
// rebuilding the hours/dates every time we switch between views
|
|
window.addEventListener('localized', this);
|
|
window.addEventListener('timeformatchange', this);
|
|
// When screen reader is used, scrolling is done using wheel events.
|
|
this.element.addEventListener('wheel', this);
|
|
},
|
|
|
|
_setupPan: function() {
|
|
var containerWidth = this.daysHolder.parentNode.offsetWidth;
|
|
this._pan = new Pan({
|
|
gridSize: Math.round(containerWidth / this.visibleCells),
|
|
visibleCells: this.visibleCells,
|
|
eventTarget: this.element,
|
|
overflows: [
|
|
this.main
|
|
],
|
|
targets: [
|
|
this.alldaysHolder,
|
|
this.daysHolder
|
|
]
|
|
});
|
|
this._pan.setup();
|
|
this._pan.on('start', () => this._hourDoubleTap.removeAddEventLink());
|
|
this._pan.on('release', obj => this._updateBaseDateAfterScroll(obj.diff));
|
|
},
|
|
|
|
_setupHours: function() {
|
|
var sidebar = this.sidebar;
|
|
// we need to remove all children because when locale change we rebuild
|
|
// the hours (we can't use data-l10n-id because of special format)
|
|
sidebar.innerHTML = '';
|
|
var i = -1, hours = '';
|
|
while (++i < 24) {
|
|
hours += template.hour.render({
|
|
hour: i,
|
|
format: this._hourFormat,
|
|
addAmPmClass: this._addAmPmClass
|
|
});
|
|
}
|
|
sidebar.innerHTML = hours;
|
|
this._hourHeight = sidebar.querySelector('.md__hour').offsetHeight;
|
|
},
|
|
|
|
_setupCurrentTime: function() {
|
|
this._currentTime = new CurrentTime({
|
|
container: this.mainContent,
|
|
sticky: this.alldaysHolder
|
|
});
|
|
},
|
|
|
|
_setupDoubleTap: function() {
|
|
this._hourDoubleTap = new HourDoubleTap({
|
|
main: this.main,
|
|
daysHolder: this.daysHolder,
|
|
alldaysHolder: this.alldaysHolder,
|
|
hourHeight: this._hourHeight
|
|
});
|
|
this._hourDoubleTap.setup();
|
|
},
|
|
|
|
handleEvent: function(e) {
|
|
switch (e.type) {
|
|
case 'dayChange':
|
|
this._onDayChange(e.data[0]);
|
|
break;
|
|
case 'localized':
|
|
case 'timeformatchange':
|
|
this._localize();
|
|
break;
|
|
case 'wheel':
|
|
this._onwheel(e);
|
|
break;
|
|
}
|
|
},
|
|
|
|
_onwheel: function(event) {
|
|
if (event.deltaMode !== event.DOM_DELTA_PAGE || event.deltaX === 0) {
|
|
return;
|
|
}
|
|
// Update dates based on the number of visible cells after screen reader
|
|
// wheel.
|
|
this._updateBaseDateAfterScroll(event.deltaX * this.visibleCells);
|
|
},
|
|
|
|
_onDayChange: function(date) {
|
|
// _render() updates the _visibleRange, so we need to check it first
|
|
var containedToday = this._visibleRange.contains(new Date());
|
|
this.baseDate = date;
|
|
this._render();
|
|
if (!containedToday) {
|
|
this._scrollToHour({ onlyToday: true });
|
|
}
|
|
},
|
|
|
|
_localize: function() {
|
|
this._setupHours();
|
|
this._refreshCurrentTime();
|
|
},
|
|
|
|
_updateBaseDateAfterScroll: function(diff) {
|
|
var day = createDayDiff(this.baseDate, diff);
|
|
core.timeController.move(day);
|
|
core.timeController.selectedDay = day;
|
|
},
|
|
|
|
_render: function() {
|
|
var currentRange = this._getRange();
|
|
this._removeDatesOutsideRange(currentRange);
|
|
|
|
// very important to re-activate child views in case we change views
|
|
// without moving to a different date
|
|
this.children.forEach(child => child.onactive());
|
|
|
|
this._addDatesInsideRange(currentRange);
|
|
|
|
this._prevRange = currentRange;
|
|
this._visibleRange = this._getVisibleRange();
|
|
this._sortDays();
|
|
this._setVisibleForScreenReader();
|
|
this._pan.refresh();
|
|
this._refreshCurrentTime();
|
|
},
|
|
|
|
_refreshCurrentTime: function() {
|
|
this._currentTime.timespan = this._visibleRange;
|
|
this._currentTime.refresh();
|
|
},
|
|
|
|
_setVisibleForScreenReader: function() {
|
|
this.children.forEach(
|
|
child => child.setVisibleForScreenReader(this._visibleRange));
|
|
},
|
|
|
|
_removeDatesOutsideRange: function(range) {
|
|
if (this.children.length) {
|
|
this.children = this.children.filter(child => {
|
|
if (range.contains(child.date)) {
|
|
return true;
|
|
}
|
|
child.destroy();
|
|
return false;
|
|
});
|
|
}
|
|
},
|
|
|
|
_addDatesInsideRange: function(range) {
|
|
this._getPendingDates(range)
|
|
.forEach(date => {
|
|
var day = new this.childClass({
|
|
date: date,
|
|
daysHolder: this.daysHolder,
|
|
alldaysHolder: this.alldaysHolder,
|
|
allDayIcon: this.allDayIcon,
|
|
hourHeight: this._hourHeight,
|
|
oneDayLabelFormat: this._oneDayLabelFormat
|
|
});
|
|
day.setup();
|
|
this.children.push(day);
|
|
});
|
|
},
|
|
|
|
_getPendingDates: function(range) {
|
|
var dates = Calc.daysBetween(range);
|
|
if (this._prevRange) {
|
|
dates = dates.filter(date => {
|
|
return !this._prevRange.contains(date);
|
|
});
|
|
}
|
|
return dates;
|
|
},
|
|
|
|
_sortDays: function() {
|
|
// decided to use float and reappend the elements in the right order
|
|
// since using position:absolute or css transforms felt "slower"
|
|
// (we have a reflow anyway since we might add new elements to the DOM)
|
|
this.children
|
|
.sort((a, b) => a.date - b.date)
|
|
.forEach(day => day.append());
|
|
},
|
|
|
|
_getRange: function() {
|
|
return new Timespan(
|
|
createDayDiff(this.baseDate, -this.visibleCells),
|
|
createDayDiff(this.baseDate, (this.visibleCells * 2) - 1)
|
|
);
|
|
},
|
|
|
|
_getVisibleRange: function() {
|
|
return new Timespan(
|
|
this.baseDate,
|
|
createDayDiff(this.baseDate, this.visibleCells)
|
|
);
|
|
},
|
|
|
|
_resetScroll: function() {
|
|
this.main.scrollTop = 0;
|
|
},
|
|
|
|
_scrollToHour: function(options) {
|
|
var hour = this._getScrollDestinationHour(options);
|
|
if (hour != null) {
|
|
animatedScroll({
|
|
content: this.mainContent,
|
|
container: this.main,
|
|
scrollTop: hour * this._hourHeight
|
|
});
|
|
}
|
|
},
|
|
|
|
_getScrollDestinationHour: function(options) {
|
|
var hour = options && options.hour;
|
|
if (hour != null) {
|
|
return hour;
|
|
}
|
|
|
|
var now = new Date();
|
|
if (this._visibleRange.contains(now)) {
|
|
return Math.max(now.getHours() - 1, 0);
|
|
}
|
|
|
|
return (options && options.onlyToday) ? null : 8;
|
|
},
|
|
|
|
oninactive: function() {
|
|
this.element.classList.remove(View.ACTIVE);
|
|
core.timeController.removeEventListener('dayChange', this);
|
|
this.children.forEach(child => child.oninactive());
|
|
}
|
|
};
|
|
|
|
function createDayDiff(date, diff) {
|
|
return createDay(date, date.getDate() + diff);
|
|
}
|
|
|
|
});
|
|
|
|
|
|
define('views/week',['require','exports','module','common/calc','shared/intl_helper','./multi_day','dom!week-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Calc = require('common/calc');
|
|
var IntlHelper = require('shared/intl_helper');
|
|
var MultiDay = require('./multi_day');
|
|
|
|
require('dom!week-view');
|
|
|
|
IntlHelper.define('week-hour-format', 'mozdatetime', {
|
|
hour: 'numeric',
|
|
dayperiod: true,
|
|
});
|
|
|
|
function WeekView(opts) {
|
|
MultiDay.apply(this, arguments);
|
|
}
|
|
module.exports = WeekView;
|
|
|
|
WeekView.prototype = {
|
|
__proto__: MultiDay.prototype,
|
|
|
|
scale: 'week',
|
|
visibleCells: 5,
|
|
_hourFormat: 'week-hour-format',
|
|
_oneDayLabelFormat: 'week-event-one-day-duration',
|
|
_addAmPmClass: true,
|
|
|
|
get element() {
|
|
return document.getElementById('week-view');
|
|
},
|
|
|
|
_calcBaseDate: function(date) {
|
|
// Don't reset the first day when come back from other screens.
|
|
if (this.baseDate && Calc.isSameDate(date, this.baseDate)) {
|
|
return this.baseDate;
|
|
}
|
|
|
|
// Show monday as the first day of the grid if date is between Mon-Fri.
|
|
var index = Calc.dayOfWeekFromMonday(date.getDay());
|
|
if (index < 5) {
|
|
date = Calc.createDay(date, date.getDate() - index);
|
|
}
|
|
return date;
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
/* global mozIntl */
|
|
define('templates/alarm',['require','exports','module','template'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var create = require('template').create;
|
|
|
|
var MINUTE = 60;
|
|
var HOUR = 3600;
|
|
var DAY = 86400;
|
|
var WEEK = 604800;
|
|
var MORNING = HOUR * 9;
|
|
var layouts = {
|
|
standard: [
|
|
'none',
|
|
0,
|
|
0 - MINUTE * 5,
|
|
0 - MINUTE * 15,
|
|
0 - MINUTE * 30,
|
|
0 - HOUR,
|
|
0 - HOUR * 2,
|
|
0 - DAY
|
|
],
|
|
allday: [
|
|
'none',
|
|
0 + MORNING,
|
|
0 - DAY + MORNING,
|
|
0 - DAY * 2 + MORNING,
|
|
0 - WEEK + MORNING,
|
|
0 - WEEK * 2 + MORNING
|
|
]
|
|
};
|
|
|
|
var Alarm = create({
|
|
reminder: function() {
|
|
var alarmContent = '';
|
|
var alarms = this.arg('alarms');
|
|
var isAllDay = this.arg('isAllDay');
|
|
|
|
var i = 0;
|
|
var alarm;
|
|
while ((alarm = alarms[i])) {
|
|
i++;
|
|
alarmContent += Alarm.description.render({
|
|
trigger: alarm.trigger,
|
|
layout: isAllDay ? 'allday' : 'standard'
|
|
});
|
|
}
|
|
|
|
return alarmContent;
|
|
},
|
|
|
|
description: function() {
|
|
var {id, data} = getL10n(this.arg('trigger'), this.arg('layout'));
|
|
var args = JSON.stringify(data);
|
|
var description = navigator.mozL10n.get(id, data);
|
|
|
|
return `<div role="listitem" data-l10n-id="${id}"
|
|
data-l10n-args=\'${args}\'>
|
|
${description}
|
|
</div>`;
|
|
},
|
|
|
|
// builds a list of <option>
|
|
options: function() {
|
|
var content = '';
|
|
var selected;
|
|
var foundSelected = false;
|
|
|
|
var trigger = this.arg('trigger');
|
|
var layout = this.arg('layout') || 'standard';
|
|
var options = layouts[layout];
|
|
|
|
var i = 0;
|
|
var iLen = options.length;
|
|
|
|
for (; i < iLen; i++) {
|
|
selected = false;
|
|
|
|
// trigger option 'selected' by normalizing imported dates
|
|
if (layout === 'allday') {
|
|
if (options[i] === (trigger + MORNING)) {
|
|
trigger += MORNING;
|
|
}
|
|
}
|
|
|
|
if (!selected && trigger && options[i] === trigger) {
|
|
selected = true;
|
|
foundSelected = true;
|
|
}
|
|
|
|
content += Alarm.option.render({
|
|
selected: selected,
|
|
layout: layout,
|
|
value: options[i]
|
|
});
|
|
}
|
|
|
|
// foundSelected is used in cases where user is editing an event that has
|
|
// a custom reminder value (X minutes/hours/days before event) and that
|
|
// is an option that we don't support internally on the calendar app.
|
|
// we always add a new <option> using the custom value and mark it as
|
|
// selected.
|
|
if (!foundSelected && /^-?\d+$/.test(trigger)) {
|
|
content += Alarm.option.render({
|
|
selected: true,
|
|
layout: layout,
|
|
value: trigger
|
|
});
|
|
}
|
|
|
|
return content;
|
|
},
|
|
|
|
option: function() {
|
|
var _ = navigator.mozL10n.get;
|
|
|
|
var layout = this.arg('layout');
|
|
var value = this.arg('value');
|
|
var selected = this.arg('selected');
|
|
|
|
var l10n = getL10n(value, layout);
|
|
|
|
var content = [
|
|
'<option',
|
|
'value="' + value + '"',
|
|
(selected ? 'selected' : ''),
|
|
'data-l10n-id="' + l10n.id + '"',
|
|
'data-l10n-args=\'' + JSON.stringify(l10n.data) + '\'>',
|
|
_(l10n.id, l10n.data) + '</option>'
|
|
].join(' ');
|
|
|
|
return content;
|
|
},
|
|
|
|
picker: function() {
|
|
return '<span class="button icon icon-dialog">' +
|
|
'<select name="alarm[]">' +
|
|
Alarm.options.render(this.data) +
|
|
'</select>' +
|
|
'</span>';
|
|
}
|
|
});
|
|
|
|
function getL10n(trigger, layout) {
|
|
if (trigger === 'none') {
|
|
return {
|
|
id: trigger,
|
|
data: {}
|
|
};
|
|
}
|
|
|
|
// Format the display text based on a zero-offset trigger
|
|
if (layout === 'allday') {
|
|
var options = layouts.allday;
|
|
if (options.indexOf(trigger) !== -1) {
|
|
trigger -= MORNING;
|
|
}
|
|
}
|
|
|
|
if (trigger === 0) {
|
|
return {
|
|
id: 'alarm-at-event-' + layout,
|
|
data: {}
|
|
};
|
|
}
|
|
|
|
var affix = trigger > 0 ? 'after' : 'before';
|
|
var part = mozIntl._gaia.relativePart(trigger * 1000);
|
|
return {
|
|
id: part.unit + '-' + affix,
|
|
data: {
|
|
value: part.value
|
|
}
|
|
};
|
|
}
|
|
module.exports = Alarm;
|
|
|
|
});
|
|
|
|
define('templates/account',['require','exports','module','template'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var create = require('template').create;
|
|
|
|
module.exports = create({
|
|
provider: function() {
|
|
var name = this.h('name');
|
|
return `<li class="${name}" role="presentation">
|
|
<a data-l10n-id="preset-${name}" role="option" dir="auto"
|
|
data-provider="${name}" href="/create-account/${name}">
|
|
</a>
|
|
</li>`;
|
|
},
|
|
|
|
account: function() {
|
|
var id = this.h('id');
|
|
var preset = this.h('preset');
|
|
var user = this.h('user');
|
|
|
|
return `<li id="account-${id}" role="presentation">
|
|
<a href="/update-account/${id}" role="option" dir="auto">
|
|
<span class="preset" data-l10n-id="preset-${preset}"></span>
|
|
<span class="user">${user}</span>
|
|
</a>
|
|
</li>`;
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
|
|
define('views/advanced_settings',['require','exports','module','templates/alarm','view','ext/co','core','utils/dom','router','templates/account','dom!advanced-settings-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var AlarmTemplate = require('templates/alarm');
|
|
var View = require('view');
|
|
var co = require('ext/co');
|
|
var core = require('core');
|
|
var dom = require('utils/dom');
|
|
var router = require('router');
|
|
var template = require('templates/account');
|
|
|
|
require('dom!advanced-settings-view');
|
|
|
|
function AdvancedSettings(options) {
|
|
View.apply(this, arguments);
|
|
}
|
|
module.exports = AdvancedSettings;
|
|
|
|
AdvancedSettings.prototype = {
|
|
__proto__: View.prototype,
|
|
|
|
selectors: {
|
|
element: '#advanced-settings-view',
|
|
accountList: '#advanced-settings-view .account-list',
|
|
createAccountButton: '#advanced-settings-view .create-account',
|
|
syncFrequency: '#setting-sync-frequency',
|
|
header: '#advanced-settings-header',
|
|
standardAlarmLabel: '#default-event-alarm',
|
|
alldayAlarmLabel: '#default-allday-alarm'
|
|
},
|
|
|
|
get accountList() {
|
|
return this._findElement('accountList');
|
|
},
|
|
|
|
get createAccountButton() {
|
|
return this._findElement('createAccountButton');
|
|
},
|
|
|
|
get syncFrequency() {
|
|
return this._findElement('syncFrequency');
|
|
},
|
|
|
|
get standardAlarmLabel() {
|
|
return this._findElement('standardAlarmLabel');
|
|
},
|
|
|
|
get alldayAlarmLabel() {
|
|
return this._findElement('alldayAlarmLabel');
|
|
},
|
|
|
|
get header() {
|
|
return this._findElement('header');
|
|
},
|
|
|
|
get standardAlarm() {
|
|
return this.standardAlarmLabel.querySelector('select');
|
|
},
|
|
|
|
get alldayAlarm() {
|
|
return this.alldayAlarmLabel.querySelector('select');
|
|
},
|
|
|
|
_formatModel: function(model) {
|
|
// XXX: Here for l10n
|
|
return {
|
|
id: model._id,
|
|
preset: model.preset,
|
|
user: model.user,
|
|
hasError: !!model.error
|
|
};
|
|
},
|
|
|
|
_initEvents: function() {
|
|
var bridge = core.bridge;
|
|
bridge.observeAccounts().listen(this._renderAccounts.bind(this));
|
|
bridge.observeSetting('syncFrequency').listen(
|
|
this._renderSyncFrequency.bind(this)
|
|
);
|
|
|
|
this.createAccountButton.addEventListener('click',
|
|
this.onCreateAccount.bind(this));
|
|
this.syncFrequency.addEventListener('change', this);
|
|
|
|
this.standardAlarmLabel.addEventListener('change', this);
|
|
this.alldayAlarmLabel.addEventListener('change', this);
|
|
},
|
|
|
|
handleSettingUiChange: function(type, value) {
|
|
// basic conversions
|
|
if (value === 'null') {
|
|
value = null;
|
|
}
|
|
|
|
switch (type) {
|
|
case 'alldayAlarmDefault':
|
|
case 'standardAlarmDefault':
|
|
case 'syncFrequency':
|
|
if (value !== null) {
|
|
value = parseInt(value);
|
|
}
|
|
core.bridge.setSetting(type, value);
|
|
break;
|
|
}
|
|
},
|
|
|
|
handleEvent: function(event) {
|
|
switch (event.type) {
|
|
case 'change':
|
|
var target = event.target;
|
|
this.handleSettingUiChange(target.name, target.value);
|
|
break;
|
|
}
|
|
},
|
|
|
|
onCreateAccount: function(event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
router.show(event.target.getAttribute('href'));
|
|
},
|
|
|
|
render: co.wrap(function *() {
|
|
this.header.runFontFitSoon();
|
|
|
|
// the accounts and syncFrequency are already handled by the _initEvents
|
|
var bridge = core.bridge;
|
|
var data = yield {
|
|
standardAlarmDefault: bridge.getSetting('standardAlarmDefault'),
|
|
alldayAlarmDefault: bridge.getSetting('alldayAlarmDefault')
|
|
};
|
|
|
|
this._renderAlarmDefault('standard', data.standardAlarmDefault);
|
|
this._renderAlarmDefault('allday', data.alldayAlarmDefault);
|
|
}),
|
|
|
|
_renderSyncFrequency: function(value) {
|
|
this.syncFrequency.value = String(value);
|
|
},
|
|
|
|
_renderAccounts: function(accounts) {
|
|
this.accountList.innerHTML = '';
|
|
|
|
accounts
|
|
.filter(data => data.provider.hasAccountSettings)
|
|
.forEach(data => {
|
|
var model = data.account;
|
|
var idx = this.accountList.children.length;
|
|
var item = template.account.render(this._formatModel(model));
|
|
this.accountList.insertAdjacentHTML('beforeend', item);
|
|
|
|
if (model.error) {
|
|
this.accountList.children[idx].classList.add('error');
|
|
}
|
|
});
|
|
},
|
|
|
|
_renderAlarmDefault: function(type, value) {
|
|
var element = type + 'AlarmLabel';
|
|
dom.removeElements(this[element].querySelector('select'));
|
|
|
|
// Render the select box
|
|
var template = AlarmTemplate;
|
|
var select = document.createElement('select');
|
|
select.name = type + 'AlarmDefault';
|
|
select.innerHTML = template.options.render({
|
|
layout: type,
|
|
trigger: value
|
|
});
|
|
this[element].querySelector('.button').appendChild(select);
|
|
},
|
|
|
|
onfirstseen: function() {
|
|
this._initEvents();
|
|
this.render();
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
|
|
define('views/create_account',['require','exports','module','common/presets','view','ext/co','core','templates/account','dom!create-account-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Presets = require('common/presets');
|
|
var View = require('view');
|
|
var co = require('ext/co');
|
|
var core = require('core');
|
|
var template = require('templates/account');
|
|
|
|
require('dom!create-account-view');
|
|
|
|
function CreateAccount(options) {
|
|
View.apply(this, arguments);
|
|
this.cancel = this.cancel.bind(this);
|
|
}
|
|
module.exports = CreateAccount;
|
|
|
|
CreateAccount.prototype = {
|
|
__proto__: View.prototype,
|
|
|
|
_changeToken: 0,
|
|
|
|
presets: Presets,
|
|
|
|
selectors: {
|
|
element: '#create-account-view',
|
|
accounts: '#create-account-presets',
|
|
header: '#create-account-header'
|
|
},
|
|
|
|
get accounts() {
|
|
return this._findElement('accounts');
|
|
},
|
|
|
|
get header() {
|
|
return this._findElement('header');
|
|
},
|
|
|
|
_initEvents: function() {
|
|
this._accountStream = core.bridge.observeAccounts();
|
|
this._accountStream.listen(() => this.render());
|
|
this.header.addEventListener('action', this.cancel);
|
|
},
|
|
|
|
destroy: function() {
|
|
this._accountStream && this._accountStream.cancel();
|
|
this.header.removeEventListener('action', this.cancel);
|
|
},
|
|
|
|
render: co.wrap(function *() {
|
|
var presets = this.presets;
|
|
var listElement = this.accounts;
|
|
var currentToken = ++this._changeToken;
|
|
|
|
listElement.innerHTML = '';
|
|
|
|
function renderPreset(presetName) {
|
|
listElement.insertAdjacentHTML(
|
|
'beforeend',
|
|
template.provider.render({ name: presetName })
|
|
);
|
|
}
|
|
|
|
try {
|
|
var available = yield core.bridge.availablePresets(presets);
|
|
if (this._changeToken !== currentToken) {
|
|
// another render call takes priority over this one.
|
|
return;
|
|
}
|
|
available.forEach(renderPreset);
|
|
} catch (err) {
|
|
console.error('Error displaying presets', err);
|
|
}
|
|
}),
|
|
|
|
onfirstseen: function() {
|
|
// render will be called as a side-effect of _initEvents
|
|
this._initEvents();
|
|
},
|
|
|
|
cancel: function() {
|
|
window.history.back();
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
|
|
define('views/day',['require','exports','module','./multi_day','dom!day-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var MultiDay = require('./multi_day');
|
|
|
|
require('dom!day-view');
|
|
|
|
function DayView(opts) {
|
|
MultiDay.apply(this, arguments);
|
|
}
|
|
module.exports = DayView;
|
|
|
|
DayView.prototype = {
|
|
__proto__: MultiDay.prototype,
|
|
|
|
scale: 'day',
|
|
visibleCells: 1,
|
|
|
|
get element() {
|
|
return document.getElementById('day-view');
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
|
|
define('oauth_window',['require','exports','module','querystring','view','dom!oauth2'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var QueryString = require('querystring');
|
|
var View = require('view');
|
|
|
|
require('dom!oauth2');
|
|
|
|
/**
|
|
* Creates a oAuth dialog given a set of parameters.
|
|
*
|
|
* var oauth = new OAuthWindow(
|
|
* elementContainer,
|
|
* 'https://accounts.google.com/o/oauth2/auth',
|
|
* {
|
|
* response_type: 'code',
|
|
* client_id: 'xxx',
|
|
* scope: 'https://www.googleapis.com/auth/calendar',
|
|
* redirect_uri: 'xxx',
|
|
* state: 'foobar',
|
|
* access_type: 'offline',
|
|
* approval_prompt: 'force'
|
|
* }
|
|
* );
|
|
*
|
|
* oauth.oncomplete = function(evt) {
|
|
* if (evt.detail.code) {
|
|
* // success
|
|
* }
|
|
* };
|
|
*
|
|
* oauth.onabort = function() {
|
|
* // oauth was aborted
|
|
* };
|
|
*
|
|
*
|
|
*/
|
|
function OAuthWindow(container, server, params) {
|
|
if (!params.redirect_uri) {
|
|
throw new Error(
|
|
'must provide params.redirect_uri so oauth flow can complete'
|
|
);
|
|
}
|
|
|
|
this.params = {};
|
|
for (var key in params) {
|
|
this.params[key] = params[key];
|
|
}
|
|
|
|
this._element = container;
|
|
|
|
View.call(this);
|
|
this.target = server + '?' + QueryString.stringify(params);
|
|
|
|
this._handleUserTriggeredClose =
|
|
this._handleUserTriggeredClose.bind(this);
|
|
}
|
|
module.exports = OAuthWindow;
|
|
|
|
OAuthWindow.prototype = {
|
|
__proto__: View.prototype,
|
|
|
|
get element() {
|
|
return this._element;
|
|
},
|
|
|
|
get isOpen() {
|
|
return !!this.browserFrame;
|
|
},
|
|
|
|
selectors: {
|
|
browserHeader: 'gaia-header',
|
|
browserTitle: 'gaia-header > h1',
|
|
browserContainer: '.browser-container'
|
|
},
|
|
|
|
get browserContainer() {
|
|
return this._findElement('browserContainer', this.element);
|
|
},
|
|
|
|
get browserTitle() {
|
|
return this._findElement('browserTitle', this.element);
|
|
},
|
|
|
|
get browserHeader() {
|
|
return this._findElement('browserHeader', this.element);
|
|
},
|
|
|
|
_handleFinalRedirect: function(url) {
|
|
this.close();
|
|
|
|
if (this.oncomplete) {
|
|
var params;
|
|
|
|
// find query string
|
|
var queryStringIdx = url.indexOf('?');
|
|
if (queryStringIdx !== -1) {
|
|
params = QueryString.parse(url.slice(queryStringIdx + 1));
|
|
}
|
|
|
|
this.oncomplete(params || {});
|
|
}
|
|
},
|
|
|
|
_handleLocationChange: function(url) {
|
|
this.browserTitle.textContent = url;
|
|
},
|
|
|
|
_handleUserTriggeredClose: function() {
|
|
// close the oauth flow
|
|
this.close();
|
|
|
|
// trigger an event so others can cleanup
|
|
if (this.onabort) {
|
|
this.onabort();
|
|
}
|
|
},
|
|
|
|
handleEvent: function(event) {
|
|
switch (event.type) {
|
|
case 'mozbrowserlocationchange':
|
|
var url = event.detail;
|
|
if (url.indexOf(this.params.redirect_uri) === 0) {
|
|
return this._handleFinalRedirect(url);
|
|
}
|
|
this._handleLocationChange(url);
|
|
break;
|
|
}
|
|
},
|
|
|
|
open: function() {
|
|
if (this.browserFrame) {
|
|
throw new Error('attempting to open frame while another is open');
|
|
}
|
|
|
|
// add the active class
|
|
this.element.classList.add(View.ACTIVE);
|
|
|
|
// handle cancel events
|
|
this.browserHeader.addEventListener(
|
|
'action', this._handleUserTriggeredClose
|
|
);
|
|
|
|
// setup browser iframe
|
|
var iframe = this.browserFrame =
|
|
document.createElement('iframe');
|
|
|
|
iframe.setAttribute('mozbrowser', true);
|
|
iframe.setAttribute('src', this.target);
|
|
|
|
this.browserContainer.appendChild(iframe);
|
|
|
|
iframe.addEventListener('mozbrowserlocationchange', this);
|
|
},
|
|
|
|
close: function() {
|
|
if (!this.isOpen) {
|
|
return;
|
|
}
|
|
|
|
this.browserFrame.removeEventListener(
|
|
'mozbrowserlocationchange', this
|
|
);
|
|
|
|
this.browserHeader.removeEventListener(
|
|
'action', this._handleUserTriggeredClose
|
|
);
|
|
|
|
this.element.classList.remove(View.ACTIVE);
|
|
|
|
this.browserFrame.parentNode.removeChild(
|
|
this.browserFrame
|
|
);
|
|
|
|
this.browserFrame = undefined;
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
define('utils/uri',['require','exports','module'],function(require, exports) {
|
|
'use strict';
|
|
|
|
/**
|
|
* Get the port of the url.
|
|
* @param {string} url full url.
|
|
* @return {?number} port number, null if none found.
|
|
*/
|
|
exports.getPort = function(url) {
|
|
var parts = url.split(':');
|
|
if (parts.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
// If we found a port and path, it's the last part
|
|
var candidate = parts[parts.length - 1];
|
|
parts = candidate.split('/');
|
|
|
|
// If we found a port, it's the first part
|
|
candidate = parts[0];
|
|
if (!isInteger(candidate)) {
|
|
return null;
|
|
}
|
|
|
|
return parseInt(candidate, 10);
|
|
};
|
|
|
|
/**
|
|
* Get the scheme of the url. Note that this only detects http and https.
|
|
* @param {string} url full url.
|
|
* @return {?string} uri scheme (ie http), null if none found.
|
|
*/
|
|
exports.getScheme = function(url) {
|
|
var parts = url.split(':');
|
|
if (parts.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
|
|
// If we found a scheme, it's the first part
|
|
var candidate = parts[0];
|
|
if (candidate !== 'http' && candidate !== 'https') {
|
|
return null;
|
|
}
|
|
|
|
return candidate;
|
|
};
|
|
|
|
/**
|
|
* Decide whether or not this string represents an integer.
|
|
* @param {string} str some string.
|
|
* @param {boolean} whether or not str represents an integer.
|
|
*/
|
|
function isInteger(str) {
|
|
return (/^\d+$/).test(str);
|
|
}
|
|
|
|
});
|
|
|
|
|
|
define('views/modify_account',['require','exports','module','models/account','oauth_window','common/presets','utils/uri','view','ext/co','core','common/is_offline','router','dom!modify-account-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Account = require('models/account');
|
|
var OAuthWindow = require('oauth_window');
|
|
var Presets = require('common/presets');
|
|
var URI = require('utils/uri');
|
|
var View = require('view');
|
|
var co = require('ext/co');
|
|
var core = require('core');
|
|
var isOffline = require('common/is_offline');
|
|
var router = require('router');
|
|
|
|
require('dom!modify-account-view');
|
|
|
|
var DEFAULT_AUTH_TYPE = 'basic';
|
|
var OAUTH_AUTH_CREDENTIALS = [
|
|
'client_id',
|
|
'scope',
|
|
'redirect_uri',
|
|
'state'
|
|
];
|
|
|
|
function ModifyAccount(options) {
|
|
View.apply(this, arguments);
|
|
|
|
this.deleteRecord = this.deleteRecord.bind(this);
|
|
this.cancel = this.cancel.bind(this);
|
|
this.displayOAuth2 = this.displayOAuth2.bind(this);
|
|
this.hideHeaderAndForm = this.hideHeaderAndForm.bind(this);
|
|
this.cancelDelete = this.cancelDelete.bind(this);
|
|
|
|
// bound so we can add remove listeners
|
|
this._boundSaveUpdateModel = this.save.bind(this, { updateModel: true });
|
|
}
|
|
module.exports = ModifyAccount;
|
|
|
|
ModifyAccount.prototype = {
|
|
__proto__: View.prototype,
|
|
|
|
_changeToken: 0,
|
|
|
|
selectors: {
|
|
element: '#modify-account-view',
|
|
form: '.modify-account-form',
|
|
fields: '*[name]',
|
|
saveButton: '#modify-account-view .save',
|
|
deleteButton: '#modify-account-view .delete-confirm',
|
|
deleteRecordButton: '.delete-record',
|
|
cancelDeleteButton: '#modify-account-view .delete-cancel',
|
|
header: '#modify-account-header',
|
|
status: '#modify-account-view section[role="status"]',
|
|
errors: '#modify-account-view .errors',
|
|
oauth2Window: '#oauth2',
|
|
oauth2SignIn: '#modify-account-view .force-oauth2'
|
|
},
|
|
|
|
progressClass: 'in-progress',
|
|
removeDialogClass: 'remove-dialog',
|
|
|
|
get authenticationType() {
|
|
if (this.preset && this.preset.authenticationType) {
|
|
return this.preset.authenticationType;
|
|
}
|
|
|
|
return DEFAULT_AUTH_TYPE;
|
|
},
|
|
|
|
get oauth2Window() {
|
|
return this._findElement('oauth2Window');
|
|
},
|
|
|
|
get oauth2SignIn() {
|
|
return this._findElement('oauth2SignIn');
|
|
},
|
|
|
|
get deleteRecordButton() {
|
|
return this._findElement('deleteRecordButton');
|
|
},
|
|
|
|
get deleteButton() {
|
|
return this._findElement('deleteButton');
|
|
},
|
|
|
|
get cancelDeleteButton() {
|
|
return this._findElement('cancelDeleteButton');
|
|
},
|
|
|
|
get header() {
|
|
return this._findElement('header');
|
|
},
|
|
|
|
get saveButton() {
|
|
return this._findElement('saveButton');
|
|
},
|
|
|
|
get form() {
|
|
return this._findElement('form');
|
|
},
|
|
|
|
get fields() {
|
|
if (!this._fields) {
|
|
var result = this._fields = {};
|
|
var elements = this.element.querySelectorAll(
|
|
this.selectors.fields
|
|
);
|
|
|
|
var i = 0;
|
|
var len = elements.length;
|
|
|
|
for (i; i < len; i++) {
|
|
var el = elements[i];
|
|
result[el.getAttribute('name')] = el;
|
|
}
|
|
}
|
|
|
|
return this._fields;
|
|
},
|
|
|
|
updateForm: function() {
|
|
var update = ['user', 'fullUrl'];
|
|
|
|
update.forEach(function(name) {
|
|
var field = this.fields[name];
|
|
field.value = this.model[name];
|
|
}, this);
|
|
},
|
|
|
|
updateModel: function() {
|
|
var update = ['user', 'password', 'fullUrl'];
|
|
|
|
update.forEach(function(name) {
|
|
var field = this.fields[name];
|
|
var value = field.value;
|
|
if (name === 'fullUrl') {
|
|
// Prepend a scheme if url has neither port nor scheme
|
|
var port = URI.getPort(value);
|
|
var scheme = URI.getScheme(value);
|
|
if (!port && !scheme) {
|
|
value = 'https://' + value;
|
|
}
|
|
}
|
|
|
|
this.model[name] = value;
|
|
}, this);
|
|
},
|
|
|
|
deleteRecord: function(e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
// begin the removal (which will emit the preRemove event) but don't wait
|
|
// for it to complete...
|
|
core.bridge.deleteAccount(this.model._id);
|
|
|
|
// semi-hack clear the :target - harmless in tests
|
|
// but important in the current UI because css :target
|
|
// does not get cleared (for some reason)
|
|
window.location.replace('#');
|
|
|
|
// TODO: in the future we may want to store the entry
|
|
// url of this view and use that instead of this
|
|
// hard coded value...
|
|
router.show('/advanced-settings/');
|
|
},
|
|
|
|
cancel: function(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
window.history.back();
|
|
},
|
|
|
|
cancelDelete: function(event) {
|
|
this.element.classList.remove(this.removeDialogClass);
|
|
this.cancel(event);
|
|
},
|
|
|
|
isOffline: isOffline,
|
|
|
|
save: co.wrap(function *(options, e) {
|
|
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
var list = this.element.classList;
|
|
|
|
if (this.isOffline()) {
|
|
this.showErrors([{name: 'offline'}]);
|
|
return;
|
|
}
|
|
|
|
list.add(this.progressClass);
|
|
|
|
this.errors.textContent = '';
|
|
|
|
if (options && options.updateModel) {
|
|
this.updateModel();
|
|
}
|
|
|
|
try {
|
|
yield core.bridge.createAccount(this.model);
|
|
list.remove(this.progressClass);
|
|
router.go(this.completeUrl);
|
|
} catch(err) {
|
|
list.remove(this.progressClass);
|
|
this.showErrors(err);
|
|
}
|
|
}),
|
|
|
|
hideHeaderAndForm: function() {
|
|
this.element.classList.add(this.removeDialogClass);
|
|
},
|
|
|
|
displayOAuth2: function(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
var self = this;
|
|
this.oauth2Window.classList.add(View.ACTIVE);
|
|
|
|
navigator.mozApps.getSelf().onsuccess = function(e) {
|
|
var app = e.target.result;
|
|
app.clearBrowserData().onsuccess = function() {
|
|
self._redirectToOAuthFlow();
|
|
};
|
|
};
|
|
},
|
|
|
|
/**
|
|
* @param {String} preset name of value in Calendar.Presets.
|
|
*/
|
|
_createModel: function(preset, callback) {
|
|
var settings = Presets[preset];
|
|
var model = new Account(settings.options);
|
|
model.preset = preset;
|
|
return model;
|
|
},
|
|
|
|
_redirectToOAuthFlow: function() {
|
|
|
|
var apiCredentials = this.preset.apiCredentials;
|
|
var params = {
|
|
/*
|
|
* code response type for now might change when we can use window.open
|
|
*/
|
|
response_type: 'code',
|
|
/* offline so we get refresh_token[s] */
|
|
access_type: 'offline',
|
|
/* we us force so we always get a refresh_token */
|
|
approval_prompt: 'force'
|
|
};
|
|
|
|
OAUTH_AUTH_CREDENTIALS.forEach(function(key) {
|
|
if (key in apiCredentials) {
|
|
params[key] = apiCredentials[key];
|
|
}
|
|
});
|
|
|
|
var oauth = this._oauthDialog = new OAuthWindow(
|
|
this.oauth2Window,
|
|
apiCredentials.authorizationUrl,
|
|
params
|
|
);
|
|
|
|
var self = this;
|
|
|
|
oauth.open();
|
|
oauth.onabort = function() {
|
|
self.cancel();
|
|
};
|
|
|
|
oauth.oncomplete = function(params) {
|
|
if ('error' in params) {
|
|
// Ruh roh
|
|
return self.cancel();
|
|
}
|
|
|
|
if (!params.code) {
|
|
return console.error('authentication error');
|
|
}
|
|
|
|
// Fistpump!
|
|
self.model.oauth = { code: params.code };
|
|
self.save();
|
|
};
|
|
},
|
|
|
|
render: function() {
|
|
if (!this.model) {
|
|
throw new Error('must provider model to ModifyAccount');
|
|
}
|
|
|
|
this.form.addEventListener('submit', this._boundSaveUpdateModel);
|
|
this.saveButton.addEventListener('click', this._boundSaveUpdateModel);
|
|
this.header.addEventListener('action', this.cancel);
|
|
this.deleteRecordButton.addEventListener('click', this.hideHeaderAndForm);
|
|
|
|
if (this.model._id) {
|
|
this.type = 'update';
|
|
this.deleteButton.addEventListener('click', this.deleteRecord);
|
|
this.cancelDeleteButton.addEventListener('click', this.cancelDelete);
|
|
} else {
|
|
this.type = 'create';
|
|
}
|
|
|
|
var list = this.element.classList;
|
|
list.add(this.type);
|
|
list.add('preset-' + this.model.preset);
|
|
list.add('provider-' + this.model.providerType);
|
|
list.add('auth-' + this.authenticationType);
|
|
|
|
if (this.model.error) {
|
|
list.add('error');
|
|
}
|
|
|
|
if (this.authenticationType === 'oauth2') {
|
|
this.oauth2SignIn.addEventListener('click', this.displayOAuth2);
|
|
|
|
if (this.type === 'create') {
|
|
this.displayOAuth2();
|
|
}
|
|
|
|
this.fields.user.disabled = true;
|
|
this.saveButton.disabled = true;
|
|
}
|
|
|
|
this.form.reset();
|
|
this.updateForm();
|
|
|
|
var usernameType = this.model.usernameType;
|
|
this.fields.user.type = (usernameType === undefined) ?
|
|
'text' : usernameType;
|
|
|
|
this.header.runFontFitSoon();
|
|
},
|
|
|
|
destroy: function() {
|
|
var list = this.element.classList;
|
|
|
|
list.remove(this.type);
|
|
|
|
list.remove('preset-' + this.model.preset);
|
|
list.remove('provider-' + this.model.providerType);
|
|
list.remove('auth-' + this.authenticationType);
|
|
list.remove('error');
|
|
list.remove(this.removeDialogClass);
|
|
|
|
this.fields.user.disabled = false;
|
|
this.saveButton.disabled = false;
|
|
|
|
this._fields = null;
|
|
this.form.reset();
|
|
|
|
this.deleteRecordButton.removeEventListener('click',
|
|
this.hideHeaderAndForm);
|
|
this.oauth2SignIn.removeEventListener('click', this.displayOAuth2);
|
|
this.saveButton.removeEventListener('click', this._boundSaveUpdateModel);
|
|
this.deleteButton.removeEventListener('click', this.deleteRecord);
|
|
this.cancelDeleteButton.removeEventListener('click', this.cancelDelete);
|
|
this.header.removeEventListener('action',
|
|
this.cancel);
|
|
this.form.removeEventListener('submit', this._boundSaveUpdateModel);
|
|
},
|
|
|
|
dispatch: co.wrap(function *(data) {
|
|
if (this.model) {
|
|
this.destroy();
|
|
}
|
|
|
|
var params = data.params;
|
|
var changeToken = ++this._changeToken;
|
|
|
|
this.completeUrl = '/settings/';
|
|
|
|
try {
|
|
var model;
|
|
if (params.id) {
|
|
model = yield core.bridge.getAccount(params.id);
|
|
} else if (params.preset) {
|
|
model = this._createModel(params.preset);
|
|
}
|
|
|
|
this.preset = Presets[model.preset];
|
|
|
|
// race condition another dispatch has queued
|
|
// while we where waiting for an async event.
|
|
if (this._changeToken !== changeToken) {
|
|
return;
|
|
}
|
|
|
|
this.model = model;
|
|
this.render();
|
|
} catch (err) {
|
|
console.error('Error displaying model in ModifyAccount', data, err);
|
|
}
|
|
}),
|
|
|
|
oninactive: function() {
|
|
View.prototype.oninactive.apply(this, arguments);
|
|
|
|
if (this._oauthDialog) {
|
|
this._oauthDialog.close();
|
|
this._oauthDialog = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
define('models/event',['require','exports','module','common/calc','common/probably_parse_int'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Calc = require('common/calc');
|
|
var probablyParseInt = require('common/probably_parse_int');
|
|
|
|
/**
|
|
* Creates a wrapper around a event instance from the db
|
|
*/
|
|
function Event(data) {
|
|
var isNew = false;
|
|
|
|
if (typeof(data) === 'undefined') {
|
|
isNew = true;
|
|
data = Object.create(null);
|
|
data.remote = {};
|
|
}
|
|
|
|
this.data = data;
|
|
/** shortcut */
|
|
var remote = this.remote = this.data.remote;
|
|
|
|
if ('start' in remote && !('startDate' in remote)) {
|
|
remote.startDate = Calc.dateFromTransport(
|
|
remote.start
|
|
);
|
|
}
|
|
|
|
if ('end' in remote && !('endDate' in remote)) {
|
|
remote.endDate = Calc.dateFromTransport(
|
|
remote.end
|
|
);
|
|
}
|
|
|
|
if (isNew) {
|
|
this.resetToDefaults();
|
|
}
|
|
|
|
var start = this.remote.startDate;
|
|
var end = this.remote.endDate;
|
|
|
|
// the typeof check is to see if we have already
|
|
// set the value in resetToDefaults (or prior)
|
|
if (
|
|
typeof(this._isAllDay) === 'undefined' &&
|
|
Calc.isOnlyDate(start) &&
|
|
Calc.isOnlyDate(end)
|
|
) {
|
|
// mostly to handle the case before the time
|
|
// where we actually managed isAllDay as a setter.
|
|
this.isAllDay = true;
|
|
} else {
|
|
// not on prototype intentionally because
|
|
// we need to either need to resetToDefaults
|
|
// or check startDate/endDate in the constructor.
|
|
this.isAllDay = false;
|
|
}
|
|
}
|
|
module.exports = Event;
|
|
|
|
Event.prototype = {
|
|
|
|
/**
|
|
* Sets default values of an event.
|
|
*/
|
|
resetToDefaults: function() {
|
|
var now = new Date();
|
|
|
|
this.isAllDay = false;
|
|
|
|
this.startDate = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate()
|
|
);
|
|
|
|
this.endDate = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate() + 1
|
|
);
|
|
},
|
|
|
|
get _id() {
|
|
return this.data._id;
|
|
},
|
|
|
|
_setDate: function(date, field) {
|
|
if (!(date instanceof Date)) {
|
|
throw new TypeError('must pass instance of Date');
|
|
}
|
|
|
|
var allDay = this.isAllDay;
|
|
|
|
if (allDay) {
|
|
// clone the existing date
|
|
date = new Date(date.valueOf());
|
|
|
|
// filter out the stuff we don't care about
|
|
date.setHours(0);
|
|
date.setMinutes(0);
|
|
date.setSeconds(0);
|
|
date.setMilliseconds(0);
|
|
}
|
|
|
|
this.remote[field] = Calc.dateToTransport(
|
|
date,
|
|
null, // TODO allow setting tzid
|
|
allDay
|
|
);
|
|
|
|
this.remote[field + 'Date'] = date;
|
|
},
|
|
|
|
/* start date */
|
|
|
|
get startDate() {
|
|
return this.remote.startDate;
|
|
},
|
|
|
|
set startDate(value) {
|
|
this._setDate(value, 'start');
|
|
},
|
|
|
|
/* end date */
|
|
|
|
get endDate() {
|
|
return this.remote.endDate;
|
|
},
|
|
|
|
set endDate(value) {
|
|
this._setDate(value, 'end');
|
|
},
|
|
|
|
set isAllDay(value) {
|
|
this._isAllDay = value;
|
|
|
|
// send values through the their setter.
|
|
if (this.endDate) {
|
|
this.endDate = this.endDate;
|
|
}
|
|
|
|
if (this.startDate) {
|
|
this.startDate = this.startDate;
|
|
}
|
|
},
|
|
|
|
get isAllDay() {
|
|
return this._isAllDay;
|
|
},
|
|
|
|
/* associated records */
|
|
|
|
get calendarId() {
|
|
return this.data.calendarId;
|
|
},
|
|
|
|
set calendarId(value) {
|
|
if (value && typeof(value) !== 'number') {
|
|
value = probablyParseInt(value);
|
|
}
|
|
|
|
this.data.calendarId = value;
|
|
},
|
|
|
|
/* simple setters */
|
|
|
|
get syncToken() {
|
|
return this.remote.syncToken;
|
|
},
|
|
|
|
set syncToken(value) {
|
|
this.remote.syncToken = value;
|
|
},
|
|
|
|
get title() {
|
|
return this.remote.title || '';
|
|
},
|
|
|
|
set title(value) {
|
|
this.remote.title = value;
|
|
},
|
|
|
|
get description() {
|
|
return this.remote.description || '';
|
|
},
|
|
|
|
set description(value) {
|
|
this.remote.description = value;
|
|
},
|
|
|
|
get location() {
|
|
return this.remote.location || '';
|
|
},
|
|
|
|
set location(value) {
|
|
this.remote.location = value;
|
|
return this.remote.location;
|
|
},
|
|
|
|
get alarms() {
|
|
return this.remote.alarms || [];
|
|
},
|
|
|
|
set alarms(value) {
|
|
this.remote.alarms = value;
|
|
return this.remote.alarms;
|
|
},
|
|
|
|
/**
|
|
* If data doesn't have any errors, the event
|
|
* takes on the attributes of data.
|
|
*
|
|
* @param {Object} data, object that contains
|
|
* at least some attributes of the event object.
|
|
*
|
|
* @return {Object} errors if validationErrors returns erros,
|
|
* true otherwise.
|
|
*/
|
|
updateAttributes: function(data) {
|
|
var errors = this.validationErrors(data);
|
|
if (errors) {
|
|
return errors;
|
|
}
|
|
for (var field in data) {
|
|
this[field] = data[field];
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Validates the contents of the model.
|
|
*
|
|
* Output example:
|
|
*
|
|
* [
|
|
* {
|
|
* name: 'invalidDate',
|
|
* properties: ['startDate', 'endDate']
|
|
* }
|
|
* //...
|
|
* ]
|
|
*
|
|
* @param {Object} data, optional object that contains
|
|
* at least some attributes of the event object.
|
|
* @return {Array|False} see above.
|
|
*/
|
|
validationErrors: function(data) {
|
|
var obj = data || this;
|
|
var end = obj.endDate.valueOf();
|
|
var start = obj.startDate.valueOf();
|
|
var errors = [];
|
|
|
|
if (start >= end) {
|
|
errors.push({
|
|
name: 'start-after-end'
|
|
});
|
|
}
|
|
|
|
if (errors.length) {
|
|
return errors;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
define('views/event_base',['require','exports','module','models/event','view','ext/co','core','common/calc','common/next_tick','router'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Event = require('models/event');
|
|
var View = require('view');
|
|
var co = require('ext/co');
|
|
var core = require('core');
|
|
var isToday = require('common/calc').isToday;
|
|
var nextTick = require('common/next_tick');
|
|
var router = require('router');
|
|
|
|
function EventBase(options) {
|
|
View.apply(this, arguments);
|
|
|
|
this._els = Object.create(null);
|
|
this._changeToken = 0;
|
|
|
|
this.cancel = this.cancel.bind(this);
|
|
this.primary = this.primary.bind(this);
|
|
this._initEvents();
|
|
}
|
|
module.exports = EventBase;
|
|
|
|
EventBase.prototype = {
|
|
__proto__: View.prototype,
|
|
|
|
READONLY: 'readonly',
|
|
CREATE: 'create',
|
|
UPDATE: 'update',
|
|
PROGRESS: 'in-progress',
|
|
ALLDAY: 'allday',
|
|
LOADING: 'loading',
|
|
|
|
DEFAULT_VIEW: '/month/',
|
|
|
|
_initEvents: function() {
|
|
this.header.addEventListener('action', this.cancel);
|
|
this.primaryButton.addEventListener('click', this.primary);
|
|
},
|
|
|
|
uiSelector: '.%',
|
|
|
|
get header() {
|
|
return this._findElement('header');
|
|
},
|
|
|
|
get primaryButton() {
|
|
return this._findElement('primaryButton');
|
|
},
|
|
|
|
get fieldRoot() {
|
|
return this.element;
|
|
},
|
|
|
|
/**
|
|
* Returns the url the view will "redirect" to
|
|
* after completing the current add/edit/delete operation.
|
|
*
|
|
* @return {String} redirect url.
|
|
*/
|
|
returnTo: function() {
|
|
var path = this._returnTo || this.DEFAULT_VIEW;
|
|
return path;
|
|
},
|
|
|
|
/**
|
|
* Returns the top level URL, or returnTo()
|
|
* Resets the returnTop variable so we can override on next visit
|
|
*/
|
|
returnTop: function() {
|
|
var path = this._returnTop || this.returnTo();
|
|
delete this._returnTop;
|
|
return path;
|
|
},
|
|
|
|
/**
|
|
* Dismiss modification and go back to previous screen.
|
|
*/
|
|
cancel: function() {
|
|
window.history.back();
|
|
},
|
|
|
|
/**
|
|
* This method is overridden
|
|
*/
|
|
primary: function() {},
|
|
|
|
/**
|
|
* This method is overridden
|
|
*/
|
|
_markReadonly: function() {},
|
|
|
|
/**
|
|
* When the event is something like this:
|
|
* 2012-01-02 and we detect this is an all day event
|
|
* we want to display the end date like this 2012-01-02.
|
|
*/
|
|
formatEndDate: function(endDate) {
|
|
if (
|
|
endDate.getHours() === 0 &&
|
|
endDate.getSeconds() === 0 &&
|
|
endDate.getMinutes() === 0
|
|
) {
|
|
// subtract the date to give the user a better
|
|
// idea of which dates the event spans...
|
|
endDate = new Date(
|
|
endDate.getFullYear(),
|
|
endDate.getMonth(),
|
|
endDate.getDate() - 1
|
|
);
|
|
}
|
|
|
|
return endDate;
|
|
},
|
|
|
|
/**
|
|
* Assigns and displays event & busytime information.
|
|
* Marks view as "loading"
|
|
*
|
|
* @param {{event, busytime, capabilities}} record
|
|
*/
|
|
_useModel: function(record) {
|
|
this.record = record;
|
|
this.event = new Event(record.event);
|
|
this.busytime = record.busytime;
|
|
this.originalCalendar = record.calendar;
|
|
|
|
if (!record.capabilities.canUpdate) {
|
|
this._markReadonly(true);
|
|
this.element.classList.add(this.READONLY);
|
|
}
|
|
|
|
// inheritance hook...
|
|
this._updateUI();
|
|
},
|
|
|
|
/** override me! **/
|
|
_updateUI: function() {},
|
|
|
|
/**
|
|
* Loads event and triggers form update.
|
|
* Gracefully will handle race conditions
|
|
* if rapidly switching between events.
|
|
* TODO: This token may no longer be needed
|
|
* as we have an aria-disabled guard now.
|
|
*
|
|
* @param {String} id busytime id.
|
|
*/
|
|
_loadModel: co.wrap(function *(id) {
|
|
var token = ++this._changeToken;
|
|
var classList = this.element.classList;
|
|
var result = Promise.resolve();
|
|
|
|
classList.add(this.LOADING);
|
|
|
|
try {
|
|
var record = yield core.bridge.fetchRecord(id);
|
|
if (token === this._changeToken) {
|
|
this._useModel(record);
|
|
}
|
|
} catch(err) {
|
|
console.error(`Error looking up records for id: ${id}`);
|
|
console.error(`${err.message}\n${err.stack}`);
|
|
result = Promise.reject(err);
|
|
}
|
|
|
|
classList.remove(this.LOADING);
|
|
|
|
return result;
|
|
}),
|
|
|
|
/**
|
|
* Builds and sets defaults for a new model.
|
|
*
|
|
* @return {Calendar.Models.Model} new model.
|
|
*/
|
|
_createModel: function(time) {
|
|
// time can be null in some cases, default to today (eg. unit tests)
|
|
time = time || new Date();
|
|
|
|
this._setDefaultHour(time);
|
|
|
|
var model = new Event();
|
|
model.startDate = time;
|
|
|
|
var end = new Date(time.valueOf());
|
|
end.setHours(end.getHours() + 1);
|
|
|
|
model.endDate = end;
|
|
|
|
return model;
|
|
},
|
|
|
|
_setDefaultHour: function(date) {
|
|
if (isToday(date)) {
|
|
var now = new Date();
|
|
// events created today default to begining of the next hour
|
|
date.setHours(now.getHours() + 1, 0, 0, 0);
|
|
} else {
|
|
// events created on other days default to 8AM
|
|
date.setHours(8, 0, 0, 0);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Gets and caches an element by selector
|
|
*/
|
|
getEl: function(name) {
|
|
if (!(name in this._els)) {
|
|
var el = this.fieldRoot.querySelector(
|
|
this.uiSelector.replace('%', name)
|
|
);
|
|
if (el) {
|
|
this._els[name] = el;
|
|
}
|
|
}
|
|
return this._els[name];
|
|
},
|
|
|
|
oninactive: function() {
|
|
View.prototype.oninactive.apply(this, arguments);
|
|
},
|
|
|
|
/**
|
|
* Handles the url parameters for when this view
|
|
* comes into focus.
|
|
*
|
|
* When the (busytime) id parameter is given the event will
|
|
* be found via the time controller.
|
|
*/
|
|
dispatch: function(data) {
|
|
// always remove loading initially (to prevent worst case)
|
|
this.element.classList.remove(this.LOADING);
|
|
|
|
// Re-run the header font fit when it comes into view.
|
|
// Since the header is already in the markup on load and the view is hidden
|
|
// the font fit calculations will be wrong initially.
|
|
this.header.runFontFitSoon();
|
|
|
|
var id = data.params.id;
|
|
var classList = this.element.classList;
|
|
var last = router.last;
|
|
|
|
if (last && last.path) {
|
|
if (!(/^\/(day|event|month|week)/.test(last.path))) {
|
|
// We came from some place suspicious so fall back to default.
|
|
this._returnTo = this.DEFAULT_VIEW;
|
|
} else {
|
|
// Return to the default view if we just added an event.
|
|
// Else go back to where we came from.
|
|
this._returnTo = /^\/event\/add\//.test(last.path) ?
|
|
this.DEFAULT_VIEW : last.path;
|
|
}
|
|
}
|
|
|
|
if (!this._returnTop && this._returnTo) {
|
|
this._returnTop = this._returnTo;
|
|
}
|
|
|
|
var completeDispatch = () => this.ondispatch && this.ondispatch();
|
|
|
|
if (id) {
|
|
classList.add(this.UPDATE);
|
|
|
|
this._loadModel(id).then(completeDispatch);
|
|
} else {
|
|
classList.add(this.CREATE);
|
|
|
|
var controller = core.timeController;
|
|
this.event = this._createModel(controller.mostRecentDay);
|
|
this._updateUI();
|
|
|
|
nextTick(completeDispatch);
|
|
}
|
|
|
|
this.primaryButton.removeAttribute('aria-disabled');
|
|
},
|
|
|
|
onfirstseen: function() {}
|
|
|
|
};
|
|
|
|
});
|
|
|
|
/* exported InputParser */
|
|
|
|
|
|
/**
|
|
* Stateless object for input parser functions..
|
|
* The intent is the methods here will only relate to the parsing
|
|
* of input[type="date|time"]
|
|
*/
|
|
var InputParser = (function() {
|
|
|
|
var InputParser = {
|
|
_dateParts: ['year', 'month', 'date'],
|
|
_timeParts: ['hours', 'minutes', 'seconds'],
|
|
|
|
/**
|
|
* Import HTML5 input[type="time"] string value
|
|
*
|
|
* @param {String} value 23:20:50.52, 17:39:57.
|
|
* @return {Object} { hours: 23, minutes: 20, seconds: 50 }.
|
|
*/
|
|
importTime: function(value) {
|
|
var result = {
|
|
hours: 0,
|
|
minutes: 0,
|
|
seconds: 0
|
|
};
|
|
|
|
if (!value) {
|
|
return result;
|
|
}
|
|
|
|
|
|
var parts = value.split(':');
|
|
var part;
|
|
var partName;
|
|
|
|
var i = 0;
|
|
var len = InputParser._timeParts.length;
|
|
|
|
for (; i < len; i++) {
|
|
partName = InputParser._timeParts[i];
|
|
part = parts[i];
|
|
if (part) {
|
|
result[partName] = parseInt(part.slice(0, 2), 10) || 0;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Export date to HTML5 input[type="time"]
|
|
*
|
|
* @param {Date} value export value.
|
|
* @return {String} 17:39:57.
|
|
*/
|
|
exportTime: function(value) {
|
|
return value.toLocaleString('en-US', {
|
|
hour12: false,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Import HTML5 input[type="time"] to object.
|
|
*
|
|
* @param {String} value 1997-12-19.
|
|
* @return {Object} { year: 1997, month: 12, date: 19 }.
|
|
*/
|
|
importDate: function(value) {
|
|
var result = {
|
|
year: 0,
|
|
month: 0,
|
|
date: 0
|
|
};
|
|
|
|
if (!value) {
|
|
return result;
|
|
}
|
|
|
|
var parts = value.split('-');
|
|
var part;
|
|
var partName;
|
|
|
|
var i = 0;
|
|
var len = InputParser._dateParts.length;
|
|
|
|
for (; i < len; i++) {
|
|
partName = InputParser._dateParts[i];
|
|
part = parts[i];
|
|
if (part) {
|
|
result[partName] = parseInt(part, 10);
|
|
}
|
|
}
|
|
|
|
if (result.month > 0) {
|
|
result.month = result.month - 1;
|
|
}
|
|
|
|
result.date = result.date || 1;
|
|
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Export js local date to HTML5 input[type="date"].
|
|
*
|
|
* @param {Date} value export value.
|
|
* @return {String} date string (1997-12-19).
|
|
*/
|
|
exportDate: function(value) {
|
|
var year = value.getFullYear();
|
|
var month = value.getMonth() + 1;
|
|
var day = value.getDate();
|
|
|
|
if (month < 10) {
|
|
month = `0${month}`;
|
|
}
|
|
if (day < 10) {
|
|
day = `0${day}`;
|
|
}
|
|
return `${year}-${month}-${day}`;
|
|
},
|
|
|
|
/**
|
|
* Designed to take a date & time value from
|
|
* html5 input types and returns a JS Date.
|
|
*
|
|
* @param {String} date input date.
|
|
* @param {String} time input time.
|
|
*
|
|
* @return {Date} full date object from date/time.
|
|
*/
|
|
formatInputDate: function(date, time) {
|
|
time = InputParser.importTime(time);
|
|
date = InputParser.importDate(date);
|
|
|
|
return new Date(
|
|
date.year,
|
|
date.month,
|
|
date.date,
|
|
time.hours,
|
|
time.minutes,
|
|
time.seconds
|
|
);
|
|
},
|
|
};
|
|
|
|
return InputParser;
|
|
}());
|
|
|
|
define("shared/input_parser", (function (global) {
|
|
return function () {
|
|
var ret, fn;
|
|
return ret || global.InputParser;
|
|
};
|
|
}(this)));
|
|
|
|
|
|
define('views/modify_event',['require','exports','module','templates/alarm','./event_base','shared/input_parser','shared/intl_helper','querystring','ext/co','core','common/constants','notification','router','./factory','dom!modify-event-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var AlarmTemplate = require('templates/alarm');
|
|
var EventBase = require('./event_base');
|
|
var InputParser = require('shared/input_parser');
|
|
var IntlHelper = require('shared/intl_helper');
|
|
var QueryString = require('querystring');
|
|
var co = require('ext/co');
|
|
var core = require('core');
|
|
var localCalendarId = require('common/constants').localCalendarId;
|
|
var notification = require('notification');
|
|
var router = require('router');
|
|
var viewFactory = require('./factory');
|
|
|
|
require('dom!modify-event-view');
|
|
|
|
IntlHelper.define('formalDate', 'datetime', {
|
|
month: '2-digit',
|
|
year: '2-digit',
|
|
day: '2-digit'
|
|
});
|
|
|
|
IntlHelper.define('shortTime', 'datetime', {
|
|
hour: 'numeric',
|
|
minute: 'numeric'
|
|
});
|
|
|
|
function ModifyEvent(options) {
|
|
this.deleteRecord = this.deleteRecord.bind(this);
|
|
this._toggleAllDay = this._toggleAllDay.bind(this);
|
|
EventBase.apply(this, arguments);
|
|
}
|
|
module.exports = ModifyEvent;
|
|
|
|
ModifyEvent.prototype = {
|
|
__proto__: EventBase.prototype,
|
|
|
|
ERROR_PREFIX: 'event-error-',
|
|
|
|
MAX_ALARMS: 5,
|
|
|
|
formats: {
|
|
date: 'formalDate',
|
|
time: 'shortTime',
|
|
},
|
|
|
|
selectors: {
|
|
element: '#modify-event-view',
|
|
alarmList: '#modify-event-view .alarms',
|
|
form: '#modify-event-view form',
|
|
startTimeLocale: '#start-time-locale',
|
|
endDateLocale: '#end-date-locale',
|
|
endTimeLocale: '#end-time-locale',
|
|
status: '#modify-event-view section[role="status"]',
|
|
errors: '#modify-event-view .errors',
|
|
primaryButton: '#modify-event-view .save',
|
|
deleteButton: '#modify-event-view .delete-record',
|
|
header: '#modify-event-header'
|
|
},
|
|
|
|
uiSelector: '[name="%"]',
|
|
|
|
_duration: 0, // The duration between start and end dates.
|
|
|
|
_initEvents: function() {
|
|
EventBase.prototype._initEvents.apply(this, arguments);
|
|
|
|
this.deleteButton.addEventListener('click', this.deleteRecord);
|
|
this.form.addEventListener('click', this.focusHandler);
|
|
this.form.addEventListener('submit', this.primary);
|
|
this.form.addEventListener('input', this._toggleSave.bind(this));
|
|
|
|
var allday = this.getEl('allday');
|
|
allday.addEventListener('change', this._toggleAllDay);
|
|
|
|
this.alarmList.addEventListener('change', this._changeAlarm.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Fired when the allday checkbox changes.
|
|
*/
|
|
_toggleAllDay: function(e) {
|
|
var allday = this.getEl('allday').checked;
|
|
|
|
if (allday) {
|
|
// enable case
|
|
this.element.classList.add(this.ALLDAY);
|
|
} else {
|
|
// disable case
|
|
this.element.classList.remove(this.ALLDAY);
|
|
if (e) {
|
|
// only reset the start/end time if coming from an user interaction
|
|
this._resetDateTime();
|
|
}
|
|
}
|
|
|
|
// because of race conditions it is theoretically possible
|
|
// for the user to check/uncheck this value
|
|
// when we don't actually have a model loaded.
|
|
if (this.event) {
|
|
this.event.isAllDay = !!allday;
|
|
}
|
|
|
|
// Reset alarms if we come from a user event
|
|
if (e) {
|
|
this.event.alarms = [];
|
|
this.updateAlarms(allday);
|
|
}
|
|
},
|
|
|
|
_resetDateTime: function() {
|
|
// if start event was "all day" and switch to regular event start/end time
|
|
// will be the same, so we reset to default start time, otherwise we keep
|
|
// the previously selected value
|
|
var startDateTime = this._getStartDateTime();
|
|
if (startDateTime === this._getEndDateTime()) {
|
|
var startDate = new Date(startDateTime);
|
|
this._setDefaultHour(startDate);
|
|
this.getEl('startTime').value = InputParser.exportTime(startDate);
|
|
this._renderDateTimeLocale(
|
|
this._findElement('startTimeLocale'), startDate);
|
|
// default event duration is 1 hour
|
|
this._duration = 60 * 60 * 1000;
|
|
this._setEndDateTimeWithCurrentDuration();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Called when any alarm is changed
|
|
*/
|
|
_changeAlarm: function(e) {
|
|
var template = AlarmTemplate;
|
|
if (e.target.value == 'none') {
|
|
var parent = e.target.parentNode;
|
|
parent.parentNode.removeChild(parent);
|
|
return;
|
|
}
|
|
|
|
// Append a new alarm select only if we don't have an empty one or if we
|
|
// didn't reach the maximum number of alarms
|
|
var alarms = this.queryAlarms();
|
|
if (alarms.length >= this.MAX_ALARMS ||
|
|
alarms.some(el => el.value === 'none')) {
|
|
return;
|
|
}
|
|
|
|
var newAlarm = document.createElement('div');
|
|
newAlarm.innerHTML = template.picker.render({
|
|
layout: this.event.isAllDay ? 'allday' : 'standard'
|
|
});
|
|
this.alarmList.appendChild(newAlarm);
|
|
},
|
|
|
|
/**
|
|
* Check if current event has been stored in the database
|
|
*/
|
|
isSaved: function() {
|
|
return this.element.classList.contains(this.UPDATE);
|
|
},
|
|
|
|
/**
|
|
* Build the initial list of calendar ids.
|
|
*/
|
|
onfirstseen: function() {
|
|
// we need to notify users (specially automation tests) somehow that the
|
|
// options are still being loaded from DB, this is very important to
|
|
// avoid race conditions (eg. trying to set calendar before list is
|
|
// built) notice that we also add the class to the markup because on some
|
|
// really rare occasions "onfirstseen" is called after the EventBase
|
|
// removed the "loading" class from the root element (seen it happen less
|
|
// than 1% of the time)
|
|
this.getEl('calendarId').classList.add(this.LOADING);
|
|
},
|
|
|
|
_renderCalendars: function(records) {
|
|
// remove old calendars (this will be called every time we have a change on
|
|
// any calendar)
|
|
var select = this.getEl('calendarId');
|
|
// remove all the options
|
|
select.options.length = 0;
|
|
select.classList.remove(this.LOADING);
|
|
|
|
records
|
|
.filter(r => r.capabilities.canCreateEvent)
|
|
.forEach(record => {
|
|
var id = record.calendar._id;
|
|
var {name} = record.calendar.remote;
|
|
var option = document.createElement('option');
|
|
|
|
if (id === localCalendarId) {
|
|
option.text = navigator.mozL10n.get('calendar-local');
|
|
option.setAttribute('data-l10n-id', 'calendar-local');
|
|
} else {
|
|
option.text = name;
|
|
}
|
|
|
|
option.value = id;
|
|
select.add(option);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Mark all field's readOnly flag.
|
|
*
|
|
* @param {Boolean} boolean true/false.
|
|
*/
|
|
_markReadonly: function(boolean) {
|
|
var i = 0;
|
|
var fields = this.form.querySelectorAll('[name]');
|
|
var len = fields.length;
|
|
|
|
for (; i < len; i++) {
|
|
fields[i].readOnly = boolean;
|
|
}
|
|
},
|
|
|
|
queryAlarms: function() {
|
|
return Array.from(document.querySelectorAll('[name="alarm[]"]'));
|
|
},
|
|
|
|
get alarmList() {
|
|
return this._findElement('alarmList');
|
|
},
|
|
|
|
get form() {
|
|
return this._findElement('form');
|
|
},
|
|
|
|
get deleteButton() {
|
|
return this._findElement('deleteButton');
|
|
},
|
|
|
|
get fieldRoot() {
|
|
return this.form;
|
|
},
|
|
|
|
/**
|
|
* Ask the provider to persist an event:
|
|
*
|
|
* 1. update the model with form data
|
|
*
|
|
* 2. send it to the provider if it has the capability
|
|
*
|
|
* 3. set the position of the calendar to startDate of new/edited event.
|
|
*
|
|
* 4. redirect to last view.
|
|
*
|
|
* For now both update & create share the same
|
|
* behaviour (redirect) in the future we may change this.
|
|
*/
|
|
_persistEvent: co.wrap(function *(method) {
|
|
// create model data
|
|
var data = this.formData();
|
|
var errors;
|
|
|
|
// we check explicitly for true, because the alternative
|
|
// is an error object.
|
|
if ((errors = this.event.updateAttributes(data)) !== true) {
|
|
this.showErrors(errors);
|
|
return;
|
|
}
|
|
|
|
// can't create without a calendar id
|
|
// because of defaults this should be impossible.
|
|
if (!data.calendarId) {
|
|
return;
|
|
}
|
|
|
|
// mark view as 'in progress' so we can style
|
|
// it via css during that time period
|
|
this.element.classList.add(this.PROGRESS);
|
|
|
|
try {
|
|
// need to store date before async operation (event might change)
|
|
var moveDate = this.event.startDate;
|
|
yield core.bridge[method](this.event);
|
|
// move the position in the calendar to the added/edited day
|
|
core.timeController.move(moveDate);
|
|
// order is important the above method triggers the building
|
|
// of the dom elements so selectedDay must come after.
|
|
core.timeController.selectedDay = moveDate;
|
|
this._navigateAfterPersist(method, moveDate);
|
|
} catch(err) {
|
|
this.showErrors(err);
|
|
}
|
|
|
|
this.element.classList.remove(this.PROGRESS);
|
|
}),
|
|
|
|
_navigateAfterPersist: function(method, moveDate) {
|
|
// we pass the date so we are able to scroll to the event on the
|
|
// day/week views
|
|
var state = {
|
|
eventStartHour: moveDate.getHours()
|
|
};
|
|
|
|
if (method === 'updateEvent') {
|
|
// If we edit a view our history stack looks like:
|
|
// /week -> /event/view -> /event/save -> /event/view
|
|
// We need to return all the way to the top of the stack
|
|
// We can remove this once we have a history stack
|
|
viewFactory.get('ViewEvent', v => router.go(v.returnTop(), state));
|
|
return;
|
|
}
|
|
|
|
router.go(this.returnTo(), state);
|
|
},
|
|
|
|
/**
|
|
* Deletes current record if provider is present and has the capability.
|
|
*/
|
|
deleteRecord: co.wrap(function *(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (!this.isSaved()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
yield notification.revokeNotificationsForEvent(this.event);
|
|
yield core.bridge.deleteEvent(this.event);
|
|
// If we edit a view our history stack looks like:
|
|
// /week -> /event/view -> /event/save -> /event/view
|
|
// We need to return all the way to the top of the stack
|
|
// We can remove this once we have a history stack
|
|
viewFactory.get('ViewEvent', view => router.go(view.returnTop()));
|
|
} catch (err) {
|
|
this.showErrors(err);
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Persist current model.
|
|
*/
|
|
primary: function(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
// Disable the button on primary event to avoid race conditions
|
|
this.disablePrimary();
|
|
|
|
if (this.isSaved()) {
|
|
this._persistEvent('updateEvent');
|
|
} else {
|
|
this._persistEvent('createEvent');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Enlarges focus areas for .button controls
|
|
*/
|
|
focusHandler: function(e) {
|
|
var input = e.target.querySelector('input, select');
|
|
if (input && e.target.classList.contains('button')) {
|
|
input.focus();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Export form information into a format
|
|
* the model can understand.
|
|
*
|
|
* @return {Object} formatted data suitable
|
|
* for use with Calendar.Model.Event.
|
|
*/
|
|
formData: function() {
|
|
var fields = {
|
|
title: this.getEl('title').value,
|
|
location: this.getEl('location').value,
|
|
description: this.getEl('description').value,
|
|
calendarId: this.getEl('calendarId').value
|
|
};
|
|
|
|
var startTime;
|
|
var endTime;
|
|
var allday = this.getEl('allday').checked;
|
|
|
|
if (allday) {
|
|
startTime = null;
|
|
endTime = null;
|
|
} else {
|
|
startTime = this.getEl('startTime').value;
|
|
endTime = this.getEl('endTime').value;
|
|
}
|
|
|
|
fields.startDate = InputParser.formatInputDate(
|
|
this.getEl('startDate').value,
|
|
startTime
|
|
);
|
|
|
|
fields.endDate = InputParser.formatInputDate(
|
|
this.getEl('endDate').value,
|
|
endTime
|
|
);
|
|
|
|
// Yahoo uses the same start and end date & time for all day events.
|
|
// In order to distinguish all-day event from Yahoo calendar and event
|
|
// with 0 duration we set duration of the event starting and ending at
|
|
// the same date and time to 1 millisecond.
|
|
if (!allday && fields.endDate.getTime() === fields.startDate.getTime()) {
|
|
fields.endDate.setTime(
|
|
fields.startDate.getTime() + 1
|
|
);
|
|
}
|
|
|
|
if (allday) {
|
|
// when the event is all day we display the same
|
|
// day that the entire event spans but we must actually
|
|
// end the event at the first second, minute hour of the next
|
|
// day. This will ensure the server handles it as an all day event.
|
|
fields.endDate.setDate(
|
|
fields.endDate.getDate() + 1
|
|
);
|
|
}
|
|
|
|
fields.alarms = [];
|
|
var triggers = ['none'];
|
|
this.queryAlarms().forEach(alarm => {
|
|
if (triggers.indexOf(alarm.value) !== -1) {
|
|
return;
|
|
}
|
|
|
|
triggers.push(alarm.value);
|
|
|
|
fields.alarms.push({
|
|
action: 'DISPLAY',
|
|
trigger: parseInt(alarm.value, 10)
|
|
});
|
|
});
|
|
|
|
return fields;
|
|
},
|
|
|
|
enablePrimary: function() {
|
|
var btn = this.primaryButton;
|
|
btn.removeAttribute('aria-disabled');
|
|
btn.removeAttribute('disabled');
|
|
},
|
|
|
|
disablePrimary: function() {
|
|
var btn = this.primaryButton;
|
|
btn.setAttribute('aria-disabled', 'true');
|
|
btn.setAttribute('disabled', 'disabled');
|
|
},
|
|
|
|
/**
|
|
* Re-enable the primary button when we show errors
|
|
*/
|
|
showErrors: function() {
|
|
this.enablePrimary();
|
|
EventBase.prototype.showErrors.apply(this, arguments);
|
|
},
|
|
|
|
/**
|
|
* Read the urlparams and override stuff on our event model.
|
|
* @param {string} search Optional string of the form ?foo=bar&cat=dog.
|
|
* @private
|
|
*/
|
|
_overrideEvent: function(search) {
|
|
if (!search) {
|
|
var hash = window.location.hash;
|
|
search = hash.split('?')[1];
|
|
}
|
|
|
|
if (!search || search.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Remove the question mark that begins the search.
|
|
if (search.substr(0, 1) === '?') {
|
|
search = search.substr(1, search.length - 1);
|
|
}
|
|
|
|
var field, value;
|
|
// Parse the urlparams.
|
|
var params = QueryString.parse(search);
|
|
for (field in params) {
|
|
value = params[field];
|
|
switch (field) {
|
|
case ModifyEvent.OverrideableField.START_DATE:
|
|
case ModifyEvent.OverrideableField.END_DATE:
|
|
params[field] = new Date(value);
|
|
break;
|
|
default:
|
|
params[field] = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Override fields on our event.
|
|
var model = this.event;
|
|
for (field in ModifyEvent.OverrideableField) {
|
|
value = ModifyEvent.OverrideableField[field];
|
|
model[value] = params[value] || model[value];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Updates form to use values from the current model.
|
|
*
|
|
* Does not handle readonly flags or calenarId associations.
|
|
* Suitable for use in pre-populating values for both new and
|
|
* existing events.
|
|
*
|
|
* Resets any value on the current form.
|
|
*/
|
|
_updateUI: function() {
|
|
this._overrideEvent();
|
|
this.form.reset();
|
|
|
|
var model = this.event;
|
|
this.getEl('title').value = model.title;
|
|
this.getEl('location').value = model.location;
|
|
var dateSrc = model;
|
|
if (model.remote.isRecurring && this.busytime) {
|
|
dateSrc = this.busytime;
|
|
}
|
|
|
|
var startDate = dateSrc.startDate;
|
|
var endDate = dateSrc.endDate;
|
|
this._duration = endDate.getTime() - startDate.getTime();
|
|
|
|
// update the allday status of the view
|
|
var allday = this.getEl('allday');
|
|
if (allday && (allday.checked = model.isAllDay)) {
|
|
this._toggleAllDay();
|
|
endDate = this.formatEndDate(endDate);
|
|
}
|
|
|
|
this.getEl('startDate').value = InputParser.exportDate(startDate);
|
|
this._setupDateTimeSync('startDate', 'start-date-locale', startDate);
|
|
|
|
this.getEl('endDate').value = InputParser.exportDate(endDate);
|
|
this._setupDateTimeSync('endDate', 'end-date-locale', endDate);
|
|
|
|
this.getEl('startTime').value = InputParser.exportTime(startDate);
|
|
this._setupDateTimeSync('startTime', 'start-time-locale', startDate);
|
|
|
|
this.getEl('endTime').value = InputParser.exportTime(endDate);
|
|
this._setupDateTimeSync('endTime', 'end-time-locale', endDate);
|
|
|
|
this.getEl('description').textContent = model.description;
|
|
|
|
// update calendar id
|
|
this.getEl('calendarId').value = model.calendarId;
|
|
|
|
// calendar display
|
|
var currentCalendar = this.getEl('currentCalendar');
|
|
|
|
if (this.originalCalendar) {
|
|
currentCalendar.value =
|
|
this.originalCalendar.remote.name;
|
|
|
|
currentCalendar.readOnly = true;
|
|
}
|
|
|
|
this.updateAlarms(model.isAllDay);
|
|
this._toggleSave();
|
|
},
|
|
|
|
_toggleSave: function() {
|
|
var enabled = this.getEl('title').value || this.getEl('location').value;
|
|
if (enabled) {
|
|
this.enablePrimary();
|
|
} else {
|
|
this.disablePrimary();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handling a layer over <input> to have localized
|
|
* date/time
|
|
*/
|
|
_setupDateTimeSync: function(src, target, value) {
|
|
var targetElement = document.getElementById(target);
|
|
if (!targetElement) {
|
|
return;
|
|
}
|
|
this._renderDateTimeLocale(targetElement, value);
|
|
|
|
var type = targetElement.dataset.type;
|
|
var callback = type === 'date' ?
|
|
this._updateDateLocaleOnInput : this._updateTimeLocaleOnInput;
|
|
|
|
this.getEl(src)
|
|
.addEventListener('input', function(e) {
|
|
callback.call(this, targetElement, e);
|
|
|
|
// We only auto change the end date and end time
|
|
// when user changes start date or start time,
|
|
// or end datetime is before start datetime
|
|
// after changing end date or end time.
|
|
// Otherwise, we don't auto change end date and end time.
|
|
if (targetElement.id === 'start-date-locale' ||
|
|
targetElement.id === 'start-time-locale') {
|
|
this._setEndDateTimeWithCurrentDuration();
|
|
} else if (this._getEndDateTime() < this._getStartDateTime()) {
|
|
this._setEndDateTimeWithCurrentDuration();
|
|
this.showErrors({
|
|
name: type === 'date' ?
|
|
'start-date-after-end-date' :
|
|
'start-time-after-end-time'
|
|
});
|
|
}
|
|
|
|
this._duration = this._getEndDateTime() - this._getStartDateTime();
|
|
}.bind(this));
|
|
},
|
|
|
|
_setEndDateTimeWithCurrentDuration: function() {
|
|
var date = new Date(this._getStartDateTime() + this._duration);
|
|
var endDateLocale = this._findElement('endDateLocale');
|
|
var endTimeLocale = this._findElement('endTimeLocale');
|
|
this.getEl('endDate').value = InputParser.exportDate(date);
|
|
this.getEl('endTime').value = InputParser.exportTime(date);
|
|
this._renderDateTimeLocale(endDateLocale, date);
|
|
this._renderDateTimeLocale(endTimeLocale, date);
|
|
},
|
|
|
|
_getStartDateTime: function() {
|
|
return new Date(this.getEl('startDate').value + 'T' +
|
|
this.getEl('startTime').value).getTime();
|
|
},
|
|
|
|
_getEndDateTime: function() {
|
|
return new Date(this.getEl('endDate').value + 'T' +
|
|
this.getEl('endTime').value).getTime();
|
|
},
|
|
|
|
_renderDateTimeLocale: function(targetElement, value) {
|
|
// we inject the targetElement to make it easier to test
|
|
var type = targetElement.dataset.type;
|
|
var formatName = this.formats[type];
|
|
var formatter = IntlHelper.get(formatName);
|
|
targetElement.textContent = formatter.format(value);
|
|
// we need to store the format and date for l10n
|
|
targetElement.setAttribute('data-l10n-date-format', formatName);
|
|
targetElement.dataset.date = value;
|
|
},
|
|
|
|
_updateDateLocaleOnInput: function(targetElement, e) {
|
|
var selected = InputParser.importDate(e.target.value);
|
|
// use date constructor to avoid issues, see Bug 966516
|
|
var date = new Date(selected.year, selected.month, selected.date);
|
|
this._renderDateTimeLocale(targetElement, date);
|
|
},
|
|
|
|
_updateTimeLocaleOnInput: function(targetElement, e) {
|
|
var selected = InputParser.importTime(e.target.value);
|
|
var date = new Date();
|
|
date.setHours(selected.hours);
|
|
date.setMinutes(selected.minutes);
|
|
date.setSeconds(0);
|
|
this._renderDateTimeLocale(targetElement, date);
|
|
},
|
|
|
|
/**
|
|
* Called on render or when toggling an all-day event
|
|
*/
|
|
updateAlarms: co.wrap(function *(isAllDay) {
|
|
var template = AlarmTemplate;
|
|
var alarms = [];
|
|
|
|
// Used to make sure we don't duplicate alarms
|
|
var alarmMap = {};
|
|
|
|
if (this.event.alarms) {
|
|
//jshint boss:true
|
|
for (var i = 0, alarm; alarm = this.event.alarms[i]; i++) {
|
|
alarmMap[alarm.trigger] = true;
|
|
alarm.layout = isAllDay ? 'allday' : 'standard';
|
|
alarms.push(alarm);
|
|
}
|
|
}
|
|
|
|
var layout = isAllDay ? 'allday' : 'standard';
|
|
var value = yield core.bridge.getSetting(layout + 'AlarmDefault');
|
|
|
|
if (!this.isSaved() && !alarmMap[value] && !this.event.alarms.length) {
|
|
alarms.push({
|
|
layout: layout,
|
|
trigger: value
|
|
});
|
|
}
|
|
|
|
// Bug_898242 to show an event when default is 'none',
|
|
// we check if the event is not saved, if so, we push
|
|
// the default alarm on to the list.
|
|
if ((value === 'none' && this.isSaved()) || value !== 'none') {
|
|
alarms.push({
|
|
layout: layout
|
|
});
|
|
}
|
|
|
|
this.alarmList.innerHTML = template.picker.renderEach(alarms).join('');
|
|
}),
|
|
|
|
reset: function() {
|
|
var list = this.element.classList;
|
|
|
|
list.remove(this.UPDATE);
|
|
list.remove(this.CREATE);
|
|
list.remove(this.READONLY);
|
|
list.remove(this.ALLDAY);
|
|
|
|
var allday = this.getEl('allday');
|
|
|
|
if (allday) {
|
|
allday.checked = false;
|
|
}
|
|
|
|
this._returnTo = null;
|
|
this._markReadonly(false);
|
|
this.event = null;
|
|
this.busytime = null;
|
|
|
|
this.alarmList.innerHTML = '';
|
|
|
|
this.form.reset();
|
|
},
|
|
|
|
onactive: function() {
|
|
EventBase.prototype.onactive.apply(this, arguments);
|
|
this.calendarStream = core.bridge.observeCalendars();
|
|
this.calendarStream.listen(this._renderCalendars.bind(this));
|
|
},
|
|
|
|
oninactive: function() {
|
|
EventBase.prototype.oninactive.apply(this, arguments);
|
|
this.reset();
|
|
this.calendarStream && this.calendarStream.cancel();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The fields on our event model which urlparams may override.
|
|
* @enum {string}
|
|
*/
|
|
ModifyEvent.OverrideableField = {
|
|
CALENDAR_ID: 'calendarId',
|
|
DESCRIPTION: 'description',
|
|
END_DATE: 'endDate',
|
|
IS_ALL_DAY: 'isAllDay',
|
|
LOCATION: 'location',
|
|
START_DATE: 'startDate',
|
|
TITLE: 'title'
|
|
};
|
|
|
|
});
|
|
|
|
define('templates/calendar',['require','exports','module','template','common/constants'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var create = require('template').create;
|
|
var localCalendarId = require('common/constants').localCalendarId;
|
|
|
|
module.exports = create({
|
|
item: function() {
|
|
var id = this.h('_id');
|
|
var color = this.h('color');
|
|
var l10n = '';
|
|
var name = '';
|
|
|
|
// localize only the default calendar; there is no need to set the name
|
|
// the [data-l10n-id] will take care of setting the proper value
|
|
if (id && localCalendarId === id) {
|
|
// localize the default calendar name
|
|
l10n = 'data-l10n-id="calendar-local"';
|
|
} else {
|
|
name = this.h('name');
|
|
}
|
|
|
|
var checked = this.bool('localDisplayed', 'checked');
|
|
|
|
return `<li id="calendar-${id}" role="presentation">
|
|
<div class="gaia-icon icon-calendar-dot" style="color:${color}"
|
|
aria-hidden="true"></div>
|
|
<gaia-checkbox class="invisible" value="${id}" ${checked}/>
|
|
<label ${l10n} class="name" dir="auto">${name}</label>
|
|
</gaia-checkbox>
|
|
</li>`;
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
|
|
define('views/settings',['require','exports','module','templates/calendar','view','ext/co','core','common/debug','router','dom!settings'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var CalendarTemplate = require('templates/calendar');
|
|
var View = require('view');
|
|
var co = require('ext/co');
|
|
var core = require('core');
|
|
var debug = require('common/debug')('views/settings');
|
|
var router = require('router');
|
|
|
|
require('dom!settings');
|
|
|
|
function Settings(options) {
|
|
View.apply(this, arguments);
|
|
|
|
this.calendarList = [];
|
|
this._hideSettings = this._hideSettings.bind(this);
|
|
this._onDrawerTransitionEnd = this._onDrawerTransitionEnd.bind(this);
|
|
|
|
this._observeUI();
|
|
}
|
|
module.exports = Settings;
|
|
|
|
Settings.prototype = {
|
|
__proto__: View.prototype,
|
|
|
|
calendarList: null,
|
|
|
|
/**
|
|
* used internally to check if calendar and account data was loaded from db
|
|
* @type {Promise}
|
|
* @private
|
|
*/
|
|
_load: null,
|
|
|
|
waitBeforePersist: 600,
|
|
|
|
/**
|
|
* Local update is a flag
|
|
* used to indicate that the incoming
|
|
* update was made by this view and
|
|
* should not fire the _update method.
|
|
*/
|
|
_localUpdate: false,
|
|
|
|
/**
|
|
* Name of the class that will be applied to the
|
|
* body element when sync is in progress.
|
|
*/
|
|
selectors: {
|
|
element: '#settings',
|
|
calendars: '#settings .calendars',
|
|
calendarName: '.name',
|
|
toolbar: '#settings [role="tablist"]',
|
|
header: '#settings-header',
|
|
headerTitle: '#settings-header h1',
|
|
|
|
// A dark semi-opaque layer that is used to "gray out" the view behind
|
|
// the element used for settings. Tapping on it will also close out
|
|
// the settings element, per ux desire.
|
|
shield: '#settings .settings-shield',
|
|
|
|
// This outer div is used to hide .settings-drawer via an
|
|
// overflow: hidden, so that the .settings-drawer can translateY
|
|
// animate downward and appear to come out from under the view
|
|
// header that is visible "behind" the element used for settings.
|
|
drawerContainer: '#settings .settings-drawer-container',
|
|
|
|
// Holds the actual visible drawer contents: list of calendars
|
|
// and bottom toolbar.
|
|
drawer: '#settings .settings-drawer',
|
|
|
|
advancedSettingsButton: '#settings .settings',
|
|
syncButton: '#settings .sync',
|
|
syncProgress: '#settings .sync-progress',
|
|
|
|
// A view that settings overlays. Still needs to be active/visible but
|
|
// hidden from the screen reader.
|
|
timeViews: '#time-views'
|
|
},
|
|
|
|
get calendars() {
|
|
return this._findElement('calendars');
|
|
},
|
|
|
|
get toolbar() {
|
|
return this._findElement('toolbar');
|
|
},
|
|
|
|
get header() {
|
|
return this._findElement('header');
|
|
},
|
|
|
|
get headerTitle() {
|
|
return this._findElement('headerTitle');
|
|
},
|
|
|
|
get shield() {
|
|
return this._findElement('shield');
|
|
},
|
|
|
|
get drawerContainer() {
|
|
return this._findElement('drawerContainer');
|
|
},
|
|
|
|
get drawer() {
|
|
return this._findElement('drawer');
|
|
},
|
|
|
|
get advancedSettingsButton() {
|
|
return this._findElement('advancedSettingsButton');
|
|
},
|
|
|
|
get syncButton() {
|
|
return this._findElement('syncButton');
|
|
},
|
|
|
|
get syncProgress() {
|
|
return this._findElement('syncProgress');
|
|
},
|
|
|
|
get timeViews() {
|
|
return this._findElement('timeViews');
|
|
},
|
|
|
|
_syncStartStatus: function () {
|
|
this.syncProgress.setAttribute('data-l10n-id', 'sync-progress-syncing');
|
|
this.syncProgress.classList.add('syncing');
|
|
},
|
|
|
|
_syncCompleteStatus: function () {
|
|
this.syncProgress.setAttribute('data-l10n-id', 'sync-progress-complete');
|
|
this.syncProgress.classList.remove('syncing');
|
|
},
|
|
|
|
_observeUI: function() {
|
|
this.advancedSettingsButton.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
router.show('/advanced-settings/');
|
|
});
|
|
|
|
this.syncButton.addEventListener('click', this._onSyncClick.bind(this));
|
|
core.syncController.on('syncStart', this._syncStartStatus.bind(this));
|
|
core.syncController.on('syncComplete',
|
|
this._syncCompleteStatus.bind(this));
|
|
|
|
this.calendars.addEventListener(
|
|
'change', this._onCalendarDisplayToggle.bind(this)
|
|
);
|
|
},
|
|
|
|
_observeAccounts: function() {
|
|
var stream = core.bridge.observeAccounts();
|
|
stream.listen(this._updateSyncButton.bind(this));
|
|
return new Promise(resolve => stream.listen(resolve));
|
|
},
|
|
|
|
_observeCalendars: function() {
|
|
var stream = core.bridge.observeCalendars();
|
|
stream.listen(this._updateCalendars.bind(this));
|
|
return new Promise(resolve => stream.listen(resolve));
|
|
},
|
|
|
|
_updateCalendars: function(records) {
|
|
this.calendarList = records.map(r => r.calendar);
|
|
this.render();
|
|
},
|
|
|
|
_persistCalendarDisplay: co.wrap(function *(id, displayed) {
|
|
try {
|
|
var calendar = this.calendarList.find(c => String(c._id) === String(id));
|
|
// we "clone" the calendar otherwise the caching logic will affect the
|
|
// 'calendarVisibilityChange' event (we check if value changed this way)
|
|
calendar = Object.create(calendar);
|
|
calendar.localDisplayed = displayed;
|
|
yield core.bridge.updateCalendar(calendar);
|
|
} catch (err) {
|
|
console.error(`Can't save calendar display: ${err}`);
|
|
}
|
|
}),
|
|
|
|
_onCalendarDisplayToggle: function(e) {
|
|
var input = e.target;
|
|
this._persistCalendarDisplay(input.getAttribute('value'), !!input.checked);
|
|
},
|
|
|
|
_onSyncClick: function() {
|
|
// trigger the sync the syncStart/complete events
|
|
// will hide/show the button.
|
|
core.syncController.all();
|
|
},
|
|
|
|
// Ajust size of drawer scroll area to fit size of calendars, within
|
|
// a min/max that is controlled by CSS. This has to be a manual
|
|
// calculation because UX wants the list of calendars to form-fit
|
|
// without a scrollbar, but enforce a minimum height and a maximum.
|
|
// The alternative to this approach is to size drawerContainer and
|
|
// drawer to be height 100%, and put the min/max height CSS on the
|
|
// .calendars. However, that means the translate animation is over
|
|
// a 100% height div, which ends up looking not so smooth on close
|
|
// of the animation, since the actual visible content is about half
|
|
// the size of that 100% and in the easing, zips by too quickly that
|
|
// it is harder to track, almost looks like just a harder visibility
|
|
// discontinuity.
|
|
_setCalendarContainerSize: function() {
|
|
var nodes = this.calendars.children;
|
|
var calendarsHeight = nodes[0] ?
|
|
nodes[0].getBoundingClientRect().height *
|
|
nodes.length : 0;
|
|
this.drawerContainer.style.height = (calendarsHeight +
|
|
this.toolbar.clientHeight) + 'px';
|
|
},
|
|
|
|
onrender: function() {
|
|
this._setCalendarContainerSize();
|
|
this._rendered = true;
|
|
this._animateDrawer();
|
|
},
|
|
|
|
render: function() {
|
|
debug('Will render settings view.');
|
|
this.calendars.innerHTML = '';
|
|
|
|
debug('Inject calendars into settings list.');
|
|
this.calendarList.forEach(calendar => {
|
|
debug('Will add calendar to settings view', calendar.id, calendar);
|
|
var html = CalendarTemplate.item.render(calendar);
|
|
this.calendars.insertAdjacentHTML('beforeend', html);
|
|
|
|
if (calendar.error) {
|
|
console.error('Views.Settings calendar error:', calendar.error);
|
|
var idx = this.calendars.children.length - 1;
|
|
var el = this.calendars.children[idx];
|
|
el.classList.add('error');
|
|
}
|
|
|
|
this._setCalendarContainerSize();
|
|
});
|
|
|
|
this.onrender && this.onrender();
|
|
},
|
|
|
|
_updateSyncButton: function(list) {
|
|
var canSync = list.some(o => o.provider.canSync);
|
|
this.toolbar.classList.toggle('noaccount', !canSync);
|
|
},
|
|
|
|
_onDrawerTransitionEnd: function(e) {
|
|
this._updateDrawerAnimState('done');
|
|
if (!document.body.classList.contains('settings-drawer-visible')) {
|
|
router.resetState();
|
|
}
|
|
},
|
|
|
|
// Update a state visible in the DOM for when animation is taking place.
|
|
// This is mostly useful for a test hook to know when the animation is
|
|
// done.
|
|
_updateDrawerAnimState: function(state) {
|
|
this.drawer.dataset.animstate = state;
|
|
},
|
|
|
|
_hideSettings: function() {
|
|
this._updateDrawerAnimState('animating');
|
|
document.body.classList.remove('settings-drawer-visible');
|
|
},
|
|
|
|
_animateDrawer: function() {
|
|
// Wait for both _rendered and _activated before triggering
|
|
// the animation, so that it is smooth, without jank due to
|
|
// changes in style/layout from activating or rendering.
|
|
// Also, set the style on the body, since other views will also
|
|
// have items animate based on the class. For instance, the +
|
|
// to add an event in the view-selector views fades out.
|
|
if (!this._rendered) {
|
|
return debug('Skip animation since not yet rendered.');
|
|
}
|
|
|
|
if (!this._activated) {
|
|
return debug('Skip animation since not yet activated.');
|
|
}
|
|
|
|
var classList = document.body.classList;
|
|
if (classList.contains('settings-drawer-visible')) {
|
|
return debug('Skip animation since drawer already visible?');
|
|
}
|
|
|
|
this._updateDrawerAnimState('animating');
|
|
classList.add('settings-drawer-visible');
|
|
},
|
|
|
|
_loadBaseData: function() {
|
|
if (!this._load) {
|
|
// make sure we only observe data once and wait until it's ready before
|
|
// showing the animation
|
|
this._load = Promise.all([
|
|
this._observeCalendars(),
|
|
this._observeAccounts()
|
|
]);
|
|
}
|
|
|
|
return this._load;
|
|
},
|
|
|
|
onactive: co.wrap(function *() {
|
|
debug('Will do settings animation.');
|
|
|
|
try {
|
|
yield this._loadBaseData();
|
|
} catch(err) {
|
|
console.error('Error fetching calendars in View.Settings', err);
|
|
return;
|
|
}
|
|
|
|
// View#onactive will call Views.Settings#render the first time.
|
|
View.prototype.onactive.apply(this, arguments);
|
|
|
|
// onactive can be called more times than oninactive, since
|
|
// settings can overlay over and not trigger an inactive state,
|
|
// so only bind these listeners and do the drawer animation once.
|
|
if (document.body.classList.contains('settings-drawer-visible')) {
|
|
return;
|
|
}
|
|
|
|
debug('Settings drawer is not visible... will activate.');
|
|
this._activated = true;
|
|
this._animateDrawer();
|
|
|
|
// Set header title to same as time view header
|
|
this.headerTitle.textContent =
|
|
document.getElementById('current-month-year').textContent;
|
|
|
|
// Both the transparent back and clicking on the semi-opaque
|
|
// shield should close the settings since visually those sections
|
|
// do not look like part of the drawer UI, and UX wants to give
|
|
// the user a few options to close the drawer since there is no
|
|
// explicit close button.
|
|
this.header.addEventListener('action', this._hideSettings);
|
|
this.shield.addEventListener('click', this._hideSettings);
|
|
this.timeViews.setAttribute('aria-hidden', true);
|
|
this.drawer.addEventListener('transitionend', this._onDrawerTransitionEnd);
|
|
}),
|
|
|
|
oninactive: function() {
|
|
debug('Will deactivate settings.');
|
|
View.prototype.oninactive.apply(this, arguments);
|
|
this._activated = false;
|
|
this.header.removeEventListener('action', this._hideSettings);
|
|
this.shield.removeEventListener('click', this._hideSettings);
|
|
this.timeViews.removeAttribute('aria-hidden');
|
|
this.drawer.removeEventListener('transitionend',
|
|
this._onDrawerTransitionEnd);
|
|
}
|
|
|
|
};
|
|
|
|
Settings.prototype.onfirstseen = Settings.prototype.render;
|
|
|
|
});
|
|
|
|
define('templates/duration_time',['require','exports','module','common/calc','templates/date_span','template'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var Calc = require('common/calc');
|
|
var DateSpan = require('templates/date_span');
|
|
var create = require('template').create;
|
|
|
|
var l10n = navigator.mozL10n;
|
|
|
|
module.exports = create({
|
|
durationTime: function() {
|
|
var format = '';
|
|
var startDate = this.arg('startDate');
|
|
var endDate = this.arg('endDate');
|
|
var isAllDay = this.arg('isAllDay');
|
|
|
|
if (isAllDay) {
|
|
// Use the last second of previous day as the base for endDate
|
|
// (e.g., 1991-09-14T23:59:59 insteads of 1991-09-15T00:00:00).
|
|
endDate = new Date(endDate - 1000);
|
|
format = Calc.isSameDate(startDate, endDate) ?
|
|
'one-all-day-duration' :
|
|
'multiple-all-day-duration';
|
|
} else {
|
|
format = Calc.isSameDate(startDate, endDate) ?
|
|
'one-day-duration' :
|
|
'multiple-day-duration';
|
|
}
|
|
|
|
return l10n.get(format, {
|
|
startTime: formatTime(startDate),
|
|
startDate: formatDate(startDate),
|
|
endTime: formatTime(endDate),
|
|
endDate: formatDate(endDate)
|
|
});
|
|
}
|
|
});
|
|
|
|
function formatDate(date) {
|
|
return date.toLocaleString(navigator.languages, {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
}
|
|
|
|
function formatTime(time) {
|
|
return DateSpan.time.render({
|
|
time: time,
|
|
format: 'shortTimeFormat'
|
|
});
|
|
}
|
|
|
|
});
|
|
|
|
|
|
define('views/view_event',['require','exports','module','templates/duration_time','./event_base','templates/alarm','common/constants','router','dom!event-view'],function(require, exports, module) {
|
|
'use strict';
|
|
|
|
var DurationTime = require('templates/duration_time');
|
|
var EventBase = require('./event_base');
|
|
var alarmTemplate = require('templates/alarm');
|
|
var localCalendarId = require('common/constants').localCalendarId;
|
|
var router = require('router');
|
|
|
|
require('dom!event-view');
|
|
|
|
function ViewEvent(options) {
|
|
EventBase.apply(this, arguments);
|
|
}
|
|
module.exports = ViewEvent;
|
|
|
|
ViewEvent.prototype = {
|
|
__proto__: EventBase.prototype,
|
|
|
|
DEFAULT_VIEW: '/month/',
|
|
|
|
selectors: {
|
|
element: '#event-view',
|
|
header: '#show-event-header',
|
|
primaryButton: '#event-view .edit'
|
|
},
|
|
|
|
_initEvents: function() {
|
|
EventBase.prototype._initEvents.apply(this, arguments);
|
|
},
|
|
|
|
/**
|
|
* Dismiss modification and go back to previous screen.
|
|
*/
|
|
cancel: function() {
|
|
router.go(this.returnTop());
|
|
},
|
|
|
|
primary: function(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
// Disable the button on primary event to avoid race conditions
|
|
this.primaryButton.setAttribute('aria-disabled', 'true');
|
|
|
|
router.go('/event/edit/' + this.busytime._id + '/');
|
|
},
|
|
|
|
/**
|
|
* Mark the event readOnly
|
|
* Hides/shows the edit button
|
|
*
|
|
* @param {Boolean} boolean true/false.
|
|
*/
|
|
_markReadonly: function(boolean) {
|
|
this.primaryButton.disabled = boolean;
|
|
},
|
|
|
|
/**
|
|
* Sets content for an element
|
|
* Hides the element if there's no content to set
|
|
*/
|
|
setContent: function(element, content, method) {
|
|
method = method || 'textContent';
|
|
element = this.getEl(element);
|
|
element.querySelector('.content')[method] = content;
|
|
|
|
if (!content) {
|
|
element.style.display = 'none';
|
|
} else {
|
|
element.style.display = '';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Updates the UI to use values from the current model.
|
|
*/
|
|
_updateUI: function() {
|
|
var model = this.event;
|
|
|
|
this.setContent('title', model.title);
|
|
this.setContent('location', model.location);
|
|
|
|
if (this.originalCalendar) {
|
|
this.element.querySelector('.icon-calendar-dot').style.color =
|
|
this.originalCalendar.color;
|
|
|
|
var calendarId = this.originalCalendar.remote.id;
|
|
var isLocalCalendar = calendarId === localCalendarId;
|
|
var calendarName = isLocalCalendar ?
|
|
navigator.mozL10n.get('calendar-local') :
|
|
this.originalCalendar.remote.name;
|
|
|
|
this.setContent(
|
|
'current-calendar',
|
|
calendarName
|
|
);
|
|
|
|
if (isLocalCalendar) {
|
|
this.getEl('current-calendar')
|
|
.querySelector('.content')
|
|
.setAttribute('data-l10n-id', 'calendar-local');
|
|
}
|
|
}
|
|
|
|
var dateSrc = model;
|
|
if (model.remote.isRecurring && this.busytime) {
|
|
dateSrc = this.busytime;
|
|
}
|
|
|
|
var duationTimeContent = DurationTime.durationTime.render(dateSrc);
|
|
this.setContent('duration-time', duationTimeContent, 'innerHTML');
|
|
|
|
var alarmContent = '';
|
|
var alarms = this.event.alarms;
|
|
if (alarms) {
|
|
this.getEl('alarms')
|
|
.classList
|
|
.toggle('multiple', alarms.length > 1);
|
|
|
|
alarmContent = alarmTemplate.reminder.render({
|
|
alarms: alarms,
|
|
isAllDay: this.event.isAllDay,
|
|
});
|
|
}
|
|
|
|
this.setContent('alarms', alarmContent, 'innerHTML');
|
|
this.setContent('description', model.description);
|
|
},
|
|
|
|
oninactive: function() {
|
|
EventBase.prototype.oninactive.apply(this, arguments);
|
|
this._markReadonly(false);
|
|
}
|
|
};
|
|
|
|
});
|
|
|
|
|
|
define("lazy_loaded", function(){});
|