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