diff --git a/package-lock.json b/package-lock.json index 3bbe87fd565..db2e795651e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@tiptap/suggestion": "^2.0.0-beta.91", "@tiptap/vue-2": "^2.0.0-beta.78", "core-js": "^3.22.3", + "debounce": "^1.2.1", "escape-html": "^1.0.3", "highlight.js": "^10.7.2", "lowlight": "^1.20.0", diff --git a/package.json b/package.json index bf4bba30add..8e70129ee7b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@tiptap/suggestion": "^2.0.0-beta.91", "@tiptap/vue-2": "^2.0.0-beta.78", "core-js": "^3.22.3", + "debounce": "^1.2.1", "escape-html": "^1.0.3", "highlight.js": "^10.7.2", "lowlight": "^1.20.0", diff --git a/src/components/EditorDraggable.provider.js b/src/components/EditorDraggable.provider.js new file mode 100644 index 00000000000..2bb879a3d2a --- /dev/null +++ b/src/components/EditorDraggable.provider.js @@ -0,0 +1,21 @@ +export const IS_UPLOADING_IMAGES = Symbol('editor:is-uploading-images') +export const ACTION_IMAGE_PROMPT = Symbol('editor:action:image-prompt') +export const ACTION_CHOOSE_LOCAL_IMAGE = Symbol('editor:action:upload-image') + +export const useIsUploadingImagesMixin = { + inject: { + $isUploadingImages: { from: IS_UPLOADING_IMAGES, default: false }, + }, +} + +export const useActionImagePromptMixin = { + inject: { + $callImagePrompt: { from: ACTION_IMAGE_PROMPT, default: () => {} }, + }, +} + +export const useActionChooseLocalImageMixin = { + inject: { + $callChooseLocalImage: { from: ACTION_CHOOSE_LOCAL_IMAGE, default: () => {} }, + }, +} diff --git a/src/components/EditorDraggable.vue b/src/components/EditorDraggable.vue new file mode 100644 index 00000000000..2928ba038a3 --- /dev/null +++ b/src/components/EditorDraggable.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/src/components/EditorWrapper.provider.js b/src/components/EditorWrapper.provider.js index c19ebdc0dbc..ccdb2408b18 100644 --- a/src/components/EditorWrapper.provider.js +++ b/src/components/EditorWrapper.provider.js @@ -1,13 +1,93 @@ +/* + * @copyright Copyright (c) 2022 Vinicius Reis + * + * @author Vinicius Reis + * + * @license GNU AGPL version 3 or any later version + * + * 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 . + * + */ + export const EDITOR = Symbol('tiptap:editor') export const SYNC_SERVICE = Symbol('sync:service') +export const DOCUMENT = Symbol('editor:document') +export const IS_PUBLIC = Symbol('editor:is-public') +export const FILE = Symbol('editor:file') +export const IS_RICH_EDITOR = Symbol('editor:is-rich-editor') +export const IS_MOBILE = Symbol('editor:is-mobile') +export const RELATIVE_PATH = Symbol('editor:relative-path') +export const IS_UPLOADING_IMAGES = Symbol('editor:is-uploading-images') +export const ACTION_IMAGE_PROMPT = Symbol('action:image-prompt') export const useEditorMixin = { inject: { $editor: { from: EDITOR, default: null }, }, } + export const useSyncServiceMixin = { inject: { $syncService: { from: SYNC_SERVICE, default: null }, }, } + +export const useIsPublic = { + inject: { + $isPublic: { from: IS_PUBLIC, default: false }, + }, +} + +export const useIsRichEditorMixin = { + inject: { + $isRichEditor: { from: IS_RICH_EDITOR, default: false }, + }, +} + +export const useIsMobileMixin = { + inject: { + $isMobile: { from: IS_MOBILE, default: false }, + }, +} + +export const useDocumentMixin = { + inject: { + $document: { from: DOCUMENT, default: null }, + }, +} + +export const useIsUploadingImagesMixin = { + inject: { + $isUploadingImages: { from: IS_UPLOADING_IMAGES, default: false }, + }, +} + +export const useRelativePathMixin = { + inject: { + $relativePath: { from: RELATIVE_PATH, default: null }, + }, +} + +export const useFileMixin = { + inject: { + $file: { + from: FILE, + default: () => ({ + fileId: 0, + relativePath: null, + document: null, + }), + }, + }, +} diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index d3ebf7d3d4c..d81468d333a 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -37,19 +37,12 @@

