UNPKG

@nextcloud/vue

Version:
773 lines (772 loc) 26.7 kB
import '../assets/NcRichContenteditable-BoM95AVW.css'; import debounce from "debounce"; import stringLength from "string-length"; import Tribute from "tributejs/dist/tribute.esm.js"; import { useIsDarkTheme } from "../Composables/useIsDarkTheme.mjs"; import { g as getAvatarUrl } from "./getAvatarUrl-IhLacDEr.mjs"; import { N as NcUserStatusIcon } from "./NcUserStatusIcon-C83nB_8T.mjs"; import { n as normalizeComponent } from "./_plugin-vue2_normalizer-DU4iP6Vu.mjs"; import { u as useModelMigration } from "./useModelMigration-EhAWvqDD.mjs"; import { e as emojiSearch, a as emojiAddRecent } from "./emoji-BY_D0V5K.mjs"; import { r as register, y as t35, F as t32, a as t, G as n } from "./_l10n-BEfeU7gr.mjs"; import { r as richEditor } from "./index-TmAR7I2T.mjs"; import { G as GenRandomId } from "./GenRandomId-F5ebeBB_.mjs"; import { l as logger } from "./logger-D3RVzcfQ.mjs"; import "@nextcloud/auth"; import "@nextcloud/axios"; import "@nextcloud/router"; import "@nextcloud/sharing/public"; import "@vueuse/core"; import "vue"; import "vue-router"; import "./legacy-MK4GvP26.mjs"; import "./NcButton-CWPBzbcC.mjs"; import { g as getLinkWithPicker, s as searchProvider } from "./referencePickerModal-CN4C9eDc.mjs"; import "./customPickerElements-DLFtgReB.mjs"; import "unist-builder"; import "unist-util-visit"; import "./NcRichText-B7M7rNqC.mjs"; import "../Components/NcEmptyContent.mjs"; import "./NcSelect-PvjbF3jF.mjs"; import "../Components/NcLoadingIcon.mjs"; import "./NcTextField-D_IMz2MR.mjs"; import "@nextcloud/event-bus"; import "../Components/NcModal.mjs"; const _sfc_main$1 = { name: "NcAutoCompleteResult", components: { 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 = useIsDarkTheme(); return { isDarkTheme }; }, computed: { avatarUrl() { if (this.iconUrl) { return this.iconUrl; } return this.id && this.source === "users" ? 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__ */ normalizeComponent( _sfc_main$1, _sfc_render$1, _sfc_staticRenderFns$1, false, null, "ef14f1ec" ); const NcAutoCompleteResult = __component__$1.exports; register(t32, 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: [richEditor], inheritAttrs: false, model: { prop: "modelValue", event: "update:modelValue" }, props: { /** * The ID attribute of the content editable */ id: { type: String, default: () => 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: 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(5); const model = 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(this.localValue) > this.maxlength; }, /** * Tooltip to show if characters count is over limit * * @return {string} */ tooltipString() { if (!this.isOverMaxlength) { return null; } return 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(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(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: () => t("No emoji found"), // Display raw emoji along with its name selectTemplate: (item) => { if (textSmiles.includes(item.original)) { return item.original; } emojiAddRecent(item.original); return item.original.native; }, // Pass the search results as values values: (text, cb) => { const emojiResults = 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: () => t("No link provider found"), selectTemplate: this.getLink, // Pass the search results as values values: (text, cb) => cb(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({ 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) { 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.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__ */ normalizeComponent( _sfc_main, _sfc_render, _sfc_staticRenderFns, false, _sfc_injectStyles, "a5d4e22b" ); const NcRichContenteditable = __component__.exports; export { NcAutoCompleteResult as N, NcRichContenteditable as a }; //# sourceMappingURL=NcRichContenteditable-iQhj1-AH.mjs.map