mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 14:03:30 +00:00 
			
		
		
		
	Some of these were there because they predate block-scoped const/let. Signed-off-by: Anders Kaseorg <anders@zulip.com>
		
			
				
	
	
		
			413 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			413 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import $ from "jquery";
 | 
						||
 | 
						||
import render_input_pill from "../templates/input_pill.hbs";
 | 
						||
 | 
						||
import * as blueslip from "./blueslip";
 | 
						||
import * as ui_util from "./ui_util";
 | 
						||
 | 
						||
// See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html
 | 
						||
export function random_id() {
 | 
						||
    return Math.random().toString(16);
 | 
						||
}
 | 
						||
 | 
						||
export function create(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 undefined;
 | 
						||
    }
 | 
						||
 | 
						||
    if (!opts.create_item_from_text) {
 | 
						||
        blueslip.error("Pill needs create_item_from_text");
 | 
						||
        return undefined;
 | 
						||
    }
 | 
						||
 | 
						||
    if (!opts.get_text_from_item) {
 | 
						||
        blueslip.error("Pill needs get_text_from_item");
 | 
						||
        return undefined;
 | 
						||
    }
 | 
						||
 | 
						||
    // 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(input_elem) {
 | 
						||
            return input_elem.textContent;
 | 
						||
        },
 | 
						||
 | 
						||
        // clear the value of the input form.
 | 
						||
        clear(input_elem) {
 | 
						||
            input_elem.textContent = "";
 | 
						||
        },
 | 
						||
 | 
						||
        clear_text() {
 | 
						||
            store.$input.text("");
 | 
						||
        },
 | 
						||
 | 
						||
        is_pending() {
 | 
						||
            // 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(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 undefined;
 | 
						||
            }
 | 
						||
 | 
						||
            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(item) {
 | 
						||
            const id = random_id();
 | 
						||
 | 
						||
            if (!item.display_value) {
 | 
						||
                blueslip.error("no display_value returned");
 | 
						||
                return;
 | 
						||
            }
 | 
						||
 | 
						||
            if (!item.type) {
 | 
						||
                blueslip.error("no type defined for the item");
 | 
						||
                return;
 | 
						||
            }
 | 
						||
 | 
						||
            const payload = {
 | 
						||
                id,
 | 
						||
                item,
 | 
						||
            };
 | 
						||
 | 
						||
            store.pills.push(payload);
 | 
						||
 | 
						||
            const has_image = item.img_src !== undefined;
 | 
						||
 | 
						||
            const opts = {
 | 
						||
                id: payload.id,
 | 
						||
                display_value: item.display_value,
 | 
						||
                has_image,
 | 
						||
            };
 | 
						||
 | 
						||
            if (has_image) {
 | 
						||
                opts.img_src = item.img_src;
 | 
						||
            }
 | 
						||
 | 
						||
            if (typeof store.onPillCreate === "function") {
 | 
						||
                store.onPillCreate();
 | 
						||
            }
 | 
						||
 | 
						||
            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(value) {
 | 
						||
            if (value.length === 0) {
 | 
						||
                return true;
 | 
						||
            }
 | 
						||
            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);
 | 
						||
            return true;
 | 
						||
        },
 | 
						||
 | 
						||
        // 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(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;
 | 
						||
            }
 | 
						||
 | 
						||
            /* istanbul ignore next */
 | 
						||
            return undefined;
 | 
						||
        },
 | 
						||
 | 
						||
        // 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(quiet) {
 | 
						||
            const pill = store.pills.pop();
 | 
						||
 | 
						||
            if (pill) {
 | 
						||
                pill.$element.remove();
 | 
						||
                if (!quiet && typeof store.removePillFunction === "function") {
 | 
						||
                    store.removePillFunction(pill);
 | 
						||
                }
 | 
						||
            }
 | 
						||
        },
 | 
						||
 | 
						||
        removeAllPills(quiet) {
 | 
						||
            while (store.pills.length > 0) {
 | 
						||
                this.removeLastPill(quiet);
 | 
						||
            }
 | 
						||
 | 
						||
            this.clear(store.$input[0]);
 | 
						||
        },
 | 
						||
 | 
						||
        insertManyPills(pills) {
 | 
						||
            if (typeof pills === "string") {
 | 
						||
                pills = pills.split(/,/g).map((pill) => 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.filter(
 | 
						||
                (pill) =>
 | 
						||
                    // if this returns `false`, it erroed and we should push it to
 | 
						||
                    // the draft pills.
 | 
						||
                    funcs.appendPill(pill) === false,
 | 
						||
            );
 | 
						||
 | 
						||
            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 if the operation wasn't completely successful,
 | 
						||
            // which in this case is defined as some of the pills not autofilling
 | 
						||
            // correctly.
 | 
						||
            return drafts.length === 0;
 | 
						||
        },
 | 
						||
 | 
						||
        getByID(id) {
 | 
						||
            return store.pills.find((pill) => pill.id === id);
 | 
						||
        },
 | 
						||
 | 
						||
        _get_pills_for_testing() {
 | 
						||
            return store.pills;
 | 
						||
        },
 | 
						||
 | 
						||
        items() {
 | 
						||
            return store.pills.map((pill) => pill.item);
 | 
						||
        },
 | 
						||
 | 
						||
        createPillonPaste() {
 | 
						||
            if (typeof store.createPillonPaste === "function") {
 | 
						||
                return store.createPillonPaste();
 | 
						||
            }
 | 
						||
            return true;
 | 
						||
        },
 | 
						||
    };
 | 
						||
 | 
						||
    {
 | 
						||
        store.$parent.on("keydown", ".input", (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).trim();
 | 
						||
                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 || window.getSelection().anchorOffset === 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 && window.getSelection().anchorOffset === 0) {
 | 
						||
                store.$parent.find(".pill").last().trigger("focus");
 | 
						||
            }
 | 
						||
 | 
						||
            // Typing of the comma is prevented if the last field doesn't validate,
 | 
						||
            // as well as when the new pill is created.
 | 
						||
            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]);
 | 
						||
                }
 | 
						||
                e.preventDefault();
 | 
						||
 | 
						||
                return;
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        // handle events while hovering on ".pill" elements.
 | 
						||
        // the three primary events are next, previous, and delete.
 | 
						||
        store.$parent.on("keydown", ".pill", (e) => {
 | 
						||
            const char = e.keyCode || e.charCode;
 | 
						||
 | 
						||
            const $pill = store.$parent.find(".pill:focus");
 | 
						||
 | 
						||
            if (char === KEY.LEFT_ARROW) {
 | 
						||
                $pill.prev().trigger("focus");
 | 
						||
            } else if (char === KEY.RIGHT_ARROW) {
 | 
						||
                $pill.next().trigger("focus");
 | 
						||
            } else if (char === KEY.BACKSPACE) {
 | 
						||
                const $next = $pill.next();
 | 
						||
                const id = $pill.data("id");
 | 
						||
                funcs.removePill(id);
 | 
						||
                $next.trigger("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", (e) => {
 | 
						||
            e.preventDefault();
 | 
						||
 | 
						||
            // get text representation of clipboard
 | 
						||
            const text = (e.originalEvent || e).clipboardData.getData("text/plain");
 | 
						||
 | 
						||
            // insert text manually
 | 
						||
            document.execCommand("insertText", false, text);
 | 
						||
 | 
						||
            if (funcs.createPillonPaste()) {
 | 
						||
                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.trigger("focus");
 | 
						||
        });
 | 
						||
 | 
						||
        store.$parent.on("click", function (e) {
 | 
						||
            if ($(e.target).is(".pill-container")) {
 | 
						||
                $(this).find(".input").trigger("focus");
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        store.$parent.on("copy", ".pill", (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),
 | 
						||
 | 
						||
        getByID: funcs.getByID,
 | 
						||
        items: funcs.items,
 | 
						||
 | 
						||
        onPillCreate(callback) {
 | 
						||
            store.onPillCreate = callback;
 | 
						||
        },
 | 
						||
 | 
						||
        onPillRemove(callback) {
 | 
						||
            store.removePillFunction = callback;
 | 
						||
        },
 | 
						||
 | 
						||
        createPillonPaste(callback) {
 | 
						||
            store.createPillonPaste = callback;
 | 
						||
        },
 | 
						||
 | 
						||
        clear: funcs.removeAllPills.bind(funcs),
 | 
						||
        clear_text: funcs.clear_text,
 | 
						||
        is_pending: funcs.is_pending,
 | 
						||
        _get_pills_for_testing: funcs._get_pills_for_testing,
 | 
						||
    };
 | 
						||
 | 
						||
    return prototype;
 | 
						||
}
 |