diff --git a/cypress/e2e/nodes/Links.spec.js b/cypress/e2e/nodes/Links.spec.js index 43636f75a16..248f868d791 100644 --- a/cypress/e2e/nodes/Links.spec.js +++ b/cypress/e2e/nodes/Links.spec.js @@ -20,65 +20,101 @@ describe('test link marks', function() { cy.openFile(fileName, { force: true }) }) - describe('link preview', function() { - it('shows a link preview', () => { - cy.getContent().type('https://nextcloud.com') - cy.getContent().type('{enter}') + describe('link bubble', function() { + it('shows a link preview in the bubble after clicking link', () => { + const link = 'https://nextcloud.com/' + cy.getContent() + .type(`${link}{enter}`) cy.getContent() - .find('.widgets--list', { timeout: 10000 }) + .find(`a[href*="${link}"]`) + .click() + + cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) .find('.widget-default--name') .contains('Nextcloud') + .click({ force: true }) }) - it('does not show a link preview for links within a paragraph', () => { - cy.getContent().type('Please visit https://nextcloud.com') - cy.getContent().type('{enter}') + it('shows a link preview in the bubble after browsing to link', () => { + const link = 'https://nextcloud.com/' + cy.getContent() + .type(`${link}{enter}`) + cy.getContent() + .type('{upArrow}') cy.getContent() - .find('.widgets--list', { timeout: 10000 }) - .should('not.exist') + .find(`a[href*="${link}"]`) + + cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) + .find('.widget-default--name') + .contains('Nextcloud') }) - }) - describe('autolink', function() { - it('with protocol to files app and fileId', () => { - cy.getFile(fileName) - .then($el => { - const id = $el.data('id') + it('allows to edit a link in the bubble', () => { + cy.getContent() + .type('https://example.org{enter}') + cy.getContent() + .type('{upArrow}{rightArrow}') - const link = `${Cypress.env('baseUrl')}/apps/files/file-name?fileId=${id}` - cy.clearContent() - cy.getContent() - .type(`${link}{enter}`) + cy.get('.link-view-bubble button[title="Edit link"]') + .click() - cy.getContent() - .find(`a[href*="${Cypress.env('baseUrl')}"]`) - .click({ force: true }) + cy.get('.link-view-bubble input') + .type('{selectAll}https://nextcloud.com') - cy.get('@winOpen') - .should('have.been.calledOnce') - .should('have.been.calledWithMatch', new RegExp(`/f/${id}$`)) - }) + cy.get('.link-view-bubble button[title="Save changes"]') + .click() + + cy.getContent() + .find('a[href*="https://nextcloud.com"]') + + }) + + it('allows to remove a link in the bubble', () => { + const link = 'https://nextcloud.com' + cy.getContent() + .type(`${link}{enter}`) + cy.getContent() + .type('{upArrow}{rightArrow}') + + cy.get('.link-view-bubble button[title="Remove link"]') + .click() + + cy.getContent() + .find(`a[href*="${link}"]`) + .should('not.exist') + + }) + + it('Ctrl-click on a link opens a new tab', () => { + const link = 'https://nextcloud.com/' + cy.getContent() + .type(`${link}{enter}`) + + cy.getContent() + .find(`a[href*="${link}"]`) + .click({ ctrlKey: true }) + + cy.get('@winOpen') + .should('have.been.calledOnce') + .should('have.been.calledWith', link) }) + }) - it('with protocol and fileId', () => { + describe('autolink', function() { + it('with protocol to files app and fileId', () => { cy.getFile(fileName) .then($el => { - const id = $el.data('id') + const id = $el.data('cyFilesListRowFileid') - const link = `${Cypress.env('baseUrl')}/file-name?fileId=${id}` + const link = `${Cypress.env('baseUrl')}/apps/files/?dir=/&openfile=${id}#relPath=/${fileName}` cy.clearContent() cy.getContent() .type(`${link}{enter}`) cy.getContent() .find(`a[href*="${Cypress.env('baseUrl')}"]`) - .click({ force: true }) - - cy.get('@winOpen') - .should('have.been.calledOnce') - .should('have.been.calledWithMatch', new RegExp(`${Cypress.env('baseUrl')}/file-name\\?fileId=${id}$`)) }) }) @@ -115,10 +151,6 @@ describe('test link marks', function() { .get(`a[href*="${url}"]`) .should('have.text', text) // ensure correct text used .click({ force: true }) - - cy.get('@winOpen') - .should('have.been.calledOnce') - .should('have.been.calledWith', url) } beforeEach(cy.clearContent) @@ -151,7 +183,6 @@ describe('test link marks', function() { return cy.getContent() .find(`a[href*="${encodeURIComponent(filename)}"]`) .should('have.text', text === undefined ? filename : text) - .click({ force: true }) } beforeEach(() => cy.clearContent()) @@ -176,8 +207,6 @@ describe('test link marks', function() { cy.getFile(fileName).then($el => { cy.getContent().type(`${text}{selectAll}`) checkLinkFile('dummy folder', text, true) - cy.get('@winOpen') - .should('have.been.calledOnce') }) }) }) diff --git a/cypress/e2e/workspace.spec.js b/cypress/e2e/workspace.spec.js index d32d0bf8241..1a2fa8add60 100644 --- a/cypress/e2e/workspace.spec.js +++ b/cypress/e2e/workspace.spec.js @@ -174,8 +174,13 @@ describe('Workspace', function() { .and('contains', `dir=/${this.testFolder}/sub-folder/alpha`) .and('contains', '#relPath=sub-folder/alpha/test.md') - cy.getEditor() - .find('a').click() + cy.getContent() + .type('{leftArrow}') + + cy.get('.link-view-bubble .widget-file', { timeout: 10000 }) + .find('.widget-file--title') + .contains('test.md') + .click({ force: true }) cy.getModal() .find('.modal-header') diff --git a/src/components/Editor.provider.js b/src/components/Editor.provider.js index 47722b77df7..41984183666 100644 --- a/src/components/Editor.provider.js +++ b/src/components/Editor.provider.js @@ -31,7 +31,6 @@ export const IS_RICH_EDITOR = Symbol('editor:is-rich-editor') export const IS_RICH_WORKSPACE = Symbol('editor:is-rich-woskapace') export const SYNC_SERVICE = Symbol('sync:service') export const EDITOR_UPLOAD = Symbol('editor:upload') -export const HOOK_LINK_CLICK = Symbol('hook:link-click') export const HOOK_MENTION_SEARCH = Symbol('hook:mention-search') export const HOOK_MENTION_INSERT = Symbol('hook:mention-insert') @@ -117,11 +116,3 @@ export const useMentionHook = { }, }, } -export const useLinkClickHook = { - inject: { - $linkHookClick: { - from: HOOK_LINK_CLICK, - default: null, - }, - }, -} diff --git a/src/components/Editor/MarkdownContentEditor.vue b/src/components/Editor/MarkdownContentEditor.vue index 65933e7789c..cfeb7982248 100644 --- a/src/components/Editor/MarkdownContentEditor.vue +++ b/src/components/Editor/MarkdownContentEditor.vue @@ -41,7 +41,7 @@ import { Editor } from '@tiptap/core' /* eslint-disable import/no-named-as-default */ import History from '@tiptap/extension-history' import { getCurrentUser } from '@nextcloud/auth' -import { ATTACHMENT_RESOLVER, EDITOR, IS_RICH_EDITOR, useLinkClickHook } from '../Editor.provider.js' +import { ATTACHMENT_RESOLVER, EDITOR, IS_RICH_EDITOR } from '../Editor.provider.js' import { createMarkdownSerializer } from '../../extensions/Markdown.js' import AttachmentResolver from '../../services/AttachmentResolver.js' import markdownit from '../../markdownit/index.js' @@ -52,7 +52,6 @@ import ContentContainer from './ContentContainer.vue' export default { name: 'MarkdownContentEditor', components: { ContentContainer, ReadonlyBar, MenuBar, MainContainer, Wrapper }, - mixins: [useLinkClickHook], provide() { const val = {} @@ -136,13 +135,6 @@ export default { return [ RichText.configure({ component: this, - link: this?.$linkHookClick - ? { - onClick: (event, attrs) => { - return this?.$linkHookClick?.(event, attrs) - }, - } - : undefined, extensions: [ History, ], diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue new file mode 100644 index 00000000000..d1a1e46ca82 --- /dev/null +++ b/src/components/Link/LinkBubbleView.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/src/components/RichTextReader.vue b/src/components/RichTextReader.vue index d45d410e11c..02f2da37ddc 100644 --- a/src/components/RichTextReader.vue +++ b/src/components/RichTextReader.vue @@ -41,12 +41,6 @@ export default { return [ RichText.configure({ editing: false, - link: { - onClick: (event, attrs) => { - this.$emit('click-link', event, attrs) - return true - }, - }, }), ] }, @@ -58,23 +52,6 @@ export default { required: true, }, }, - - mounted() { - this.$el.addEventListener('click', this.preventOpeningLinks, true) - }, - - unmounted() { - this.$el.removeEventListener('click', this.preventOpeningLinks, true) - }, - - methods: { - preventOpeningLinks(event) { - // We use custom onClick handler only for left clicks - if (event.target.closest('a') && event.button === 0 && !event.ctrlKey) { - event.preventDefault() - } - }, - }, } diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index cd0f05ee362..7d60937cf31 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -98,7 +98,7 @@ div.ProseMirror { padding: .5em 0; } - p .paragraph-content { + p.paragraph-content { margin-bottom: 1em; line-height: 150%; } @@ -248,7 +248,7 @@ div.ProseMirror { position: relative; padding-left: 3px; - p .paragraph-content { + p.paragraph-content { margin-bottom: 0.5em; } } diff --git a/src/editor.js b/src/editor.js index d60f7c9ce7e..debfc24cceb 100644 --- a/src/editor.js +++ b/src/editor.js @@ -21,7 +21,7 @@ import Vue from 'vue' import store from './store/index.js' -import { EDITOR_UPLOAD, HOOK_MENTION_SEARCH, HOOK_MENTION_INSERT, HOOK_LINK_CLICK, ATTACHMENT_RESOLVER } from './components/Editor.provider.js' +import { EDITOR_UPLOAD, HOOK_MENTION_SEARCH, HOOK_MENTION_INSERT, ATTACHMENT_RESOLVER } from './components/Editor.provider.js' import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js' __webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line @@ -152,7 +152,6 @@ window.OCA.Text.createEditor = async function({ onLoaded = () => {}, onUpdate = ({ markdown }) => {}, onOutlineToggle = (visible) => {}, - onLinkClick = undefined, onFileInsert = undefined, onMentionSearch = undefined, onMentionInsert = undefined, @@ -171,7 +170,6 @@ window.OCA.Text.createEditor = async function({ const vm = new Vue({ provide() { return { - [HOOK_LINK_CLICK]: onLinkClick, [ACTION_ATTACHMENT_PROMPT]: onFileInsert, [EDITOR_UPLOAD]: !!sessionEditor, [HOOK_MENTION_SEARCH]: sessionEditor ? true : onMentionSearch, diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js new file mode 100644 index 00000000000..ed4a9b7151c --- /dev/null +++ b/src/extensions/LinkBubble.js @@ -0,0 +1,27 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import LinkBubblePluginView from './LinkBubblePluginView.js' + +const LinkBubble = Extension.create({ + name: 'linkViewBubble', + + addOptions() { + return { + pluginKey: 'linkViewBubble', + } + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey(this.options.pluginKey), + view: (view) => new LinkBubblePluginView({ + editor: this.editor, + view, + }), + }), + ] + }, +}) + +export default LinkBubble diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js new file mode 100644 index 00000000000..8cb90a77b30 --- /dev/null +++ b/src/extensions/LinkBubblePluginView.js @@ -0,0 +1,261 @@ +import { VueRenderer } from '@tiptap/vue-2' +import tippy from 'tippy.js' +import { domHref } from '../helpers/links.js' +import LinkBubbleView from '../components/Link/LinkBubbleView.vue' + +const updateDelay = 250 + +class LinkBubblePluginView { + + #component = null + #preventHide = false + #hadUpdateFromClick = false + #updateDebounceTimer = undefined + + constructor({ editor, view }) { + this.editor = editor + this.view = view + + this.#component = new VueRenderer(LinkBubbleView, { + parent: this.editor.contentComponent, + propsData: { + editor: this.editor, + href: null, + }, + }) + + this.view.dom.addEventListener('dragstart', this.dragOrScrollHandler) + this.view.dom.addEventListener('click', this.clickHandler) + document.addEventListener('scroll', this.dragOrScrollHandler, { capture: true }) + this.editor.on('focus', this.focusHandler) + this.editor.on('blur', this.blurHandler) + } + + dragOrScrollHandler = () => { + this.hide() + } + + pointerdownHandler = () => { + this.#preventHide = true + } + + // Required for read-only mode on Firefox. For some reason, editor selection doesn't get + // updated when clicking a link in read-only mode on Firefox. + clickHandler = (event) => { + // Only regard left clicks without Ctrl/Meta + if (event.button !== 0 || event.ctrlKey || event.metaKey) { + return false + } + + // Only regard clicks that resolve to a prosemirror position + const { pos } = this.editor.view.posAtCoords({ left: event.clientX, top: event.clientY }) + if (!pos) { + return false + } + + // Derive link from position of click instead of using `getAttribute()` (like Tiptap handleClick does) + // In Firefox, `getAttribute()` doesn't work in read-only mode as clicking on links doesn't update selection/cursor. + const clickedPos = this.editor.view.state.doc.resolve(pos) + + // we use `setTimeout` to make sure `selection` is already updated + setTimeout(() => this.updateFromClick(this.editor.view, clickedPos)) + } + + focusHandler = () => { + // we use `setTimeout` to make sure `selection` is already updated + setTimeout(() => this.update(this.editor.view)) + } + + blurHandler = ({ event }) => { + if (this.#preventHide) { + this.#preventHide = false + return + } + + if (event?.relatedTarget && this.tippy?.popper.firstChild.contains(event.relatedTarget)) { + return + } + + this.hide() + } + + tippyBlurHandler = () => { + this.hide() + } + + keydownHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault() + this.hide() + } + } + + createTooltip() { + const { element: editorElement } = this.editor.options + const editorIsAttached = !!editorElement.parentElement + + if (this.tippy || !editorIsAttached) { + return + } + + this.tippy = tippy(editorElement, { + duration: 100, + getReferenceClientRect: null, + content: this.#component.element, + interactive: true, + trigger: 'manual', + placement: 'bottom', + hideOnClick: 'toggle', + popperOptions: { + strategy: 'fixed', + }, + }) + + this.tippy.popper.firstChild?.addEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + // Hide tippy on its own blur event as well + this.tippy.popper.firstChild?.addEventListener('blur', this.tippyBlurHandler) + } + + update(view, oldState) { + const { composing } = view + const selectionChanged = !oldState?.selection.eq(view.state.selection) + const docChanged = !oldState?.doc.eq(view.state.doc) + const isSame = !selectionChanged && !docChanged + + if (composing || isSame) { + return + } + + if (this.#updateDebounceTimer) { + clearTimeout(this.#updateDebounceTimer) + } + + this.#updateDebounceTimer = window.setTimeout(() => { + this.updateFromSelection(view) + }, updateDelay) + } + + updateFromSelection(view) { + // Don't update directly after updateFromClick. Prevents race condition in read-only documents in Chrome. + if (this.#hadUpdateFromClick) { + return + } + + const { state } = view + const { selection } = state + + // support for CellSelections + const { ranges } = selection + const from = Math.min(...ranges.map(range => range.$from.pos)) + + const resolved = view.state.doc.resolve(from) + const nodeStart = resolved.pos - resolved.textOffset + const linkNode = this.linkNodeFromSelection(view) + + const hasBubbleFocus = this.#component.element.contains(document.activeElement) + const hasEditorFocus = view.hasFocus() || hasBubbleFocus + + const shouldShow = !!linkNode && hasEditorFocus + + this.updateTooltip(view, shouldShow, linkNode, nodeStart) + } + + updateFromClick(view, clickedLinkPos) { + const nodeStart = clickedLinkPos.pos - clickedLinkPos.textOffset + const clickedNode = clickedLinkPos.parent.maybeChild(clickedLinkPos.index()) + const shouldShow = this.isLinkNode(clickedNode) + + this.#hadUpdateFromClick = true + setTimeout(() => { + this.#hadUpdateFromClick = false + }, 200) + this.updateTooltip(this.editor.view, shouldShow, clickedNode, nodeStart) + } + + updateTooltip = (view, shouldShow, linkNode, nodeStart) => { + this.createTooltip() + + if (!shouldShow || !linkNode) { + this.hide() + return + } + + let referenceEl = view.nodeDOM(nodeStart) + if (Object.prototype.toString.call(referenceEl) === '[object Text]') { + referenceEl = referenceEl.parentElement + } + const clientRect = referenceEl?.getBoundingClientRect() + + this.#component.updateProps({ + href: domHref(linkNode.marks.find(m => m.type.name === 'link')), + }) + + this.tippy?.setProps({ + getReferenceClientRect: () => clientRect, + }) + + this.show() + } + + show() { + this.tippy?.show() + this.view.dom.addEventListener('keydown', this.keydownHandler) + } + + hide() { + this.view.dom.removeEventListener('keydown', this.keydownHandler) + setTimeout(() => { + this.tippy?.hide() + }, 100) + } + + destroy() { + this.tippy?.popper.firstChild?.removeEventListener('blur', this.tippyBlurHandler) + this.tippy?.popper.firstChild?.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + this.tippy?.destroy() + this.view.dom.removeEventListener('dragstart', this.dragOrScrollHandler) + this.view.dom.removeEventListener('click', this.clickHandler) + document.removeEventListener('scroll', this.dragOrScrollHandler, { capture: true }) + this.editor.off('focus', this.focusHandler) + this.editor.off('blur', this.blurHandler) + } + + linkNodeFromSelection = (view) => { + const { state } = view + const { selection } = state + + // support for CellSelections + const { ranges } = selection + const from = Math.min(...ranges.map(range => range.$from.pos)) + const to = Math.max(...ranges.map(range => range.$to.pos)) + + const resolved = view.state.doc.resolve(from) + const node = resolved.parent.maybeChild(resolved.index()) + const nodeStart = resolved.pos - resolved.textOffset + const nodeEnd = nodeStart + node?.nodeSize + + if (to > nodeEnd) { + // Selection spans further than one text node + return + } + + return this.isLinkNode(node) ? node : null + } + + isLinkNode(node) { + const linkMark = node?.marks.find(m => m.type.name === 'link') + if (!linkMark) { + return false + } + + // Don't open link bubble for anchor links + if (linkMark.attrs.href.startsWith('#')) { + return false + } + + return true + } + +} + +export default LinkBubblePluginView diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 24bccbcb80b..7ae707f3f1e 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -44,6 +44,7 @@ import Image from './../nodes/Image.js' import ImageInline from './../nodes/ImageInline.js' import KeepSyntax from './KeepSyntax.js' import LinkPicker from './../extensions/LinkPicker.js' +import LinkBubble from './../extensions/LinkBubble.js' import ListItem from '@tiptap/extension-list-item' import Markdown from './../extensions/Markdown.js' import Mention from './../extensions/Mention.js' @@ -68,7 +69,6 @@ export default Extension.create({ addOptions() { return { editing: true, - link: {}, extensions: [], component: null, relativePath: null, @@ -113,6 +113,12 @@ export default Extension.create({ suggestion: EmojiSuggestion(), }), LinkPicker, + Link.configure({ + openOnClick: true, + validate: href => /^https?:\/\//.test(href), + relativePath: this.options.relativePath, + }), + LinkBubble, this.options.editing ? Placeholder.configure({ emptyNodeClass: 'is-empty', @@ -122,14 +128,6 @@ export default Extension.create({ : null, TrailingNode, ] - if (this.options.link !== false) { - defaultExtensions.push(Link.configure({ - ...this.options.link, - openOnClick: true, - validate: href => /^https?:\/\//.test(href), - relativePath: this.options.relativePath, - })) - } const additionalExtensionNames = this.options.extensions.map(e => e.name) return [ ...defaultExtensions.filter(e => e && !additionalExtensionNames.includes(e.name)), diff --git a/src/helpers/links.js b/src/helpers/links.js index d31d091690e..7ec077b0089 100644 --- a/src/helpers/links.js +++ b/src/helpers/links.js @@ -22,9 +22,6 @@ import { generateUrl } from '@nextcloud/router' -import { logger } from '../helpers/logger.js' -import markdownit from './../markdownit/index.js' - const absolutePath = function(base, rel) { if (!rel) { return base @@ -93,47 +90,7 @@ const parseHref = function(dom) { return ref } -const openLink = function(event, _attrs) { - const linkElement = event.target.closest('a') - const htmlHref = linkElement.href - const query = OC.parseQueryString(htmlHref) - const fragment = htmlHref.split('#').pop() - const fragmentQuery = OC.parseQueryString(fragment) - if (query?.dir && fragmentQuery?.relPath) { - const filename = fragmentQuery.relPath.split('/').pop() - const path = `${query.dir}/${filename}` - document.title = `${filename} - ${OC.theme.title}` - if (window.location.pathname.match(/apps\/files\/$/)) { - // The files app still lacks a popState handler - // to allow for using the back button - // OC.Util.History.pushState('', htmlHref) - } - OCA.Viewer.open({ path }) - return - } - if (htmlHref.match(/apps\/files\//) && query?.fileId) { - // open the direct file link - window.open(generateUrl(`/f/${query.fileId}`), '_self') - return - } - if (!markdownit.validateLink(htmlHref)) { - logger.error('Invalid link', { htmlHref }) - return false - } - if (fragment) { - const el = document.getElementById(fragment) - if (el) { - el.scrollIntoView() - window.location.hash = fragment - return - } - } - window.open(htmlHref) - return true -} - export { domHref, parseHref, - openLink, } diff --git a/src/marks/Link.js b/src/marks/Link.js index 70a9f48c8bc..bb1db5210f1 100644 --- a/src/marks/Link.js +++ b/src/marks/Link.js @@ -21,15 +21,14 @@ */ import TipTapLink from '@tiptap/extension-link' -import { domHref, parseHref, openLink } from './../helpers/links.js' -import { clickHandler, clickPreventer } from '../plugins/link.js' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { domHref, parseHref } from './../helpers/links.js' const Link = TipTapLink.extend({ addOptions() { return { ...this.parent?.(), - onClick: openLink, relativePath: null, } }, @@ -63,30 +62,61 @@ const Link = TipTapLink.extend({ return ['a', { ...mark.attrs, href: domHref(mark, this.options.relativePath), + 'data-md-href': mark.attrs.href, rel: 'noopener noreferrer nofollow', }, 0] }, addProseMirrorPlugins() { const plugins = this.parent() - // remove original handle click + // remove upstream link click handle plugin .filter(({ key }) => { return !key.startsWith('handleClickLink') }) - if (!this.options.openOnClick) { - return plugins - } - - // add custom click handler + // Custom click handler plugins return [ ...plugins, - clickHandler({ - editor: this.editor, - type: this.type, - onClick: this.options.onClick, + new Plugin({ + key: new PluginKey('textHandleClickLink'), + props: { + handleDOMEvents: { + // Open link in new tab on middle click + pointerup: (view, event) => { + if (event.target.closest('a') && event.button === 1 && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + event.preventDefault() + + const linkElement = event.target.closest('a') + window.open(linkElement.href, '_blank') + } + }, + // Prevent paste into links + // On Linux, middle click pastes, which breaks "open in new tab" on middle click + // Pasting into links will break the link anyway, so just disable it altogether. + paste: (view, event) => { + if (event.target.closest('a')) { + event.stopPropagation() + event.preventDefault() + event.stopImmediatePropagation() + } + }, + // Prevent open link (except anchor links) on left click (required for read-only mode) + // Open link in new tab on Ctrl/Cmd + left click + click: (view, event) => { + const linkEl = event.target.closest('a') + if (event.button === 0 && linkEl) { + event.preventDefault() + if (linkEl.attributes.href?.value?.startsWith('#')) { + // Open anchor links directly + location.href = linkEl.attributes.href.value + } else if (event.ctrlKey || event.metaKey) { + window.open(linkEl.href, '_blank') + } + } + }, + }, + }, }), - clickPreventer(), ] }, }) diff --git a/src/mixins/CopyToClipboardMixin.js b/src/mixins/CopyToClipboardMixin.js new file mode 100644 index 00000000000..d350e06352c --- /dev/null +++ b/src/mixins/CopyToClipboardMixin.js @@ -0,0 +1,41 @@ +import { showError, showSuccess } from '@nextcloud/dialogs' + +export default { + data() { + return { + copied: false, + copyLoading: false, + copySuccess: false, + } + }, + + methods: { + async copyToClipboard(url) { + // change to loading status + this.copyLoading = true + + // copy link to clipboard + try { + await navigator.clipboard.writeText(url) + this.copySuccess = true + this.copied = true + + // Notify success + showSuccess(t('collectives', 'Link copied')) + } catch (error) { + this.copySuccess = false + this.copied = true + showError( + `
${t('collectives', 'Could not copy link to the clipboard:')}
${url}
`, + { isHTML: true }) + } finally { + this.copyLoading = false + setTimeout(() => { + // stop loading status regardless of outcome + this.copySuccess = false + this.copied = false + }, 4000) + } + }, + }, +} diff --git a/src/nodes/Paragraph.js b/src/nodes/Paragraph.js index 035d5f622af..fb7cc6ce924 100644 --- a/src/nodes/Paragraph.js +++ b/src/nodes/Paragraph.js @@ -1,11 +1,13 @@ import TiptapParagraph from '@tiptap/extension-paragraph' -import { VueNodeViewRenderer } from '@tiptap/vue-2' -import ParagraphView from './ParagraphView.vue' const Paragraph = TiptapParagraph.extend({ - addNodeView() { - return VueNodeViewRenderer(ParagraphView) + addOptions() { + return { + HTMLAttributes: { + class: 'paragraph-content', + }, + } }, parseHTML() { diff --git a/src/nodes/ParagraphView.vue b/src/nodes/ParagraphView.vue deleted file mode 100644 index e05e758dcdd..00000000000 --- a/src/nodes/ParagraphView.vue +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - diff --git a/src/plugins/link.js b/src/plugins/link.js deleted file mode 100644 index 43e7d32ecd6..00000000000 --- a/src/plugins/link.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Plugin, PluginKey } from '@tiptap/pm/state' - -import { logger } from '../helpers/logger.js' - -const clickHandler = ({ editor, type, onClick }) => { - return new Plugin({ - props: { - key: new PluginKey('textLink'), - handleClick: (view, pos, event) => { - const $clicked = view.state.doc.resolve(pos) - const link = $clicked.marks().find(m => m.type.name === type.name) - if (!link) { - return false - } - if (!link.attrs.href) { - logger.warn('Could not determine href of link.') - logger.debug('Link', { link }) - return false - } - // We use custom onClick handler only for left clicks - if (event.button === 0 && !event.ctrlKey) { - event.stopPropagation() - return onClick?.(event, link.attrs) - } - }, - }, - }) -} - -const clickPreventer = () => { - return new Plugin({ - props: { - key: new PluginKey('textAvoidLinkClick'), - handleDOMEvents: { - click: (view, event) => { - if (!view.editable) { - event.preventDefault() - return false - } - }, - }, - }, - }) -} - -export { clickHandler, clickPreventer } diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index c152ba38440..480bdd94f19 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -78,5 +78,5 @@ function editorWithContent(content) { } function formatHTML(html) { - return html.replaceAll('><', '>\n<').replace(/\n$/, '') + return html.replaceAll('><', '>\n<').replace(/\n$/, '').replace('

', '

') } diff --git a/src/tests/tiptap.spec.js b/src/tests/tiptap.spec.js index 1a5fbb27a97..1deac36115f 100644 --- a/src/tests/tiptap.spec.js +++ b/src/tests/tiptap.spec.js @@ -13,11 +13,11 @@ const renderedHTML = ( markdown ) => { describe('TipTap', () => { it('render softbreaks', () => { const markdown = 'This\nis\none\nparagraph' - expect(renderedHTML(markdown)).toEqual(`

${markdown}

`) + expect(renderedHTML(markdown)).toEqual(`

${markdown}

`) }) it('render hardbreak', () => { const markdown = 'Hard line break \nNext Paragraph' - expect(renderedHTML(markdown)).toEqual('

Hard line break
Next Paragraph

') + expect(renderedHTML(markdown)).toEqual('

Hard line break
Next Paragraph

') }) })