UNPKG

@nextcloud/vue

Version:
776 lines (775 loc) 27.4 kB
require('../assets/NcRichContenteditable-BoM95AVW.css'); "use strict"; const debounce = require("debounce"); const stringLength = require("string-length"); const Tribute = require("tributejs/dist/tribute.esm.js"); const Composables_useIsDarkTheme = require("../Composables/useIsDarkTheme.cjs"); const getAvatarUrl = require("./getAvatarUrl-Du9Y3cPO.cjs"); const NcUserStatusIcon = require("./NcUserStatusIcon-DhZabBIY.cjs"); const _pluginVue2_normalizer = require("./_plugin-vue2_normalizer-V0q-tHlQ.cjs"); const useModelMigration = require("./useModelMigration-D5zhrNXr.cjs"); const emoji = require("./emoji-VgSjNTd5.cjs"); const _l10n = require("./_l10n-DM-VRK9x.cjs"); const Mixins_richEditor = require("./index-CgkN1xho.cjs"); const GenRandomId = require("./GenRandomId-D7iOvpZS.cjs"); const logger = require("./logger-3HuiEIF6.cjs"); require("@nextcloud/auth"); require("@nextcloud/axios"); require("@nextcloud/router"); require("@nextcloud/sharing/public"); require("@vueuse/core"); require("vue"); require("vue-router"); require("./legacy-KBXhWdRy.cjs"); require("./NcButton-DOsCAjiE.cjs"); const referencePickerModal = require("./referencePickerModal-BcAnnsxZ.cjs"); require("./customPickerElements-GyIIOHiQ.cjs"); require("unist-builder"); require("unist-util-visit"); require("./NcRichText-DYcfsH5s.cjs"); require("../Components/NcEmptyContent.cjs"); require("./NcSelect-s3_u0qvy.cjs"); require("../Components/NcLoadingIcon.cjs"); require("./NcTextField-Cp3tulze.cjs"); require("@nextcloud/event-bus"); require("../Components/NcModal.cjs"); const _interopDefault = (e) => e && e.__esModule ? e : { default: e }; const debounce__default = /* @__PURE__ */ _interopDefault(debounce); const stringLength__default = /* @__PURE__ */ _interopDefault(stringLength); const Tribute__default = /* @__PURE__ */ _interopDefault(Tribute); const _sfc_main$1 = { name: "NcAutoCompleteResult", components: { NcUserStatusIcon: NcUserStatusIcon.NcUserStatusIcon }, /* eslint vue/require-prop-comment: warn -- TODO: Add a proper doc block about what this props do */ props: { /** * @deprecated Use `label` instead */ title: { type: String, required: false, default: null }, label: { type: String, required: false, default: null }, subline: { type: String, default: null }, id: { type: String, default: null }, icon: { type: String, required: true }, iconUrl: { type: String, default: null }, source: { type: String, required: true }, status: { type: [Object, Array], default: () => ({}) } }, setup() { const isDarkTheme = Composables_useIsDarkTheme.useIsDarkTheme(); return { isDarkTheme }; }, computed: { avatarUrl() { if (this.iconUrl) { return this.iconUrl; } return this.id && this.source === "users" ? getAvatarUrl.getAvatarUrl(this.id, { isDarkTheme: this.isDarkTheme }) : null; }, // For backwards compatibility labelWithFallback() { return this.label || this.title; } } }; var _sfc_render$1 = function render() { var _vm = this, _c = _vm._self._c; return _c("div", { staticClass: "autocomplete-result" }, [_c("div", { staticClass: "autocomplete-result__icon", class: [_vm.icon, `autocomplete-result__icon--${_vm.avatarUrl ? "with-avatar" : ""}`], style: _vm.avatarUrl ? { backgroundImage: `url(${_vm.avatarUrl})` } : null }, [_vm.status.icon ? _c("span", { staticClass: "autocomplete-result__status autocomplete-result__status--icon" }, [_vm._v(" " + _vm._s(_vm.status && _vm.status.icon || "") + " ")]) : _vm.status.status && _vm.status.status !== "offline" ? _c("NcUserStatusIcon", { staticClass: "autocomplete-result__status", attrs: { "status": _vm.status.status } }) : _vm._e()], 1), _c("span", { staticClass: "autocomplete-result__content" }, [_c("span", { staticClass: "autocomplete-result__title", attrs: { "title": _vm.labelWithFallback } }, [_vm._v(" " + _vm._s(_vm.labelWithFallback) + " ")]), _vm.subline ? _c("span", { staticClass: "autocomplete-result__subline" }, [_vm._v(" " + _vm._s(_vm.subline) + " ")]) : _vm._e()])]); }; var _sfc_staticRenderFns$1 = []; var __component__$1 = /* @__PURE__ */ _pluginVue2_normalizer.normalizeComponent( _sfc_main$1, _sfc_render$1, _sfc_staticRenderFns$1, false, null, "ef14f1ec" ); const NcAutoCompleteResult = __component__$1.exports; _l10n.register(_l10n.t32, _l10n.t35); const style1 = { "material-design-icon": "_material-design-icon_1sdgd_12", "tribute-container": "_tribute-container_1sdgd_20", "tribute-container__item": "_tribute-container__item_1sdgd_41", "tribute-container--focus-visible": "_tribute-container--focus-visible_1sdgd_55", "tribute-container-autocomplete": "_tribute-container-autocomplete_1sdgd_59", "tribute-container-emoji": "_tribute-container-emoji_1sdgd_65", "tribute-container-link": "_tribute-container-link_1sdgd_66", "tribute-item": "_tribute-item_1sdgd_71", "tribute-item__title": "_tribute-item__title_1sdgd_86", "tribute-item__icon": "_tribute-item__icon_1sdgd_91" }; const smilesCharacters = ["d", "D", "p", "P", "s", "S", "x", "X", ")", "(", "|", "/"]; const textSmiles = []; smilesCharacters.forEach((char) => { textSmiles.push(":" + char); textSmiles.push(":-" + char); }); const _sfc_main = { name: "NcRichContenteditable", mixins: [Mixins_richEditor.richEditor], inheritAttrs: false, model: { prop: "modelValue", event: "update:modelValue" }, props: { /** * The ID attribute of the content editable */ id: { type: String, default: () => GenRandomId.GenRandomId(7) }, /** * Visual label of the contenteditable */ label: { type: String, default: "" }, /** * Removed in v9 - use `modelValue` (`v-model`) instead * * @deprecated */ value: { type: String, default: void 0 }, /** * The text content */ modelValue: { type: String, default: "" }, /** * Placeholder to be shown if empty */ placeholder: { type: String, default: _l10n.t("Write a message …") }, /** * Auto complete function */ autoComplete: { type: Function, default: () => [] }, /** * The containing element for the menu popover */ menuContainer: { type: Element, default: () => document.body }, /** * Make the contenteditable looks like a textarea or not. * Default looks like a single-line input. * This also handle the default enter/shift+enter behaviour. * if multiline, enter = newline; otherwise enter = submit * shift+enter always add a new line. ctrl+enter always submits */ multiline: { type: Boolean, default: false }, /** * Is the content editable ? */ contenteditable: { type: Boolean, // eslint-disable-next-line vue/no-boolean-default default: true }, /** * Disable the editing and show specific disabled design */ disabled: { type: Boolean, default: false }, /** * Max allowed length */ maxlength: { type: Number, default: null }, /** * Enable or disable emoji autocompletion */ emojiAutocomplete: { type: Boolean, // eslint-disable-next-line vue/no-boolean-default default: true }, /** * Enable or disable link autocompletion */ linkAutocomplete: { type: Boolean, // eslint-disable-next-line vue/no-boolean-default default: true } }, emits: [ "submit", "paste", /** * Removed in v9 - use `update:modelValue` (`v-model`) instead * * @deprecated */ "update:value", "update:modelValue", /** Same as update:modelValue for Vue 2 compatibility */ "update:model-value", "smart-picker-submit" ], setup() { const uid = GenRandomId.GenRandomId(5); const model = useModelMigration.useModelMigration("value", "update:value", true); return { model, // Constants labelId: `nc-rich-contenteditable-${uid}-label`, tributeId: `nc-rich-contenteditable-${uid}-tribute`, /** * Non-reactive property to store Tribute instance * * @type {import('tributejs').default | null} */ tribute: null, tributeStyleMutationObserver: null }; }, data() { return { // Represent the raw untrimmed text of the contenteditable // serves no other purpose than to check whether the // content is empty or not localValue: this.model, // Is in text composition session in IME isComposing: false, // Tribute autocomplete isAutocompleteOpen: false, autocompleteActiveId: void 0, isTributeIntegrationDone: false }; }, computed: { /** * Is the current trimmed value empty? * * @return {boolean} */ isEmptyValue() { return !this.localValue || this.localValue.trim() === ""; }, /** * Is the current value over maxlength? * * @return {boolean} */ isOverMaxlength() { if (this.isEmptyValue || !this.maxlength) { return false; } return stringLength__default.default(this.localValue) > this.maxlength; }, /** * Tooltip to show if characters count is over limit * * @return {string} */ tooltipString() { if (!this.isOverMaxlength) { return null; } return _l10n.n("Message limit of %n character reached", "Message limit of %n characters reached", this.maxlength); }, /** * Edit is only allowed when contenteditableis true and disabled is false * * @return {boolean} */ canEdit() { return this.contenteditable && !this.disabled; }, /** * Proxied native event handlers without custom event handlers * * @return {Record<string, Function>} */ listeners() { const listeners = { ...this.$listeners }; delete listeners.paste; return listeners; }, /** * Compute debounce function for the autocomplete function */ debouncedAutoComplete() { return debounce__default.default(async (search, callback) => { this.autoComplete(search, callback); }, 100); } }, watch: { /** * If the parent value change, we compare the plain text rendering * If it's different, we render everything and update the main content */ model() { const html = this.$refs.contenteditable.innerHTML; if (this.model.trim() !== this.parseContent(html).trim()) { this.updateContent(this.model); } } }, mounted() { this.initializeTribute(); this.updateContent(this.model); this.$refs.contenteditable.contentEditable = this.canEdit; }, beforeDestroy() { if (this.tribute) { this.tribute.detach(this.$refs.contenteditable); } if (this.tributeStyleMutationObserver) { this.tributeStyleMutationObserver.disconnect(); } }, methods: { /** * Focus the richContenteditable * * @public */ focus() { this.$refs.contenteditable.focus(); }, initializeTribute() { const renderMenuItem = (content) => `<div id="nc-rich-contenteditable-tribute-item-${GenRandomId.GenRandomId(5)}" class="${this.$style["tribute-item"]}" role="option">${content}</div>`; const tributesCollection = []; tributesCollection.push({ fillAttr: "id", // Search against id and label (display name) (fallback to title for v8.0.0..8.6.1 compatibility) lookup: (result) => `${result.id} ${result.label ?? result.title}`, requireLeadingSpace: true, // Popup mention autocompletion templates menuItemTemplate: (item) => renderMenuItem(this.renderComponentHtml(item.original, NcAutoCompleteResult)), // Hide if no results noMatchTemplate: () => '<span class="hidden"></span>', // Inner display of mentions selectTemplate: (item) => this.genSelectTemplate(item?.original?.id), // Autocompletion results values: this.debouncedAutoComplete, // Class added to the menu container containerClass: `${this.$style["tribute-container"]} ${this.$style["tribute-container-autocomplete"]}`, // Class added to each list item itemClass: this.$style["tribute-container__item"] }); if (this.emojiAutocomplete) { tributesCollection.push({ trigger: ":", // Don't use the tribute search function at all // We pass search results as values (see below) lookup: (result, query) => query, requireLeadingSpace: true, // Popup mention autocompletion templates menuItemTemplate: (item) => { if (textSmiles.includes(item.original)) { return item.original; } return renderMenuItem(`<span class="${this.$style["tribute-item__emoji"]}">${item.original.native}</span> :${item.original.short_name}`); }, // Hide if no results noMatchTemplate: () => _l10n.t("No emoji found"), // Display raw emoji along with its name selectTemplate: (item) => { if (textSmiles.includes(item.original)) { return item.original; } emoji.emojiAddRecent(item.original); return item.original.native; }, // Pass the search results as values values: (text, cb) => { const emojiResults = emoji.emojiSearch(text); if (textSmiles.includes(":" + text)) { emojiResults.unshift(":" + text); } cb(emojiResults); }, // Class added to the menu container containerClass: `${this.$style["tribute-container"]} ${this.$style["tribute-container-emoji"]}`, // Class added to each list item itemClass: this.$style["tribute-container__item"] }); } if (this.linkAutocomplete) { tributesCollection.push({ trigger: "/", // Don't use the tribute search function at all // We pass search results as values (see below) lookup: (result, query) => query, requireLeadingSpace: true, // Popup mention autocompletion templates menuItemTemplate: (item) => renderMenuItem(`<img class="${this.$style["tribute-item__icon"]}" src="${item.original.icon_url}"> <span class="${this.$style["tribute-item__title"]}">${item.original.title}</span>`), // Hide if no results noMatchTemplate: () => _l10n.t("No link provider found"), selectTemplate: this.getLink, // Pass the search results as values values: (text, cb) => cb(referencePickerModal.searchProvider(text)), // Class added to the menu container containerClass: `${this.$style["tribute-container"]} ${this.$style["tribute-container-link"]}`, // Class added to each list item itemClass: this.$style["tribute-container__item"] }); } this.tribute = new Tribute__default.default({ collection: tributesCollection, // FIXME: tributejs doesn't support allowSpaces as a collection option, only as a global one // Requires to fork a library to allow spaces only in the middle of mentions ('@' trigger) allowSpaces: false, // Where to inject the menu popup menuContainer: this.menuContainer }); this.tribute.attach(this.$refs.contenteditable); }, getLink(item) { referencePickerModal.getLinkWithPicker(item.original.id).then((result) => { const tmpElem = document.getElementById("tmp-smart-picker-result-node"); const eventData = { result, insertText: true }; this.$emit("smart-picker-submit", eventData); if (eventData.insertText) { const newElem = document.createTextNode(result); tmpElem.replaceWith(newElem); this.setCursorAfter(newElem); this.updateValue(this.$refs.contenteditable.innerHTML); } else { tmpElem.remove(); } }).catch((error) => { logger.logger.debug("Smart picker promise rejected:", error); const tmpElem = document.getElementById("tmp-smart-picker-result-node"); this.setCursorAfter(tmpElem); tmpElem.remove(); }); return '<span id="tmp-smart-picker-result-node"></span>'; }, setCursorAfter(element) { const range = document.createRange(); range.setEndAfter(element); range.collapse(); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }, moveCursorToEnd() { if (!document.createRange) { return; } const range = document.createRange(); range.selectNodeContents(this.$refs.contenteditable); range.collapse(false); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }, /** * Re-emit the input event to the parent * * @param {Event} event the input event */ onInput(event) { this.updateValue(event.target.innerHTML); }, /** * When pasting, sanitize the content, extract text * and render it again * * @param {Event} event the paste event * @fires Event paste the original paste event */ onPaste(event) { if (!this.canEdit) { return; } event.preventDefault(); const clipboardData = event.clipboardData; this.$emit("paste", event); if (clipboardData.files.length !== 0 || !Object.values(clipboardData.items).find((item) => item?.type.startsWith("text"))) { return; } const text = clipboardData.getData("text"); const selection = window.getSelection(); const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); range.collapse(false); this.updateValue(this.$refs.contenteditable.innerHTML); }, /** * Update the value text from the provided html * * @param {string} htmlOrText the html content (or raw text with @mentions) */ updateValue(htmlOrText) { const text = this.parseContent(htmlOrText).replace(/^\n$/, ""); this.localValue = text; this.model = text; }, /** * Update content and local value * * @param {string} value the message value */ updateContent(value) { const renderedContent = this.renderContent(value); this.$refs.contenteditable.innerHTML = renderedContent; this.localValue = value; }, /** * Enter key pressed. Submits if not multiline * * @param {Event} event the keydown event */ onEnter(event) { if (this.multiline || this.isOverMaxlength || this.tribute.isActive || this.isComposing) { return; } event.preventDefault(); event.stopPropagation(); this.$emit("submit", event); }, /** * Ctrl + Enter key pressed is used to submit * * @param {Event} event the keydown event */ onCtrlEnter(event) { if (this.isOverMaxlength) { return; } this.$emit("submit", event); }, onKeyUp(event) { event.stopImmediatePropagation(); }, onKeyEsc(event) { if (this.tribute && this.isAutocompleteOpen) { event.stopImmediatePropagation(); this.tribute.hideMenu(); } }, /** * Get HTML element with Tribute.js container * * @return {HTMLElement} */ getTributeContainer() { return this.tribute.menu; }, /** * Get the currently selected item element id in Tribute.js container * * @return {HTMLElement} */ getTributeSelectedItem() { return this.getTributeContainer().querySelector('.highlight [id^="nc-rich-contenteditable-tribute-item-"]'); }, /** * Handle Tribute activation * * @param {boolean} isActive - is active */ onTributeActive(isActive) { this.isAutocompleteOpen = isActive; if (isActive) { this.getTributeContainer().setAttribute("class", this.tribute.current.collection.containerClass || this.$style["tribute-container"]); this.setupTributeIntegration(); document.removeEventListener("click", this.hideTribute, true); } else { this.debouncedAutoComplete.clear(); this.autocompleteActiveId = void 0; this.setTributeFocusVisible(false); } }, onTributeArrowKeyDown() { if (!this.isAutocompleteOpen) { return; } this.setTributeFocusVisible(true); this.onTributeSelectedItemWillChange(); }, onTributeSelectedItemWillChange() { requestAnimationFrame(() => { this.autocompleteActiveId = this.getTributeSelectedItem()?.id; }); }, setupTributeIntegration() { if (this.isTributeIntegrationDone) { return; } this.isTributeIntegrationDone = true; const tributeContainer = this.getTributeContainer(); tributeContainer.id = this.tributeId; tributeContainer.setAttribute("role", "listbox"); const ul = tributeContainer.children[0]; ul.setAttribute("role", "presentation"); this.tributeStyleMutationObserver = new MutationObserver(([{ target }]) => { if (target.style.display !== "none") { this.onTributeSelectedItemWillChange(); } }).observe(tributeContainer, { attributes: true, attributeFilter: ["style"] }); tributeContainer.addEventListener("mousemove", () => { this.setTributeFocusVisible(false); this.onTributeSelectedItemWillChange(); }, { passive: true }); }, /** * Set tribute-container--focus-visible class on the Tribute container when the user navigates the listbox via keyboard. * * Because the real focus is kept on the textbox, we cannot use the :focus-visible pseudo-class * to style selected options in the autocomplete listbox. * * @param {boolean} withFocusVisible - should the focus-visible class be added */ setTributeFocusVisible(withFocusVisible) { if (withFocusVisible) { this.getTributeContainer().classList.add(this.$style["tribute-container--focus-visible"]); } else { this.getTributeContainer().classList.remove(this.$style["tribute-container--focus-visible"]); } }, /** * Show tribute menu programmatically. * * @param {string} trigger - trigger character, can be '/', '@', or ':' * * @public */ showTribute(trigger) { this.focus(); const index = this.tribute.collection.findIndex((collection) => collection.trigger === trigger); this.tribute.showMenuForCollection(this.$refs.contenteditable, index); this.updateValue(this.$refs.contenteditable.innerHTML); document.addEventListener("click", this.hideTribute, true); }, /** * Hide tribute menu programmatically * */ hideTribute() { this.tribute.hideMenu(); document.removeEventListener("click", this.hideTribute, true); } } }; var _sfc_render = function render2() { var _vm = this, _c = _vm._self._c; return _c("div", { staticClass: "rich-contenteditable" }, [_c("div", _vm._g(_vm._b({ ref: "contenteditable", staticClass: "rich-contenteditable__input", class: { "rich-contenteditable__input--empty": _vm.isEmptyValue, "rich-contenteditable__input--multiline": _vm.multiline, "rich-contenteditable__input--has-label": _vm.label, "rich-contenteditable__input--overflow": _vm.isOverMaxlength, "rich-contenteditable__input--disabled": _vm.disabled }, attrs: { "id": _vm.id, "contenteditable": _vm.canEdit, "aria-labelledby": _vm.label ? _vm.labelId : void 0, "aria-placeholder": _vm.placeholder, "aria-multiline": "true", "role": "textbox", "aria-haspopup": "listbox", "aria-autocomplete": "inline", "aria-controls": _vm.tributeId, "aria-expanded": _vm.isAutocompleteOpen ? "true" : "false", "aria-activedescendant": _vm.autocompleteActiveId, "title": _vm.tooltipString }, on: { "focus": _vm.moveCursorToEnd, "input": _vm.onInput, "compositionstart": function($event) { _vm.isComposing = true; }, "compositionend": function($event) { _vm.isComposing = false; }, "!keydown": function($event) { if (!$event.type.indexOf("key") && _vm._k($event.keyCode, "esc", 27, $event.key, ["Esc", "Escape"])) return null; return _vm.onKeyEsc.apply(null, arguments); }, "keydown": [function($event) { if (!$event.type.indexOf("key") && _vm._k($event.keyCode, "enter", 13, $event.key, "Enter")) return null; if ($event.ctrlKey || $event.shiftKey || $event.altKey || $event.metaKey) return null; return _vm.onEnter.apply(null, arguments); }, function($event) { if (!$event.type.indexOf("key") && _vm._k($event.keyCode, "enter", 13, $event.key, "Enter")) return null; if (!$event.ctrlKey) return null; if ($event.shiftKey || $event.altKey || $event.metaKey) return null; $event.stopPropagation(); $event.preventDefault(); return _vm.onCtrlEnter.apply(null, arguments); }, function($event) { if (!$event.type.indexOf("key") && _vm._k($event.keyCode, "up", 38, $event.key, ["Up", "ArrowUp"])) return null; if ($event.ctrlKey || $event.shiftKey || $event.altKey || $event.metaKey) return null; $event.stopPropagation(); return _vm.onTributeArrowKeyDown.apply(null, arguments); }, function($event) { if (!$event.type.indexOf("key") && _vm._k($event.keyCode, "down", 40, $event.key, ["Down", "ArrowDown"])) return null; if ($event.ctrlKey || $event.shiftKey || $event.altKey || $event.metaKey) return null; $event.stopPropagation(); return _vm.onTributeArrowKeyDown.apply(null, arguments); }], "paste": _vm.onPaste, "!keyup": function($event) { $event.stopPropagation(); $event.preventDefault(); return _vm.onKeyUp.apply(null, arguments); }, "tribute-active-true": function($event) { return _vm.onTributeActive(true); }, "tribute-active-false": function($event) { return _vm.onTributeActive(false); } } }, "div", _vm.$attrs, false), _vm.listeners)), _vm.label ? _c("div", { staticClass: "rich-contenteditable__label", attrs: { "id": _vm.labelId } }, [_vm._v(" " + _vm._s(_vm.label) + " ")]) : _vm._e()]); }; var _sfc_staticRenderFns = []; const __cssModules = { "$style": style1 }; function _sfc_injectStyles(ctx) { for (var key in __cssModules) { this[key] = __cssModules[key]; } } var __component__ = /* @__PURE__ */ _pluginVue2_normalizer.normalizeComponent( _sfc_main, _sfc_render, _sfc_staticRenderFns, false, _sfc_injectStyles, "a5d4e22b" ); const NcRichContenteditable = __component__.exports; exports.NcAutoCompleteResult = NcAutoCompleteResult; exports.NcRichContenteditable = NcRichContenteditable; //# sourceMappingURL=NcRichContenteditable-BILbjzSU.cjs.map