diff --git a/l10n/messages.pot b/l10n/messages.pot index 3ac4213e26..78a96295c8 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -107,6 +107,9 @@ msgstr "" msgid "No emoji found" msgstr "" +msgid "No link provider found" +msgstr "" + msgid "No results" msgstr "" diff --git a/package-lock.json b/package-lock.json index 745198ef47..d531556280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@nextcloud/l10n": "^2.0.1", "@nextcloud/logger": "^2.2.1", "@nextcloud/router": "^2.0.0", + "@nextcloud/vue-richtext": "^2.1.0-beta.5", "@nextcloud/vue-select": "^3.21.2", "@skjnldsv/sanitize-svg": "^1.0.2", "debounce": "1.2.1", @@ -3117,6 +3118,80 @@ "npm": "^7.0.0 || ^8.0.0" } }, + "node_modules/@nextcloud/vue": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-7.6.1.tgz", + "integrity": "sha512-MKYn72BUR73iZWAYROGbT3Uf1ihlVBS/XxAqQOQEs9oqF4ULdCY0EA+xR43OzGpDOQdgWeW5wt9I59kcC3qbzw==", + "dependencies": { + "@floating-ui/dom": "^1.1.0", + "@nextcloud/auth": "^2.0.0", + "@nextcloud/axios": "^2.0.0", + "@nextcloud/browser-storage": "^0.2.0", + "@nextcloud/calendar-js": "^5.0.3", + "@nextcloud/capabilities": "^1.0.4", + "@nextcloud/dialogs": "^4.0.0", + "@nextcloud/event-bus": "^3.0.0", + "@nextcloud/initial-state": "^2.0.0", + "@nextcloud/l10n": "^2.0.1", + "@nextcloud/logger": "^2.2.1", + "@nextcloud/router": "^2.0.0", + "@nextcloud/vue-select": "^3.21.2", + "@skjnldsv/sanitize-svg": "^1.0.2", + "debounce": "1.2.1", + "emoji-mart-vue-fast": "^12.0.1", + "escape-html": "^1.0.3", + "floating-vue": "^1.0.0-beta.19", + "focus-trap": "^7.1.0", + "hammerjs": "^2.0.8", + "linkify-string": "^4.0.0", + "md5": "^2.3.0", + "node-polyfill-webpack-plugin": "^2.0.1", + "splitpanes": "^2.4.1", + "string-length": "^5.0.1", + "striptags": "^3.2.0", + "tributejs": "^5.1.3", + "v-click-outside": "^3.2.0", + "vue": "^2.7.14", + "vue-color": "^2.8.1", + "vue-material-design-icons": "^5.1.2", + "vue-multiselect": "^2.1.6", + "vue2-datepicker": "^3.11.0" + }, + "engines": { + "node": "^16.0.0", + "npm": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@nextcloud/vue-richtext": { + "version": "2.1.0-beta.5", + "resolved": "https://registry.npmjs.org/@nextcloud/vue-richtext/-/vue-richtext-2.1.0-beta.5.tgz", + "integrity": "sha512-ivvP5AfjyQyhvqfFjJGkjwWFHtur3YaRHwatTYu0BWL3wDKoX9S1I6tb/GQphXB5jabMCTmdi7sPywAs9rwH4Q==", + "dependencies": { + "@nextcloud/axios": "^2.0.0", + "@nextcloud/event-bus": "^3.0.2", + "@nextcloud/initial-state": "^2.0.0", + "@nextcloud/router": "^2.0.0", + "@nextcloud/vue": "^7.5.0", + "clone": "^2.1.2", + "vue": "^2.7.8", + "vue-material-design-icons": "^5.1.2" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "vue": "^2.7.8" + } + }, + "node_modules/@nextcloud/vue-richtext/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/@nextcloud/vue-select": { "version": "3.22.2", "resolved": "https://registry.npmjs.org/@nextcloud/vue-select/-/vue-select-3.22.2.tgz", @@ -28153,6 +28228,68 @@ "@types/jquery": "2.0.60" } }, + "@nextcloud/vue": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-7.6.1.tgz", + "integrity": "sha512-MKYn72BUR73iZWAYROGbT3Uf1ihlVBS/XxAqQOQEs9oqF4ULdCY0EA+xR43OzGpDOQdgWeW5wt9I59kcC3qbzw==", + "requires": { + "@floating-ui/dom": "^1.1.0", + "@nextcloud/auth": "^2.0.0", + "@nextcloud/axios": "^2.0.0", + "@nextcloud/browser-storage": "^0.2.0", + "@nextcloud/calendar-js": "^5.0.3", + "@nextcloud/capabilities": "^1.0.4", + "@nextcloud/dialogs": "^4.0.0", + "@nextcloud/event-bus": "^3.0.0", + "@nextcloud/initial-state": "^2.0.0", + "@nextcloud/l10n": "^2.0.1", + "@nextcloud/logger": "^2.2.1", + "@nextcloud/router": "^2.0.0", + "@nextcloud/vue-select": "^3.21.2", + "@skjnldsv/sanitize-svg": "^1.0.2", + "debounce": "1.2.1", + "emoji-mart-vue-fast": "^12.0.1", + "escape-html": "^1.0.3", + "floating-vue": "^1.0.0-beta.19", + "focus-trap": "^7.1.0", + "hammerjs": "^2.0.8", + "linkify-string": "^4.0.0", + "md5": "^2.3.0", + "node-polyfill-webpack-plugin": "^2.0.1", + "splitpanes": "^2.4.1", + "string-length": "^5.0.1", + "striptags": "^3.2.0", + "tributejs": "^5.1.3", + "v-click-outside": "^3.2.0", + "vue": "^2.7.14", + "vue-color": "^2.8.1", + "vue-material-design-icons": "^5.1.2", + "vue-multiselect": "^2.1.6", + "vue2-datepicker": "^3.11.0" + } + }, + "@nextcloud/vue-richtext": { + "version": "2.1.0-beta.5", + "resolved": "https://registry.npmjs.org/@nextcloud/vue-richtext/-/vue-richtext-2.1.0-beta.5.tgz", + "integrity": "sha512-ivvP5AfjyQyhvqfFjJGkjwWFHtur3YaRHwatTYu0BWL3wDKoX9S1I6tb/GQphXB5jabMCTmdi7sPywAs9rwH4Q==", + "requires": { + "@nextcloud/axios": "^2.0.0", + "@nextcloud/event-bus": "^3.0.2", + "@nextcloud/initial-state": "^2.0.0", + "@nextcloud/router": "^2.0.0", + "@nextcloud/vue": "^7.5.0", + "clone": "^2.1.2", + "vue": "^2.7.8", + "vue-material-design-icons": "^5.1.2" + }, + "dependencies": { + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + } + } + }, "@nextcloud/vue-select": { "version": "3.22.2", "resolved": "https://registry.npmjs.org/@nextcloud/vue-select/-/vue-select-3.22.2.tgz", @@ -28163,7 +28300,7 @@ "version": "git+ssh://git@github.com/nextcloud/webpack-vue-config.git#17ec724240862ce65d32b0522fd949bda1e143ce", "integrity": "sha512-SvgLgQ1NlSnTrv2ArapeCAcQFd2vgwMRgVTD1TtGs8s0veU0lR5QYe/CcJbN2eBavpJxqDaGi183mPeObJTH/Q==", "dev": true, - "from": "@nextcloud/webpack-vue-config@github:nextcloud/webpack-vue-config#17ec724240862ce65d32b0522fd949bda1e143ce", + "from": "@nextcloud/webpack-vue-config@github:nextcloud/webpack-vue-config#master", "requires": {} }, "@nicolo-ribaudo/eslint-scope-5-internals": { diff --git a/package.json b/package.json index 75f12b7505..63388c1a6e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@nextcloud/l10n": "^2.0.1", "@nextcloud/logger": "^2.2.1", "@nextcloud/router": "^2.0.0", + "@nextcloud/vue-richtext": "^2.1.0-beta.5", "@nextcloud/vue-select": "^3.21.2", "@skjnldsv/sanitize-svg": "^1.0.2", "debounce": "1.2.1", diff --git a/src/components/NcRichContenteditable/NcRichContenteditable.vue b/src/components/NcRichContenteditable/NcRichContenteditable.vue index 018e3752c4..0ed33e37e8 100644 --- a/src/components/NcRichContenteditable/NcRichContenteditable.vue +++ b/src/components/NcRichContenteditable/NcRichContenteditable.vue @@ -148,6 +148,7 @@ import NcAutoCompleteResult from './NcAutoCompleteResult.vue' import richEditor from '../../mixins/richEditor/index.js' import Tooltip from '../../directives/Tooltip/index.js' import { emojiSearch, emojiAddRecent } from '../../functions/emoji/index.js' +import { linkProviderSearch, getLink } from '../../functions/linkPicker/index.js' import Tribute from 'tributejs/dist/tribute.esm.js' import debounce from 'debounce' @@ -227,6 +228,14 @@ export default { type: Boolean, default: true, }, + + /** + * Enable or disable link autocompletion + */ + linkAutocomplete: { + type: Boolean, + default: true, + }, }, emits: [ @@ -278,6 +287,25 @@ export default { // Class added to each list item itemClass: 'tribute-container-emoji__item', }, + linkOptions: { + trigger: '/', + // Don't use the tribute search function at all + // We pass search results as values (see below) + lookup: (result, query) => query, + // Where to inject the menu popup + menuContainer: this.menuContainer, + // Popup mention autocompletion templates + menuItemTemplate: item => ` ${item.original.title}`, + // Hide if no results + noMatchTemplate: () => t('No link provider found'), + selectTemplate: this.getLink, + // Pass the search results as values + values: (text, cb) => cb(linkProviderSearch(text)), + // Class added to the menu container + containerClass: 'tribute-container-link', + // Class added to each list item + itemClass: 'tribute-container-link__item', + }, // Represent the raw untrimmed text of the contenteditable // serves no other purpose than to check whether the @@ -367,6 +395,11 @@ export default { this.emojiTribute.attach(this.$el) } + if (this.linkAutocomplete) { + this.linkTribute = new Tribute(this.linkOptions) + this.linkTribute.attach(this.$el) + } + // Update default value this.updateContent(this.value) @@ -381,9 +414,40 @@ export default { if (this.emojiTribute) { this.emojiTribute.detach(this.$el) } + if (this.linkTribute) { + this.linkTribute.detach(this.$el) + } }, methods: { + getLink(item) { + // there is no way to get a tribute result asynchronously + // so we immediately insert a node and replace it when the result comes + getLink(item.original.id) + .then(link => { + // replace dummy temp element by a text node which contains the link + const tmpElem = document.getElementById('tmp-link-result-node') + const newElem = document.createTextNode(link) + tmpElem.replaceWith(newElem) + this.setCursorAfter(newElem) + this.updateValue(this.$refs.contenteditable.innerHTML) + }) + .catch((error) => { + console.debug('Link picker promise rejected:', error) + const tmpElem = document.getElementById('tmp-link-result-node') + this.setCursorAfter(tmpElem) + tmpElem.remove() + }) + return '' + }, + setCursorAfter(element) { + const range = document.createRange() + range.setEndAfter(element) + range.collapse() + const selection = window.getSelection() + selection.removeAllRanges() + selection.addRange(range) + }, /** * Re-emit the input event to the parent * @@ -527,7 +591,7 @@ export default { // Prevent submitting if autocompletion menu // is opened or length is over maxlength if (this.multiline || this.isOverMaxlength - || this.autocompleteTribute.isActive || this.emojiTribute.isActive) { + || this.autocompleteTribute.isActive || this.emojiTribute.isActive || this.linkTribute.isActive) { return } @@ -611,7 +675,7 @@ export default { diff --git a/src/functions/linkPicker/index.js b/src/functions/linkPicker/index.js new file mode 100644 index 0000000000..ee01944e98 --- /dev/null +++ b/src/functions/linkPicker/index.js @@ -0,0 +1,27 @@ +/** + * @copyright Copyright (c) 2023 Julien Veyssier + * + * @author Julien Veyssier + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { searchProvider, getLinkWithPicker } from '@nextcloud/vue-richtext' + +export const linkProviderSearch = searchProvider + +export const getLink = getLinkWithPicker