vis-timeline
Version:
Create a fully customizable, interactive timeline with items and ranges.
581 lines (513 loc) • 17.7 kB
JavaScript
import Hammer from '../../../module/hammer';
import util from 'vis-util';
import moment from '../../../module/moment';
import locales from '../../locales';
import '../css/item.css';
/**
* Item
*/
class Item {
/**
* @constructor Item
* @param {Object} data Object containing (optional) parameters type,
* start, end, content, group, className.
* @param {{toScreen: function, toTime: function}} conversion
* Conversion functions from time to screen and vice versa
* @param {Object} options Configuration options
* // TODO: describe available options
*/
constructor(data, conversion, options) {
this.id = null;
this.parent = null;
this.data = data;
this.dom = null;
this.conversion = conversion || {};
this.defaultOptions = {
locales,
locale: 'en'
};
this.options = util.extend({}, this.defaultOptions, options);
this.selected = false;
this.displayed = false;
this.groupShowing = true;
this.selectable = (options && options.selectable) || false;
this.dirty = true;
this.top = null;
this.right = null;
this.left = null;
this.width = null;
this.height = null;
this.setSelectability(data);
this.editable = null;
this._updateEditStatus();
}
/**
* Select current item
*/
select() {
if (this.selectable) {
this.selected = true;
this.dirty = true;
if (this.displayed) this.redraw();
}
}
/**
* Unselect current item
*/
unselect() {
this.selected = false;
this.dirty = true;
if (this.displayed) this.redraw();
}
/**
* Set data for the item. Existing data will be updated. The id should not
* be changed. When the item is displayed, it will be redrawn immediately.
* @param {Object} data
*/
setData(data) {
const groupChanged = data.group != undefined && this.data.group != data.group;
if (groupChanged && this.parent != null) {
this.parent.itemSet._moveToGroup(this, data.group);
}
this.setSelectability(data);
if (this.parent) {
this.parent.stackDirty = true;
}
const subGroupChanged = data.subgroup != undefined && this.data.subgroup != data.subgroup;
if (subGroupChanged && this.parent != null) {
this.parent.changeSubgroup(this, this.data.subgroup, data.subgroup);
}
this.data = data;
this._updateEditStatus();
this.dirty = true;
if (this.displayed) this.redraw();
}
/**
* Set whether the item can be selected.
* Can only be set/unset if the timeline's `selectable` configuration option is `true`.
* @param {Object} data `data` from `constructor` and `setData`
*/
setSelectability(data) {
if (data) {
this.selectable = typeof data.selectable === 'undefined' ? true : Boolean(data.selectable);
}
}
/**
* Set a parent for the item
* @param {Group} parent
*/
setParent(parent) {
if (this.displayed) {
this.hide();
this.parent = parent;
if (this.parent) {
this.show();
}
}
else {
this.parent = parent;
}
}
/**
* Check whether this item is visible inside given range
* @param {timeline.Range} range with a timestamp for start and end
* @returns {boolean} True if visible
*/
isVisible(range) { // eslint-disable-line no-unused-vars
return false;
}
/**
* Show the Item in the DOM (when not already visible)
* @return {Boolean} changed
*/
show() {
return false;
}
/**
* Hide the Item from the DOM (when visible)
* @return {Boolean} changed
*/
hide() {
return false;
}
/**
* Repaint the item
*/
redraw() {
// should be implemented by the item
}
/**
* Reposition the Item horizontally
*/
repositionX() {
// should be implemented by the item
}
/**
* Reposition the Item vertically
*/
repositionY() {
// should be implemented by the item
}
/**
* Repaint a drag area on the center of the item when the item is selected
* @protected
*/
_repaintDragCenter() {
if (this.selected && this.options.editable.updateTime && !this.dom.dragCenter) {
const me = this;
// create and show drag area
const dragCenter = document.createElement('div');
dragCenter.className = 'vis-drag-center';
dragCenter.dragCenterItem = this;
this.hammerDragCenter = new Hammer(dragCenter);
this.hammerDragCenter.on('tap', event => {
me.parent.itemSet.body.emitter.emit('click', {
event,
item: me.id
});
});
this.hammerDragCenter.on('doubletap', event => {
event.stopPropagation();
me.parent.itemSet._onUpdateItem(me);
me.parent.itemSet.body.emitter.emit('doubleClick', {
event,
item: me.id
});
});
this.hammerDragCenter.on('panstart', me.parent.itemSet._onDragStart.bind(me.parent.itemSet));
this.hammerDragCenter.on('panmove', me.parent.itemSet._onDrag.bind(me.parent.itemSet));
this.hammerDragCenter.on('panend', me.parent.itemSet._onDragEnd.bind(me.parent.itemSet));
if (this.dom.box) {
if (this.dom.dragLeft) {
this.dom.box.insertBefore(dragCenter, this.dom.dragLeft);
}
else {
this.dom.box.appendChild(dragCenter);
}
}
else if (this.dom.point) {
this.dom.point.appendChild(dragCenter);
}
this.dom.dragCenter = dragCenter;
}
else if (!this.selected && this.dom.dragCenter) {
// delete drag area
if (this.dom.dragCenter.parentNode) {
this.dom.dragCenter.parentNode.removeChild(this.dom.dragCenter);
}
this.dom.dragCenter = null;
if (this.hammerDragCenter) {
this.hammerDragCenter.destroy();
this.hammerDragCenter = null;
}
}
}
/**
* Repaint a delete button on the top right of the item when the item is selected
* @param {HTMLElement} anchor
* @protected
*/
_repaintDeleteButton(anchor) {
const editable = ((this.options.editable.overrideItems || this.editable == null) && this.options.editable.remove) ||
(!this.options.editable.overrideItems && this.editable != null && this.editable.remove);
if (this.selected && editable && !this.dom.deleteButton) {
// create and show button
const me = this;
const deleteButton = document.createElement('div');
if (this.options.rtl) {
deleteButton.className = 'vis-delete-rtl';
} else {
deleteButton.className = 'vis-delete';
}
let locales = this.options.locales[this.options.locale];
if (!locales) {
if (!this.warned) {
console.warn(`WARNING: options.locales['${this.options.locale}'] not found. See https://visjs.github.io/vis-timeline/docs/timeline/#Localization`);
this.warned = true;
}
locales = this.options.locales['en']; // fall back on english when not available
}
deleteButton.title = locales.deleteSelected;
// TODO: be able to destroy the delete button
this.hammerDeleteButton = new Hammer(deleteButton).on('tap', event => {
event.stopPropagation();
me.parent.removeFromDataSet(me);
});
anchor.appendChild(deleteButton);
this.dom.deleteButton = deleteButton;
}
else if (!this.selected && this.dom.deleteButton) {
// remove button
if (this.dom.deleteButton.parentNode) {
this.dom.deleteButton.parentNode.removeChild(this.dom.deleteButton);
}
this.dom.deleteButton = null;
if (this.hammerDeleteButton) {
this.hammerDeleteButton.destroy();
this.hammerDeleteButton = null;
}
}
}
/**
* Repaint a onChange tooltip on the top right of the item when the item is selected
* @param {HTMLElement} anchor
* @protected
*/
_repaintOnItemUpdateTimeTooltip(anchor) {
if (!this.options.tooltipOnItemUpdateTime) return;
const editable = (this.options.editable.updateTime ||
this.data.editable === true) &&
this.data.editable !== false;
if (this.selected && editable && !this.dom.onItemUpdateTimeTooltip) {
const onItemUpdateTimeTooltip = document.createElement('div');
onItemUpdateTimeTooltip.className = 'vis-onUpdateTime-tooltip';
anchor.appendChild(onItemUpdateTimeTooltip);
this.dom.onItemUpdateTimeTooltip = onItemUpdateTimeTooltip;
} else if (!this.selected && this.dom.onItemUpdateTimeTooltip) {
// remove button
if (this.dom.onItemUpdateTimeTooltip.parentNode) {
this.dom.onItemUpdateTimeTooltip.parentNode.removeChild(this.dom.onItemUpdateTimeTooltip);
}
this.dom.onItemUpdateTimeTooltip = null;
}
// position onChange tooltip
if (this.dom.onItemUpdateTimeTooltip) {
// only show when editing
this.dom.onItemUpdateTimeTooltip.style.visibility = this.parent.itemSet.touchParams.itemIsDragging ? 'visible' : 'hidden';
// position relative to item's content
if (this.options.rtl) {
this.dom.onItemUpdateTimeTooltip.style.right = this.dom.content.style.right;
} else {
this.dom.onItemUpdateTimeTooltip.style.left = this.dom.content.style.left;
}
// position above or below the item depending on the item's position in the window
const tooltipOffset = 50; // TODO: should be tooltip height (depends on template)
const scrollTop = this.parent.itemSet.body.domProps.scrollTop;
// TODO: this.top for orientation:true is actually the items distance from the bottom...
// (should be this.bottom)
let itemDistanceFromTop;
if (this.options.orientation.item == 'top') {
itemDistanceFromTop = this.top;
} else {
itemDistanceFromTop = (this.parent.height - this.top - this.height)
}
const isCloseToTop = itemDistanceFromTop + this.parent.top - tooltipOffset < -scrollTop;
if (isCloseToTop) {
this.dom.onItemUpdateTimeTooltip.style.bottom = "";
this.dom.onItemUpdateTimeTooltip.style.top = `${this.height + 2}px`;
} else {
this.dom.onItemUpdateTimeTooltip.style.top = "";
this.dom.onItemUpdateTimeTooltip.style.bottom = `${this.height + 2}px`;
}
// handle tooltip content
let content;
let templateFunction;
if (this.options.tooltipOnItemUpdateTime && this.options.tooltipOnItemUpdateTime.template) {
templateFunction = this.options.tooltipOnItemUpdateTime.template.bind(this);
content = templateFunction(this.data);
} else {
content = `start: ${moment(this.data.start).format('MM/DD/YYYY hh:mm')}`;
if (this.data.end) {
content += `<br> end: ${moment(this.data.end).format('MM/DD/YYYY hh:mm')}`;
}
}
this.dom.onItemUpdateTimeTooltip.innerHTML = content;
}
}
/**
* get item data
* @return {object}
* @private
*/
_getItemData() {
return this.parent.itemSet.itemsData.get(this.id);
}
/**
* Set HTML contents for the item
* @param {Element} element HTML element to fill with the contents
* @private
*/
_updateContents(element) {
let content;
let changed;
let templateFunction;
let itemVisibleFrameContent;
let visibleFrameTemplateFunction;
const itemData = this._getItemData(); // get a clone of the data from the dataset
const frameElement = this.dom.box || this.dom.point;
const itemVisibleFrameContentElement = frameElement.getElementsByClassName('vis-item-visible-frame')[0];
if (this.options.visibleFrameTemplate) {
visibleFrameTemplateFunction = this.options.visibleFrameTemplate.bind(this);
itemVisibleFrameContent = visibleFrameTemplateFunction(itemData, itemVisibleFrameContentElement);
} else {
itemVisibleFrameContent = '';
}
if (itemVisibleFrameContentElement) {
if ((itemVisibleFrameContent instanceof Object) && !(itemVisibleFrameContent instanceof Element)) {
visibleFrameTemplateFunction(itemData, itemVisibleFrameContentElement)
} else {
changed = this._contentToString(this.itemVisibleFrameContent) !== this._contentToString(itemVisibleFrameContent);
if (changed) {
// only replace the content when changed
if (itemVisibleFrameContent instanceof Element) {
itemVisibleFrameContentElement.innerHTML = '';
itemVisibleFrameContentElement.appendChild(itemVisibleFrameContent);
}
else if (itemVisibleFrameContent != undefined) {
itemVisibleFrameContentElement.innerHTML = itemVisibleFrameContent;
}
else {
if (!(this.data.type == 'background' && this.data.content === undefined)) {
throw new Error(`Property "content" missing in item ${this.id}`);
}
}
this.itemVisibleFrameContent = itemVisibleFrameContent;
}
}
}
if (this.options.template) {
templateFunction = this.options.template.bind(this);
content = templateFunction(itemData, element, this.data);
} else {
content = this.data.content;
}
if ((content instanceof Object) && !(content instanceof Element)) {
templateFunction(itemData, element)
} else {
changed = this._contentToString(this.content) !== this._contentToString(content);
if (changed) {
// only replace the content when changed
if (content instanceof Element) {
element.innerHTML = '';
element.appendChild(content);
}
else if (content != undefined) {
element.innerHTML = content;
}
else {
if (!(this.data.type == 'background' && this.data.content === undefined)) {
throw new Error(`Property "content" missing in item ${this.id}`);
}
}
this.content = content;
}
}
}
/**
* Process dataAttributes timeline option and set as data- attributes on dom.content
* @param {Element} element HTML element to which the attributes will be attached
* @private
*/
_updateDataAttributes(element) {
if (this.options.dataAttributes && this.options.dataAttributes.length > 0) {
let attributes = [];
if (Array.isArray(this.options.dataAttributes)) {
attributes = this.options.dataAttributes;
}
else if (this.options.dataAttributes == 'all') {
attributes = Object.keys(this.data);
}
else {
return;
}
for (const name of attributes) {
const value = this.data[name];
if (value != null) {
element.setAttribute(`data-${name}`, value);
}
else {
element.removeAttribute(`data-${name}`);
}
}
}
}
/**
* Update custom styles of the element
* @param {Element} element
* @private
*/
_updateStyle(element) {
// remove old styles
if (this.style) {
util.removeCssText(element, this.style);
this.style = null;
}
// append new styles
if (this.data.style) {
util.addCssText(element, this.data.style);
this.style = this.data.style;
}
}
/**
* Stringify the items contents
* @param {string | Element | undefined} content
* @returns {string | undefined}
* @private
*/
_contentToString(content) {
if (typeof content === 'string') return content;
if (content && 'outerHTML' in content) return content.outerHTML;
return content;
}
/**
* Update the editability of this item.
*/
_updateEditStatus() {
if (this.options) {
if(typeof this.options.editable === 'boolean') {
this.editable = {
updateTime: this.options.editable,
updateGroup: this.options.editable,
remove: this.options.editable
};
} else if(typeof this.options.editable === 'object') {
this.editable = {};
util.selectiveExtend(['updateTime', 'updateGroup', 'remove'], this.editable, this.options.editable);
}
}
// Item data overrides, except if options.editable.overrideItems is set.
if (!this.options || !(this.options.editable) || (this.options.editable.overrideItems !== true)) {
if (this.data) {
if (typeof this.data.editable === 'boolean') {
this.editable = {
updateTime: this.data.editable,
updateGroup: this.data.editable,
remove: this.data.editable
}
} else if (typeof this.data.editable === 'object') {
// TODO: in timeline.js 5.0, we should change this to not reset options from the timeline configuration.
// Basically just remove the next line...
this.editable = {};
util.selectiveExtend(['updateTime', 'updateGroup', 'remove'], this.editable, this.data.editable);
}
}
}
}
/**
* Return the width of the item left from its start date
* @return {number}
*/
getWidthLeft() {
return 0;
}
/**
* Return the width of the item right from the max of its start and end date
* @return {number}
*/
getWidthRight() {
return 0;
}
/**
* Return the title of the item
* @return {string | undefined}
*/
getTitle() {
if (this.options.tooltip && this.options.tooltip.template) {
const templateFunction = this.options.tooltip.template.bind(this);
return templateFunction(this._getItemData(), this.data);
}
return this.data.title;
}
}
Item.prototype.stack = true;
export default Item;