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
')
})
})