zulip/static/js/input_pill.js

387 lines
13 KiB
JavaScript
Raw Normal View History

const render_input_pill = require('../templates/input_pill.hbs');
2018-07-21 17:56:43 +02:00
// See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html
exports.random_id = function () {
return Math.random().toString(16);
};
exports.create = function (opts) {
// a dictionary of the key codes that are associated with each key
// to make if/else more human readable.
const KEY = {
ENTER: 13,
BACKSPACE: 8,
LEFT_ARROW: 37,
RIGHT_ARROW: 39,
COMMA: 188,
};
if (!opts.container) {
blueslip.error('Pill needs container.');
return;
}
if (!opts.create_item_from_text) {
blueslip.error('Pill needs create_item_from_text');
return;
}
if (!opts.get_text_from_item) {
blueslip.error('Pill needs get_text_from_item');
return;
}
// a stateful object of this `pill_container` instance.
// all unique instance information is stored in here.
const store = {
pills: [],
$parent: opts.container,
$input: opts.container.find(".input").expectOne(),
create_item_from_text: opts.create_item_from_text,
get_text_from_item: opts.get_text_from_item,
};
// a dictionary of internal functions. Some of these are exposed as well,
// and nothing in here should be assumed to be private (due to the passing)
// of the `this` arg in the `Function.prototype.bind` use in the prototype.
const funcs = {
// return the value of the contenteditable input form.
value: function (input_elem) {
return input_elem.innerText.trim();
},
// clear the value of the input form.
clear: function (input_elem) {
input_elem.innerText = "";
},
clear_text: function () {
store.$input.text("");
},
2018-09-18 01:25:38 +02:00
is_pending: function () {
// This function returns true if we have text
// in out widget that hasn't been turned into
// pills. We use it to decide things like
// whether we're ready to send typing indicators.
return store.$input.text().trim() !== '';
},
create_item: function (text) {
const existing_items = funcs.items();
const item = store.create_item_from_text(text, existing_items);
if (!item || !item.display_value) {
store.$input.addClass("shake");
return;
}
if (typeof store.onPillCreate === "function") {
store.onPillCreate();
}
return item;
},
// This is generally called by typeahead logic, where we have all
// the data we need (as opposed to, say, just a user-typed email).
appendValidatedData: function (item) {
const id = exports.random_id();
if (!item.display_value) {
blueslip.error('no display_value returned');
return;
}
const payload = {
id: id,
item: item,
};
store.pills.push(payload);
const has_image = item.img_src !== undefined;
const opts = {
id: payload.id,
display_value: item.display_value,
has_image: has_image,
};
if (has_image) {
opts.img_src = item.img_src;
}
const pill_html = render_input_pill(opts);
payload.$element = $(pill_html);
store.$input.before(payload.$element);
},
// this appends a pill to the end of the container but before the
// input block.
appendPill: function (value) {
if (value.length === 0) {
return;
}
if (value.match(",")) {
funcs.insertManyPills(value);
return false;
}
const payload = this.create_item(value);
// if the pill object is undefined, then it means the pill was
// rejected so we should return out of this.
if (!payload) {
return false;
}
this.appendValidatedData(payload);
},
// this searches given a particlar pill ID for it, removes the node
// from the DOM, removes it from the array and returns it.
// this would generally be used for DOM-provoked actions, such as a user
// clicking on a pill to remove it.
removePill: function (id) {
let idx;
for (let x = 0; x < store.pills.length; x += 1) {
if (store.pills[x].id === id) {
idx = x;
}
}
if (typeof idx === "number") {
store.pills[idx].$element.remove();
const pill = store.pills.splice(idx, 1);
if (typeof store.removePillFunction === "function") {
store.removePillFunction(pill);
}
return pill;
}
},
// this will remove the last pill in the container -- by default tied
// to the "backspace" key when the value of the input is empty.
// If quiet is a truthy value, the event handler associated with the
// pill will not be evaluated. This is useful when using clear to reset
// the pills.
removeLastPill: function (quiet) {
const pill = store.pills.pop();
if (pill) {
pill.$element.remove();
if (!quiet && typeof store.removePillFunction === "function") {
store.removePillFunction(pill);
}
}
},
removeAllPills: function (quiet) {
while (store.pills.length > 0) {
this.removeLastPill(quiet);
}
this.clear(store.$input[0]);
},
insertManyPills: function (pills) {
if (typeof pills === "string") {
pills = pills.split(/,/g).map(function (pill) {
return pill.trim();
});
}
// this is an array to push all the errored values to, so it's drafts
// of pills for the user to fix.
const drafts = [];
pills.forEach(function (pill) {
// if this returns `false`, it erroed and we should push it to
// the draft pills.
if (funcs.appendPill(pill) === false) {
drafts.push(pill);
}
});
store.$input.text(drafts.join(", "));
// when using the `text` insertion feature with jQuery the caret is
// placed at the beginning of the input field, so this moves it to
// the end.
ui_util.place_caret_at_end(store.$input[0]);
// this sends a flag that the operation wasn't completely successful,
// which in this case is defined as some of the pills not autofilling
// correclty.
if (drafts.length > 0) {
return false;
}
},
getByID: function (id) {
return store.pills.find(pill => pill.id === id);
},
items: function () {
return store.pills.map(pill => pill.item);
},
};
(function events() {
store.$parent.on("keydown", ".input", function (e) {
const char = e.keyCode || e.charCode;
if (char === KEY.ENTER) {
// regardless of the value of the input, the ENTER keyword
// should be ignored in favor of keeping content to one line
// always.
e.preventDefault();
// if there is input, grab the input, make a pill from it,
// and append the pill, then clear the input.
const value = funcs.value(e.target);
if (value.length > 0) {
// append the pill and by proxy create the pill object.
const ret = funcs.appendPill(value);
// if the pill to append was rejected, no need to clear the
// input; it may have just been a typo or something close but
// incorrect.
if (ret !== false) {
// clear the input.
funcs.clear(e.target);
e.stopPropagation();
}
}
return;
}
// if the user backspaces and there is input, just do normal char
// deletion, otherwise delete the last pill in the sequence.
if (char === KEY.BACKSPACE && funcs.value(e.target).length === 0) {
e.preventDefault();
funcs.removeLastPill();
return;
}
// if one is on the ".input" element and back/left arrows, then it
// should switch to focus the last pill in the list.
// the rest of the events then will be taken care of in the function
// below that handles events on the ".pill" class.
if (char === KEY.LEFT_ARROW) {
if (window.getSelection().anchorOffset === 0) {
store.$parent.find(".pill").last().focus();
}
}
// users should not be able to type a comma if the last field doesn't
// validate.
if (char === KEY.COMMA) {
// if the pill is successful, it will create the pill and clear
// the input.
if (funcs.appendPill(store.$input.text().trim()) !== false) {
funcs.clear(store.$input[0]);
// otherwise it will prevent the typing of the comma because they
// cannot add another pill until this input is valid.
} else {
e.preventDefault();
return;
}
}
});
// handle events while hovering on ".pill" elements.
// the three primary events are next, previous, and delete.
store.$parent.on("keydown", ".pill", function (e) {
const char = e.keyCode || e.charCode;
const $pill = store.$parent.find(".pill:focus");
if (char === KEY.LEFT_ARROW) {
$pill.prev().focus();
} else if (char === KEY.RIGHT_ARROW) {
$pill.next().focus();
} else if (char === KEY.BACKSPACE) {
const $next = $pill.next();
const id = $pill.data("id");
funcs.removePill(id);
$next.focus();
// the "backspace" key in FireFox will go back a page if you do
// not prevent it.
e.preventDefault();
}
});
// when the shake animation is applied to the ".input" on invalid input,
// we want to remove the class when finished automatically.
store.$parent.on("animationend", ".input", function () {
$(this).removeClass("shake");
});
// replace formatted input with plaintext to allow for sane copy-paste
// actions.
store.$parent.on("paste", ".input", function (e) {
e.preventDefault();
// get text representation of clipboard
const text = (e.originalEvent || e).clipboardData.getData('text/plain');
// insert text manually
2018-03-31 13:09:50 +02:00
document.execCommand("insertText", false, text);
funcs.insertManyPills(store.$input.text().trim());
});
// when the "×" is clicked on a pill, it should delete that pill and then
// select the next pill (or input).
store.$parent.on("click", ".exit", function (e) {
e.stopPropagation();
const $pill = $(this).closest(".pill");
const $next = $pill.next();
const id = $pill.data("id");
funcs.removePill(id);
$next.focus();
});
store.$parent.on("click", function (e) {
if ($(e.target).is(".pill-container")) {
$(this).find(".input").focus();
}
});
store.$parent.on("copy", ".pill", function (e) {
const id = store.$parent.find(":focus").data("id");
const data = funcs.getByID(id);
e.originalEvent.clipboardData.setData("text/plain", store.get_text_from_item(data.item));
e.preventDefault();
});
}());
// the external, user-accessible prototype.
const prototype = {
appendValue: funcs.appendPill.bind(funcs),
appendValidatedData: funcs.appendValidatedData.bind(funcs),
items: funcs.items,
onPillCreate: function (callback) {
store.onPillCreate = callback;
},
onPillRemove: function (callback) {
store.removePillFunction = callback;
},
clear: funcs.removeAllPills.bind(funcs),
clear_text: funcs.clear_text,
2018-09-18 01:25:38 +02:00
is_pending: funcs.is_pending,
};
return prototype;
};
window.input_pill = exports;