-
+
-
+ @@ -90,9 +83,20 @@ import Vue from 'vue' import escapeHtml from 'escape-html' import moment from '@nextcloud/moment' +import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' import { showError } from '@nextcloud/dialogs' +import { EditorContent } from '@tiptap/vue-2' +import { getCurrentUser } from '@nextcloud/auth' +import { getVersion, receiveTransaction } from 'prosemirror-collab' -import { EDITOR, SYNC_SERVICE } from './EditorWrapper.provider.js' +import { + EDITOR, + SYNC_SERVICE, + IS_PUBLIC, + IS_RICH_EDITOR, + IS_MOBILE, + FILE, +} from './EditorWrapper.provider.js' import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService.js' import { endpointUrl, getRandomGuestName } from './../helpers/index.js' @@ -101,14 +105,14 @@ import { createEditor, serializePlainText, loadSyntaxHighlight } from './../Edit import { createMarkdownSerializer } from './../extensions/Markdown.js' import markdownit from './../markdownit/index.js' -import { EditorContent } from '@tiptap/vue-2' import { Collaboration, Keymap, UserColor } from './../extensions/index.js' import isMobile from './../mixins/isMobile.js' import store from './../mixins/store.js' -import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' -import { getVersion, receiveTransaction } from 'prosemirror-collab' import { Step } from 'prosemirror-transform' import Lock from 'vue-material-design-icons/Lock' +// import MenuBar from './Menu/Bar.vue' +import EditorDraggable from './EditorDraggable.vue' + const EDITOR_PUSH_DEBOUNCE = 200 const IMAGE_MIMES = [ @@ -127,6 +131,7 @@ export default { name: 'EditorWrapper', components: { EditorContent, + EditorDraggable, MenuBar: () => import(/* webpackChunkName: "editor-rich" */'./Menu/Bar.vue'), MenuBubble: () => import(/* webpackChunkName: "editor-rich" */'./MenuBubble.vue'), ReadOnlyEditor: () => import(/* webpackChunkName: "editor" */'./ReadOnlyEditor.vue'), @@ -149,16 +154,25 @@ export default { // providers aren't naturally reactive // and $editor will start as null // using getters we can always provide the - // actual $editor without being reactive - Object.defineProperty(val, EDITOR, { - get: () => { - return this.$editor + // actual $editor, and other values without being reactive + Object.defineProperties(val, { + [EDITOR]: { + get: () => this.$editor, }, - }) - - Object.defineProperty(val, SYNC_SERVICE, { - get: () => { - return this.$syncService + [SYNC_SERVICE]: { + get: () => this.$syncService, + }, + [FILE]: { + get: () => this.fileData, + }, + [IS_PUBLIC]: { + get: () => this.isPublic, + }, + [IS_RICH_EDITOR]: { + get: () => this.isRichEditor, + }, + [IS_MOBILE]: { + get: () => this.isMobile, }, }) @@ -295,6 +309,18 @@ export default { && !this.syncError && !this.readOnly }, + imagePath() { + return this.relativePath.split('/').slice(0, -1).join('/') + }, + fileData() { + return { + fileId: this.fileId, + relativePath: this.relativePath, + document: { + ...this.document, + }, + } + }, }, watch: { lastSavedStatus() { @@ -591,6 +617,7 @@ export default { this.displayHelp = false }, onPaste(e) { + // emit('files:past-files', e) this.uploadImageFiles(e.detail.files) }, onEditorDrop(e) { @@ -622,9 +649,20 @@ export default { showError(error?.response?.data?.error) }) }, + showImagePrompt() { + const currentUser = getCurrentUser() + if (!currentUser) { + return + } + + OC.dialogs.filepicker(t('text', 'Insert an image'), (filePath) => { + this.insertImagePath(filePath) + }, false, [], true, undefined, this.imagePath) + }, insertImagePath(imagePath) { this.uploadingImages = true - this.$syncService.insertImageFile(imagePath).then((response) => { + + return this.$syncService.insertImageFile(imagePath).then((response) => { this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) diff --git a/src/components/Menu/ActionEntry.js b/src/components/Menu/ActionEntry.js new file mode 100644 index 00000000000..928110a7044 --- /dev/null +++ b/src/components/Menu/ActionEntry.js @@ -0,0 +1,37 @@ +/* + * @copyright Copyright (c) 2022 Vinicius Reis + * + * @author Vinicius Reis + * + * @license GNU AGPL version 3 or any later version + * + * 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 SingleAction from './SingleAction.vue' + +export default { + name: 'ActionEntry', + functional: true, + render(h, ctx) { + const { actionEntry } = ctx.props + + if (actionEntry.component) { + return h(actionEntry.component, ctx) + } + + return h(SingleAction, ctx) + }, +} diff --git a/src/components/Menu/ActionEntry.mixin.js b/src/components/Menu/ActionEntry.mixin.js new file mode 100644 index 00000000000..ccb86748017 --- /dev/null +++ b/src/components/Menu/ActionEntry.mixin.js @@ -0,0 +1,74 @@ +/* + * @copyright Copyright (c) 2022 Vinicius Reis + * + * @author Vinicius Reis + * + * @license GNU AGPL version 3 or any later version + * + * 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 . + * + */ + +/* eslint-disable jsdoc/valid-types */ + +import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +import debounce from 'debounce' + +import { useDocumentMixin, useEditorMixin, useIsMobileMixin } from '../EditorWrapper.provider.js' +import { getActionState, getKeys } from './utils.js' + +/** + * @type {import("vue").ComponentOptions} BaseActionEntry + */ +const BaseActionEntry = { + directives: { + Tooltip, + }, + mixins: [useEditorMixin, useIsMobileMixin, useDocumentMixin], + props: { + actionEntry: { + type: Object, + required: true, + }, + }, + data() { + return { + state: getActionState(this.actionEntry, this.$editor), + } + }, + computed: { + icon() { + return this.actionEntry.icon + }, + tooltip() { + return [this.actionEntry.label, getKeys(this.isMobile, this.actionEntry)].join(' ') + }, + }, + mounted() { + this.$_updateState = debounce(this.updateState.bind(this), 50) + this.$editor.on('update', this.$_updateState) + this.$editor.on('selectionUpdate', this.$_updateState) + }, + beforeDestroy() { + this.$editor.off('update', this.$_updateState) + this.$editor.off('selectionUpdate', this.$_updateState) + }, + methods: { + updateState() { + this.state = getActionState(this.actionEntry, this.$editor) + }, + }, +} + +export { BaseActionEntry } diff --git a/src/components/Menu/ActionEntry.scss b/src/components/Menu/ActionEntry.scss new file mode 100644 index 00000000000..d69baf1d037 --- /dev/null +++ b/src/components/Menu/ActionEntry.scss @@ -0,0 +1,40 @@ +button.entry-action, +.entry-action > button, +.entry-action > div > button { + position: relative; + width: 44px; + height: 44px; + margin: 0; + background-size: 16px; + border: 0; + background-color: transparent; + opacity: .5; + color: var(--color-main-text); + background-position: center center; + vertical-align: top; + padding: 0.7em; + &:hover, + &:focus, + &:active { + background-color: var(--color-background-dark); + } + + &.is-active::before { + transform: translateX(-50%); + border-radius: 100%; + position: absolute; + background: var(--color-primary-element); + bottom: 3px; + height: 6px; + width: 6px; + content: ''; + left: 50%; + + } + + &.is-active, + &:hover, + &:focus { + opacity: 1; + } +} diff --git a/src/components/Menu/Bar.vue b/src/components/Menu/Bar.vue index 3c6df47a35e..4d965256d4a 100644 --- a/src/components/Menu/Bar.vue +++ b/src/components/Menu/Bar.vue @@ -1,6 +1,7 @@ + + + + +