diff options
author | Azul <azul@riseup.net> | 2021-11-30 14:03:38 +0300 |
---|---|---|
committer | Max <max@nextcloud.com> | 2022-02-09 11:42:41 +0300 |
commit | edfbb70f1578dad76ff869b9f2ca0f3c027232d2 (patch) | |
tree | a4458fed13123910d3ab7a5024e08edc33993f0c /src/components | |
parent | 3c9f8e7b2c1d7019e79e855014ade08f79e86f2d (diff) |
upgrade: tiptap v2
Migrate the entire editor to tiptap v2.
Some changes were introduces that go beyond just using the new tiptap API:
*Collaboration*
Port tiptap1 collaboration.
We still want to use our session and sync mechanism.
*Serialization*
Add Markdown extension to handle serialization.
Tiptap config extensions are not automatically added
to the prosemirror schema anymore.
The extension adds the `toMarkdown` config value to the prosemirror schema.
With the new naming scheme tiptap nodes for a number of elements
do not match the prosemirror names.
Camelcase the marks and nodes from `defaultMarkdownSerializer`
so they match the tiptap names.
*Menubar*
* Specify args for isActive function directly rather than setting a function.
* Make use of the editor instance inside the MenuBar component.
* Use the editor rather than slots for command, focused etc.
* disable icons based on editor.can
* Show menubar as long as submenus are open.
When opening a submenu of the menubar
keep track of the open menu even for the image and the remaining action menu.
Also refocus the editor whenever a submenu is closed.
*MenuBubble*
Let tippy handle the positioning
Tippy is really good at positioning the menu bubble.
Remove all our workarounds and let it do its thing.
In order for this to work the content of the MenuBubble
actually needs to live inside the tippy-content.
Tippy bases its calculations on the width of tippy-content.
So if we have the content hanging in a separate div
with absolute positioning
tippy-content will be 0x0 px and not represent the actual width
of the menu bubble.
*Upgrade image node and ImageView.*
Quite a bit of the syntax changed.
We now need a wrapping `<node-view-wrapper>` element.
Pretty good docs at https://tiptap.dev/guide/node-views/vue#render-a-vue-component
We also need to handle the async action.
It will run the action on it's own.
So in `clickIcon()` we need to test if the action returned anything.
Tiptap v1 had inline images.
v2 keeps them outside of paragraphs by default.
Configure Image node to use inline images as markdownit creates inline images right now.
*Trailing Node*
Tiptap v2 does not ship the trailing node extension anymore.
Included the one from the demos and turned it from typescript into javascript.
*Tests*
In order to isolate some problems tests were added.
The tests in Undeline.spec.js were green right from the beginning.
They are not related to the fix and only helped isolate the problem.
Also introduced a cypress test for Lists
that tests the editor
without rendering the page and logging in.
It is very fast and fairly easy to write.
*Refactorings*
* Split marks into separate files.
Signed-off-by: Max <max@nextcloud.com>
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/EditorWrapper.vue | 48 | ||||
-rw-r--r-- | src/components/MenuBar.vue | 365 | ||||
-rw-r--r-- | src/components/MenuBubble.vue | 157 | ||||
-rw-r--r-- | src/components/ReadOnlyEditor.vue | 2 |
4 files changed, 261 insertions, 311 deletions
diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index 6e48b03b8..1828998d4 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -35,7 +35,7 @@ </div> <div v-if="displayed" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !initialLoading && !hasConnectionIssue, 'richEditor': isRichEditor, 'show-color-annotations': showAuthorAnnotations}"> <div id="editor"> - <MenuBar v-if="!syncError && !readOnly" + <MenuBar v-if="tiptap && !syncError && !readOnly" ref="menubar" :editor="tiptap" :sync-service="syncService" @@ -56,7 +56,7 @@ <slot name="header" /> </MenuBar> <div ref="contentWrapper" class="content-wrapper"> - <MenuBubble v-if="!readOnly && isRichEditor" + <MenuBubble v-if="tiptap && !readOnly && isRichEditor" :editor="tiptap" :content-wrapper="contentWrapper" :file-path="relativePath" /> @@ -83,12 +83,12 @@ import moment from '@nextcloud/moment' import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService' import { endpointUrl, getRandomGuestName } from './../helpers' import { extensionHighlight } from '../helpers/mappings' -import { createEditor, createMarkdownSerializer, serializePlainText, loadSyntaxHighlight } from './../EditorFactory' +import { createEditor, serializePlainText, loadSyntaxHighlight } from './../EditorFactory' +import { createMarkdownSerializer } from './../extensions/Markdown' import markdownit from './../markdownit' -import { EditorContent } from 'tiptap' -import { Collaboration } from 'tiptap-extensions' -import { Emoji, Keymap, UserColor } from './../extensions' +import { EditorContent } from '@tiptap/vue-2' +import { Collaboration, Keymap, UserColor } from './../extensions' import isMobile from './../mixins/isMobile' import store from './../mixins/store' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' @@ -299,7 +299,7 @@ export default { forceRecreate: this.forceRecreate, serialize: (document) => { if (this.isRichEditor) { - return (createMarkdownSerializer(this.tiptap.nodes, this.tiptap.marks)).serialize(document) + return (createMarkdownSerializer(this.tiptap.schema)).serialize(document) } return serializePlainText(this.tiptap) @@ -331,17 +331,17 @@ export default { loadSyntaxHighlight(extensionHighlight[this.fileExtension] || this.fileExtension).then((languages) => { this.tiptap = createEditor({ content, - onInit: ({ state }) => { - this.syncService.state = state + onCreate: ({ editor }) => { + this.syncService.state = editor.state this.syncService.startSync() }, - onUpdate: ({ state }) => { - this.syncService.state = state + onUpdate: ({ editor }) => { + this.syncService.state = editor.state }, extensions: [ - new Collaboration({ - // the initial version we start with - // version is an integer which is incremented with every change + Collaboration.configure({ + // the initial version we start with + // version is an integer which is incremented with every change version: this.document.initialVersion, clientID: this.currentSession.id, // debounce changes so we can save some bandwidth @@ -353,11 +353,9 @@ export default { }, update: ({ steps, version, editor }) => { const { state, view, schema } = editor - if (getVersion(state) > version) { return } - const tr = receiveTransaction( state, steps.map(item => Step.fromJSON(schema, item.step)), @@ -367,7 +365,13 @@ export default { view.dispatch(tr) }, }), - new UserColor({ + Keymap.configure({ + 'Mod-s': () => { + this.syncService.save() + return true + }, + }), + UserColor.configure({ clientID: this.currentSession.id, color: (clientID) => { const session = this.sessions.find(item => '' + item.id === '' + clientID) @@ -378,13 +382,6 @@ export default { return session?.userId ? session.userId : session?.guestName }, }), - new Keymap({ - 'Mod-s': () => { - this.syncService.save() - return true - }, - }), - new Emoji(), ], enableRichEditing: this.isRichEditor, languages, @@ -402,7 +399,8 @@ export default { .on('sync', ({ steps, document }) => { this.hasConnectionIssue = false try { - this.tiptap.extensions.options.collaboration.update({ + const collaboration = this.tiptap.extensionManager.extensions.find(e => e.name === 'collaboration') + collaboration.options.update({ version: document.currentVersion, steps, editor: this.tiptap, diff --git a/src/components/MenuBar.vue b/src/components/MenuBar.vue index d78922172..793fd8e7b 100644 --- a/src/components/MenuBar.vue +++ b/src/components/MenuBar.vue @@ -21,118 +21,119 @@ --> <template> - <EditorMenuBar v-slot="{ commands, isActive, focused }" :editor="editor"> - <div class="menubar" :class="{ 'is-focused': focused, 'autohide': autohide }"> - <input - ref="imageFileInput" - type="file" - accept="image/*" - aria-hidden="true" - class="hidden-visually" - @change="onImageUploadFilePicked"> - <div v-if="isRichEditor" ref="menubar" class="menubar-icons"> - <template v-for="(icon, $index) in allIcons"> - <EmojiPicker v-if="icon.class === 'icon-emoji'" - :key="icon.label" - class="menuitem-emoji" - @select="emojiObject => addEmoji(commands, allIcons.find(i => i.class === 'icon-emoji'), emojiObject)"> - <button v-tooltip="t('text', 'Insert emoji')" - class="icon-emoji" - :aria-label="t('text', 'Insert emoji')" - :aria-haspopup="true" /> - </EmojiPicker> - <Actions v-else-if="icon.class === 'icon-image'" - :key="icon.label" - ref="imageActions" - class="submenu" - :default-icon="'icon-image'" - @close="onImageActionClose"> - <button slot="icon" - :class="{ 'icon-image': true, 'loading-small': uploadingImage }" - :title="icon.label" - :aria-label="icon.label" - :aria-haspopup="true" /> - <ActionButton - icon="icon-upload" - :close-after-click="true" - :disabled="uploadingImage" - @click="onUploadImage(commands.image)"> - {{ t('text', 'Upload from computer') }} - </ActionButton> - <ActionButton v-if="!isPublic" - icon="icon-folder" - :close-after-click="true" - :disabled="uploadingImage" - @click="showImagePrompt(commands.image)"> - {{ t('text', 'Insert from Files') }} - </ActionButton> - <ActionButton v-if="!showImageLinkPrompt" - icon="icon-link" - :close-after-click="false" - :disabled="uploadingImage" - @click="showImageLinkPrompt = true"> - {{ t('text', 'Insert from link') }} - </ActionButton> - <ActionInput v-else - icon="icon-link" - :value="imageLink" - @update:value="onImageLinkUpdateValue" - @submit="onImageLinkSubmit(commands.image)"> - {{ t('text', 'Image link to insert') }} - </ActionInput> - </Actions> - <button v-else-if="icon.class" - v-show="$index < iconCount" + <div class="menubar" :class="{ 'show': isVisible, 'autohide': autohide }"> + <input + ref="imageFileInput" + type="file" + accept="image/*" + aria-hidden="true" + class="hidden-visually" + @change="onImageUploadFilePicked"> + <div v-if="isRichEditor" ref="menubar" class="menubar-icons"> + <template v-for="(icon, $index) in allIcons"> + <EmojiPicker v-if="icon.class === 'icon-emoji'" + :key="icon.label" + class="menuitem-emoji" + @select="emojiObject => addEmoji(icon, emojiObject)"> + <button v-tooltip="t('text', 'Insert emoji')" + class="icon-emoji" + :aria-label="t('text', 'Insert emoji')" + :aria-haspopup="true" + @click="toggleChildMenu(icon)" /> + </EmojiPicker> + <Actions v-else-if="icon.class === 'icon-image'" + :key="icon.label" + ref="imageActions" + class="submenu" + :default-icon="'icon-image'" + @open="toggleChildMenu(icon)" + @close="onImageActionClose; toggleChildMenu(icon)"> + <button slot="icon" + :class="{ 'icon-image': true, 'loading-small': uploadingImage }" + :title="icon.label" + :aria-label="icon.label" + :aria-haspopup="true" /> + <ActionButton + icon="icon-upload" + :close-after-click="true" + :disabled="uploadingImage" + @click="onUploadImage()"> + {{ t('text', 'Upload from computer') }} + </ActionButton> + <ActionButton v-if="!isPublic" + icon="icon-folder" + :close-after-click="true" + :disabled="uploadingImage" + @click="showImagePrompt()"> + {{ t('text', 'Insert from Files') }} + </ActionButton> + <ActionButton v-if="!showImageLinkPrompt" + icon="icon-link" + :close-after-click="false" + :disabled="uploadingImage" + @click="showImageLinkPrompt = true"> + {{ t('text', 'Insert from link') }} + </ActionButton> + <ActionInput v-else + icon="icon-link" + :value="imageLink" + @update:value="onImageLinkUpdateValue" + @submit="onImageLinkSubmit()"> + {{ t('text', 'Image link to insert') }} + </ActionInput> + </Actions> + <button v-else-if="icon.class" + v-show="$index < iconCount" + :key="icon.label" + v-tooltip="getLabelAndKeys(icon)" + :class="getIconClasses(icon)" + :disabled="disabled(icon)" + @click="clickIcon(icon)" /> + <template v-else> + <div v-show="$index < iconCount || !icon.class" :key="icon.label" - v-tooltip="getLabelAndKeys(icon)" - :class="getIconClasses(isActive, icon)" - :disabled="disabled(commands, icon)" - @click="clickIcon(commands, icon)" /> - <template v-else> - <div v-show="$index < iconCount || !icon.class" - :key="icon.label" - v-click-outside="() => hideChildMenu(icon)" - class="submenu"> - <button v-tooltip="getLabelAndKeys(icon)" - :class="childIconClasses(isActive, icon.children, )" - @click.prevent="toggleChildMenu(icon)" /> - <div :class="{open: isChildMenuVisible(icon)}" class="popovermenu menu-center"> - <PopoverMenu :menu="childPopoverMenu(isActive, commands, icon.children, icon)" /> - </div> + v-click-outside="() => hideChildMenu(icon)" + class="submenu"> + <button v-tooltip="getLabelAndKeys(icon)" + :class="childIconClasses(icon.children, )" + @click.prevent="toggleChildMenu(icon)" /> + <div :class="{open: isChildMenuVisible(icon)}" class="popovermenu menu-center"> + <PopoverMenu :menu="childPopoverMenu(icon.children, icon)" /> </div> - </template> + </div> </template> - <Actions> - <template v-for="(icon, $index) in allIcons"> - <ActionButton v-if="icon.class && isHiddenInMenu($index) && !(icon.class === 'icon-emoji')" - :key="icon.class" - v-tooltip="getKeys(icon)" - :icon="icon.class" - :close-after-click="true" - @click="clickIcon(commands, icon)"> - {{ icon.label }} + </template> + <Actions + @open="toggleChildMenu({ label: 'Remaining Actions' })" + @close="toggleChildMenu({ label: 'Remaining Actions' })"> + <template v-for="(icon, $index) in allIcons"> + <ActionButton v-if="icon.class && isHiddenInMenu($index) && !(icon.class === 'icon-emoji')" + :key="icon.class" + v-tooltip="getKeys(icon)" + :icon="icon.class" + :close-after-click="true" + @click="clickIcon(icon)"> + {{ icon.label }} + </ActionButton> + <!--<template v-else-if="!icon.class && isHiddenInMenu($index)"> + <ActionButton v-for="childIcon in icon.children" + :key="childIcon.class" + :icon="childIcon.class" + @click="clickIcon(childIcon)"> + v-tooltip="getKeys(childIcon)" + {{ childIcon.label }} </ActionButton> - <!--<template v-else-if="!icon.class && isHiddenInMenu($index)"> - <ActionButton v-for="childIcon in icon.children" - :key="childIcon.class" - :icon="childIcon.class" - @click="clickIcon(commands, childIcon)"> - v-tooltip="getKeys(childIcon)" - {{ childIcon.label }} - </ActionButton> - </template>--> - </template> - </Actions> - </div> - <slot> - Left side - </slot> + </template>--> + </template> + </Actions> </div> - </EditorMenuBar> + <slot> + Left side + </slot> + </div> </template> <script> -import { EditorMenuBar } from 'tiptap' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' import menuBarIcons from './../mixins/menubar' import isMobile from './../mixins/isMobile' @@ -161,7 +162,6 @@ const imageMimes = [ export default { name: 'MenuBar', components: { - EditorMenuBar, ActionButton, ActionInput, PopoverMenu, @@ -178,8 +178,7 @@ export default { props: { editor: { type: Object, - required: false, - default: null, + required: true, }, syncService: { type: Object, @@ -227,19 +226,29 @@ export default { return ($index) => $index - this.iconCount >= 0 }, getIconClasses() { - return (isActive, icon) => { - const classes = { - 'is-active': typeof icon.isActive === 'function' ? icon.isActive(isActive) : false, - } + return (icon) => { + const classes = {} classes[icon.class] = true + classes['is-active'] = this.isActive(icon) return classes } }, + isActive() { + return ({ isActive }) => { + if (!isActive) { + return false + } + const args = Array.isArray(isActive) ? isActive : [isActive] + return this.editor.isActive(...args) + } + }, + isVisible() { + return this.editor.isFocused + || Object.values(this.submenuVisibility).find((v) => v) + }, disabled() { - return (commands, menuItem) => { - return false - // FIXME with this we seem to be running into an endless rerender loop, so this needs more investigation at some later point - // typeof menuItem.isDisabled === 'function' ? menuItem.isDisabled()(commands) : false + return (menuItem) => { + return menuItem.action && !menuItem.action(this.editor.can()) } }, isChildMenuVisible() { @@ -254,47 +263,35 @@ export default { }, { label: t('text', 'Formatting help'), class: 'icon-info', - isActive: () => { - }, - action: (commands) => { + click: () => { this.$emit('show-help') }, }] }, childPopoverMenu() { - return (isActive, commands, icons, parent) => { - const popoverMenuItems = [] - for (const index in icons) { - popoverMenuItems.push({ + return (icons, parent) => { + return icons.map(icon => { + return { // text: this.getLabelAndKeys(icons[index]), - text: icons[index].label, - icon: icons[index].class, + text: icon.label, + icon: icon.class, + active: this.isActive(icon), action: () => { - icons[index].action(commands) + this.clickIcon(icon) this.hideChildMenu(parent) }, - active: icons[index].isActive(isActive), - }) - } - return popoverMenuItems + } + }) } }, childIconClasses() { - return (isActive, icons) => { - const icon = this.childIcon(isActive, icons) - return this.getIconClasses(isActive, icon) + return (icons) => { + const icon = this.childIcon(icons) + return this.getIconClasses(icon) } }, childIcon() { - return (isActive, icons) => { - for (const index in icons) { - const icon = icons[index] - if (icon.isActive(isActive)) { - return icon - } - } - return icons[0] - } + return (icons) => icons.find(icon => this.isActive(icon)) || icons[0] }, iconCount() { this.forceRecompute // eslint-disable-line @@ -330,9 +327,17 @@ export default { this.forceRecompute++ }) }, - clickIcon(commands, icon) { - this.editor.focus() - return icon.action(commands) + refocus() { + this.editor.chain().focus().run() + }, + clickIcon(icon) { + if (icon.click) { + return icon.click() + } + // Some actions run themselves. + // others still need to have .run() called upon them. + const action = icon.action(this.editor.chain().focus()) + action && action.run() }, getWindowWidth(event) { this.windowWidth = document.documentElement.clientWidth @@ -340,18 +345,20 @@ export default { getWindowHeight(event) { this.windowHeight = document.documentElement.clientHeight }, - hideChildMenu(icon) { - this.$set(this.submenuVisibility, icon.label, false) + hideChildMenu({ label }) { + this.$set(this.submenuVisibility, label, false) }, - toggleChildMenu(icon) { - const lastValue = Object.prototype.hasOwnProperty.call(this.submenuVisibility, icon.label) ? this.submenuVisibility[icon.label] : false - this.$set(this.submenuVisibility, icon.label, !lastValue) + toggleChildMenu({ label }) { + const lastValue = Object.prototype.hasOwnProperty.call(this.submenuVisibility, label) ? this.submenuVisibility[label] : false + this.$set(this.submenuVisibility, label, !lastValue) + if (lastValue) { + this.refocus() + } }, onImageActionClose() { this.showImageLinkPrompt = false }, - onUploadImage(command) { - this.imageCommand = command + onUploadImage() { this.$refs.imageFileInput.click() }, onImageUploadFilePicked(event) { @@ -360,7 +367,6 @@ export default { const image = files[0] if (!imageMimes.includes(image.type)) { showError(t('text', 'Image format not supported')) - this.imageCommand = null this.uploadingImage = false return } @@ -370,12 +376,11 @@ export default { event.target.value = '' this.syncService.uploadImage(image).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, this.imageCommand) + this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) showError(error?.response?.data?.error) }).then(() => { - this.imageCommand = null this.uploadingImage = false }) }, @@ -383,7 +388,7 @@ export default { // this avoids the input being reset on each file polling this.imageLink = newImageLink }, - onImageLinkSubmit(command) { + onImageLinkSubmit() { if (!this.imageLink) { return } @@ -392,7 +397,7 @@ export default { this.$refs.imageActions[0].closeMenu() this.syncService.insertImageLink(this.imageLink).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, command) + this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) showError(error?.response?.data?.error) @@ -401,12 +406,12 @@ export default { this.imageLink = '' }) }, - onImagePathSubmit(imagePath, command) { + onImagePathSubmit(imagePath) { this.uploadingImage = true this.$refs.imageActions[0].closeMenu() this.syncService.insertImageFile(imagePath).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, command) + this.insertAttachmentImage(response.data?.name, response.data?.id) }).catch((error) => { console.error(error) showError(error?.response?.data?.error) @@ -414,43 +419,21 @@ export default { this.uploadingImage = false }) }, - showImagePrompt(command) { + showImagePrompt() { const currentUser = getCurrentUser() if (!currentUser) { return } OC.dialogs.filepicker(t('text', 'Insert an image'), (file) => { - this.onImagePathSubmit(file, command) + this.onImagePathSubmit(file) }, false, [], true, undefined, this.imagePath) }, - insertAttachmentImage(name, fileId, command) { + insertAttachmentImage(name, fileId) { const src = 'text://image?imageFileName=' + encodeURIComponent(name) - command({ - src, - // simply get rid of brackets to make sure link text is valid - // as it does not need to be unique and matching the real file name - alt: name.replaceAll(/[[\]]/g, ''), - }) - }, - showLinkPrompt(command) { - const currentUser = getCurrentUser() - if (!currentUser) { - return - } - const _command = command - OC.dialogs.filepicker('Insert a link', (file) => { - const client = OC.Files.getClient() - client.getFileInfo(file).then((_status, fileInfo) => { - this.lastLinkPath = fileInfo.path - const path = this.optimalPathTo(`${fileInfo.path}/${fileInfo.name}`) - const encodedPath = path.split('/').map(encodeURIComponent).join('/') - const href = `${encodedPath}?fileId=${fileInfo.id}` - - _command({ - href, - }) - }) - }, false, [], true, undefined, this.linkPath) + // simply get rid of brackets to make sure link text is valid + // as it does not need to be unique and matching the real file name + const alt = name.replaceAll(/[[\]]/g, '') + this.editor.chain().setImage({ src, alt }).focus().run() }, optimalPathTo(targetFile) { const absolutePath = targetFile.split('/') @@ -469,8 +452,10 @@ export default { } return current.fill('..').concat(target).join('/') }, - addEmoji(commands, icon, emojiObject) { - return icon.action(commands, emojiObject) + addEmoji(icon, emojiObject) { + return icon.action(this.editor.chain(), emojiObject) + .focus() + .run() }, keysString(keyChar, modifiers = []) { const translations = { @@ -517,7 +502,7 @@ export default { visibility: hidden; opacity: 0; transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s; - &.is-focused { + &.show { visibility: visible; opacity: 1; } diff --git a/src/components/MenuBubble.vue b/src/components/MenuBubble.vue index 472e62a22..8c068a045 100644 --- a/src/components/MenuBubble.vue +++ b/src/components/MenuBubble.vue @@ -21,60 +21,57 @@ --> <template> - <EditorMenuBubble v-slot="{ commands, isActive, getMarkAttrs, menu }" - class="menububble" + <BubbleMenu :editor="editor" - @hide="hideLinkMenu"> - <div class="menububble" - :class="{ 'is-active': menu.isActive }" - :style="bubblePosition(menu)"> - <form v-if="linkMenuIsActive" class="menububble__form" @submit.prevent="setLinkUrl(commands.link, linkUrl)"> - <input ref="linkInput" - v-model="linkUrl" - class="menububble__input" - type="text" - placeholder="https://" - @keydown.esc="hideLinkMenu"> - <button class="menububble__button icon-confirm" - type="button" - tabindex="0" - @click="setLinkUrl(commands.link, linkUrl)" /> - </form> - - <template v-else> - <button - class="menububble__button" - :class="{ 'is-active': isActive.link() }" - @click="showLinkMenu(getMarkAttrs('link'))"> - <span class="icon-link" /> - <span class="menububble__buttontext"> - {{ isActive.link() ? t('text', 'Update Link') : t('text', 'Add Link') }} - </span> - </button> - <button v-if="!isUsingDirectEditing" - class="menububble__button" - :class="{ 'is-active': isActive.link() }" - @click="selectFile(commands.link)"> - <span class="icon-file" /> - <span class="menububble__buttontext">{{ t('text', 'Link file') }}</span> - </button> - <button - v-if="isActive.link()" - class="menububble__button" - :class="{ 'is-active': isActive.link() }" - @click="removeLinkUrl(commands.link, linkUrl)"> - <span class="icon-delete" /> - <span class="menububble__buttontext"> - {{ t('text', 'Remove Link') }} - </span> - </button> - </template> - </div> - </EditorMenuBubble> + :tippy-options="{ onHide: hideLinkMenu, duration: 200, placement: 'bottom' }" + class="menububble"> + <form v-if="linkMenuIsActive" class="menububble__form" @submit.prevent="setLinkUrl()"> + <input ref="linkInput" + v-model="linkUrl" + class="menububble__input" + type="text" + placeholder="https://" + @keydown.esc="hideLinkMenu"> + <button class="menububble__button icon-confirm" + type="button" + tabindex="0" + @click="setLinkUrl()" /> + </form> + + <template v-else> + <button + class="menububble__button" + :class="{ 'is-active': isActive('link') }" + @click="showLinkMenu()"> + <span class="icon-link" /> + <span class="menububble__buttontext"> + {{ isActive('link') ? t('text', 'Update Link') : t('text', 'Add Link') }} + </span> + </button> + <button v-if="!isUsingDirectEditing" + class="menububble__button" + :class="{ 'is-active': isActive('link') }" + @click="selectFile()"> + <span class="icon-file" /> + <span class="menububble__buttontext">{{ t('text', 'Link file') }}</span> + </button> + <button + v-if="isActive('link')" + class="menububble__button" + :class="{ 'is-active': isActive('link') }" + @click="removeLinkUrl()"> + <span class="icon-delete" /> + <span class="menububble__buttontext"> + {{ t('text', 'Remove Link') }} + </span> + </button> + </template> + </BubbleMenu> </template> <script> -import { EditorMenuBubble } from 'tiptap' +import { BubbleMenu } from '@tiptap/vue-2' +import { getMarkAttributes } from '@tiptap/core' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' import { getCurrentUser } from '@nextcloud/auth' import { optimalPath } from './../helpers/files' @@ -83,7 +80,7 @@ import { loadState } from '@nextcloud/initial-state' export default { name: 'MenuBubble', components: { - EditorMenuBubble, + BubbleMenu, }, directives: { tooltip: Tooltip, @@ -91,8 +88,7 @@ export default { props: { editor: { type: Object, - required: false, - default: null, + required: true, }, // used to calculate the position based on the scrollOffset contentWrapper: { @@ -113,23 +109,9 @@ export default { isUsingDirectEditing: loadState('text', 'directEditingToken', null) !== null, } }, - computed: { - - // Minimum left value for the bubble so that it stays inside the editor. - // the width of the menububble changes depending on its state - // during the bubblePosition calculation it has not been rendered yet. - // so we have to hard code the minimum. - minLeft() { - if (this.linkMenuIsActive || !this.editor.isActive.link()) { - return 150 - } else { - return 225 - } - }, - - }, methods: { - showLinkMenu(attrs) { + showLinkMenu() { + const attrs = getMarkAttributes(this.editor.state, 'link') this.linkUrl = attrs.href this.linkMenuIsActive = true this.$nextTick(() => { @@ -140,7 +122,7 @@ export default { this.linkUrl = null this.linkMenuIsActive = false }, - selectFile(command) { + selectFile() { const currentUser = getCurrentUser() if (!currentUser) { return @@ -151,12 +133,14 @@ export default { client.getFileInfo(file).then((_status, fileInfo) => { const path = optimalPath(this.filePath, `${fileInfo.path}/${fileInfo.name}`) const encodedPath = path.split('/').map(encodeURIComponent).join('/') - command({ href: `${encodedPath}?fileId=${fileInfo.id}` }) + const href = `${encodedPath}?fileId=${fileInfo.id}` + this.editor.chain().setLink({ href }).focus().run() this.hideLinkMenu() }) }, false, [], true, undefined, startPath) }, - setLinkUrl(command, url) { + setLinkUrl() { + let url = this.linkUrl // Heuristics for determining if we need a https:// prefix. const noPrefixes = [ /^[a-zA-Z]+:/, // url with protocol ("mailTo:email@domain.tld") @@ -171,30 +155,22 @@ export default { } // Avoid issues when parsing urls later on in markdown that might be entered in an invalid format (e.g. "mailto: example@example.com") - url.replaceAll(' ', '%20') - - command({ href: url }) + const href = url.replaceAll(' ', '%20') + this.editor.chain().setLink({ href }).focus().run() this.hideLinkMenu() }, - removeLinkUrl(command, url) { - command({ href: null }) + removeLinkUrl() { + this.editor.chain().unsetLink().focus().run() }, - bubblePosition(menu) { - const left = Math.max(this.minLeft, menu.left) - const offset = this.contentWrapper?.scrollTop || 0 - return { - top: `${menu.top + offset + 5}px`, - left: `${left}px`, - } + isActive(selector, args = {}) { + return this.editor.isActive(selector, args) }, - }, } </script> <style scoped lang="scss"> .menububble { - position: absolute; display: flex; z-index: 10020; background: var(--color-main-background-translucent); @@ -203,17 +179,8 @@ export default { overflow: hidden; padding: 0; margin-left: 10px; - visibility: hidden; - opacity: 0; - transform: translateX(-50%); - transition: opacity 0.2s, visibility 0.2s; height: 44px; - &.is-active { - opacity: 1; - visibility: visible; - } - &__button { display: block; flex-grow: 1; diff --git a/src/components/ReadOnlyEditor.vue b/src/components/ReadOnlyEditor.vue index fe27f873c..8ffc4c87e 100644 --- a/src/components/ReadOnlyEditor.vue +++ b/src/components/ReadOnlyEditor.vue @@ -25,7 +25,7 @@ </template> <script> -import { EditorContent } from 'tiptap' +import { EditorContent } from '@tiptap/vue-2' import escapeHtml from 'escape-html' import { createEditor } from '../EditorFactory' import markdownit from './../markdownit' |