@nextcloud/vue
Version:
Nextcloud vue components
773 lines (772 loc) • 26.7 kB
JavaScript
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