diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 7b69352e0d7..f56d0f3c1ce 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -43,6 +43,7 @@ const createRichEditor = ({ relativePath, isEmbedded = false, mentionSearch = undefined, + openLink = undefined, } = {}) => { return new Editor({ editorProps, @@ -52,6 +53,7 @@ const createRichEditor = ({ relativePath, isEmbedded, mentionSearch, + openLink, }), FocusTrap, ...extensions, diff --git a/src/components/Editor.provider.ts b/src/components/Editor.provider.ts index def138d9ea2..17e0eb8721b 100644 --- a/src/components/Editor.provider.ts +++ b/src/components/Editor.provider.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { openLink } from '../helpers/links.js' import { logger } from '../helpers/logger.js' export const FILE = Symbol('editor:file') @@ -12,7 +11,6 @@ export const IS_MOBILE = Symbol('editor:is-mobile') export const EDITOR_UPLOAD = Symbol('editor:upload') export const HOOK_MENTION_SEARCH = Symbol('hook:mention-search') export const HOOK_MENTION_INSERT = Symbol('hook:mention-insert') -export const OPEN_LINK_HANDLER = Symbol('editor:open-link-handler') export const HOOK_MENUBAR_LINK_CUSTOM_ACTION = Symbol('menubar:link-custom-action') export const useIsMobileMixin = { @@ -69,13 +67,3 @@ export const useMentionHook = { }, }, } -export const useOpenLinkHandler = { - inject: { - $openLinkHandler: { - from: OPEN_LINK_HANDLER, - default: { - openLink, - }, - }, - }, -} diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 5b7b4da78dc..8ed4e663a57 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -91,6 +91,7 @@ import Autofocus from '../extensions/Autofocus.js' import { provideEditor } from '../composables/useEditor.ts' import { provideEditorFlags } from '../composables/useEditorFlags.ts' +import { useOpenLinkHandler } from '../composables/useOpenLinkHandler.ts' import { ATTACHMENT_RESOLVER, FILE, @@ -244,7 +245,8 @@ export default defineComponent({ Collaboration.configure({ document: ydoc }), CollaborationCaret.configure({ provider: { awareness } }), ] - const mentionSearch = inject(HOOK_MENTION_SEARCH) + const mentionSearch = inject(HOOK_MENTION_SEARCH, undefined) + const { openLinkHandler } = useOpenLinkHandler() const editor = isRichEditor ? createRichEditor({ connection, @@ -252,6 +254,7 @@ export default defineComponent({ extensions, isEmbedded: props.isEmbedded, mentionSearch, + openLink: openLinkHandler.openLink, }) : createPlainEditor({ language, extensions }) provideEditor(editor) diff --git a/src/components/Editor/MarkdownContentEditor.vue b/src/components/Editor/MarkdownContentEditor.vue index c40eb454693..1be8a1ddccd 100644 --- a/src/components/Editor/MarkdownContentEditor.vue +++ b/src/components/Editor/MarkdownContentEditor.vue @@ -31,6 +31,7 @@ import { editorFlagsKey } from '../../composables/useEditorFlags.ts' import { provideEditorHeadings } from '../../composables/useEditorHeadings.ts' import { useEditorMethods } from '../../composables/useEditorMethods.ts' import { provideEditorWidth } from '../../composables/useEditorWidth.ts' +import { useOpenLinkHandler } from '../../composables/useOpenLinkHandler.ts' import { FocusTrap, RichText } from '../../extensions/index.js' import { createMarkdownSerializer } from '../../extensions/Markdown.js' import AttachmentResolver from '../../services/AttachmentResolver.js' @@ -82,9 +83,11 @@ export default { emits: ['update:content'], setup(props) { + const { openLinkHandler } = useOpenLinkHandler() const extensions = [ RichText.configure({ extensions: [UndoRedo], + openLink: openLinkHandler.openLink, }), FocusTrap, ] diff --git a/src/components/Editor/PreviewOptions.vue b/src/components/Editor/PreviewOptions.vue index 03701dfe86c..401c2fefea3 100644 --- a/src/components/Editor/PreviewOptions.vue +++ b/src/components/Editor/PreviewOptions.vue @@ -72,6 +72,7 @@ import ContentCopyIcon from 'vue-material-design-icons/ContentCopy.vue' import DotsVerticalIcon from 'vue-material-design-icons/DotsVertical.vue' import OpenIcon from 'vue-material-design-icons/OpenInNew.vue' import DeleteOutlineIcon from 'vue-material-design-icons/TrashCanOutline.vue' +import { useOpenLinkHandler } from '../../composables/useOpenLinkHandler.ts' import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin.js' export default { @@ -104,6 +105,11 @@ export default { }, }, + setup() { + const { openLinkHandler } = useOpenLinkHandler() + return { openLinkHandler } + }, + data() { return { open: false, @@ -126,7 +132,7 @@ export default { }, openLink() { if (!this.href) return - window.open(this.href, '_blank').focus() + this.openLinkHandler.openLink(this.href) }, async copyLink() { await this.copyToClipboard(this.href) diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue index 47c16a06ded..cc8a1e66cf7 100644 --- a/src/components/Link/LinkBubbleView.vue +++ b/src/components/Link/LinkBubbleView.vue @@ -97,7 +97,7 @@ import CloseIcon from 'vue-material-design-icons/Close.vue' import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue' import PencilOutlineIcon from 'vue-material-design-icons/PencilOutline.vue' -import { useOpenLinkHandler } from '../Editor.provider.ts' +import { useOpenLinkHandler } from '../../composables/useOpenLinkHandler.ts' import PreviewOptions from '../Editor/PreviewOptions.vue' const PROTOCOLS_WITH_PREVIEW = ['http:', 'https:'] @@ -116,8 +116,6 @@ export default { PencilOutlineIcon, }, - mixins: [useOpenLinkHandler], - props: { editor: { type: Object, @@ -129,6 +127,11 @@ export default { }, }, + setup() { + const { openLinkHandler } = useOpenLinkHandler() + return { openLinkHandler } + }, + data() { return { isEditable: false, @@ -191,7 +194,7 @@ export default { }, openLink(href) { - this.$openLinkHandler.openLink(href) + this.openLinkHandler.openLink(href) }, onReferenceListLoaded() { diff --git a/src/composables/useOpenLinkHandler.ts b/src/composables/useOpenLinkHandler.ts new file mode 100644 index 00000000000..7afc11e6363 --- /dev/null +++ b/src/composables/useOpenLinkHandler.ts @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { inject } from 'vue' +import { openLink } from '../helpers/links.js' + +export const OPEN_LINK_HANDLER = Symbol('editor:open-link-handler') + +/** + * Inject provided link handler + */ +export function useOpenLinkHandler() { + const openLinkHandler = inject(OPEN_LINK_HANDLER, { openLink }) + return { openLinkHandler } +} diff --git a/src/editor.js b/src/editor.js index 84e4db401a5..9028a3a2b75 100644 --- a/src/editor.js +++ b/src/editor.js @@ -11,9 +11,9 @@ import { HOOK_MENTION_INSERT, HOOK_MENTION_SEARCH, HOOK_MENUBAR_LINK_CUSTOM_ACTION, - OPEN_LINK_HANDLER, } from './components/Editor.provider.ts' import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js' +import { OPEN_LINK_HANDLER } from './composables/useOpenLinkHandler.ts' import { encodeAttachmentFilename } from './helpers/attachmentFilename.ts' import { openLink } from './helpers/links.js' // eslint-disable-next-line import/no-unresolved, n/no-missing-import diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js index 8ae10e27ab8..7bbc049971f 100644 --- a/src/extensions/LinkBubble.js +++ b/src/extensions/LinkBubble.js @@ -4,7 +4,7 @@ */ import { Extension } from '@tiptap/core' -import { hideLinkBubble, linkBubble } from '../plugins/links.js' +import { hideLinkBubble, linkBubble } from '../plugins/links.ts' const LinkBubble = Extension.create({ name: 'linkViewBubble', diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 72a5a71724e..f61924dde23 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -61,6 +61,7 @@ export default Extension.create({ relativePath: null, isEmbedded: false, mentionSearch: undefined, + openLink: undefined, } }, @@ -122,6 +123,7 @@ export default Extension.create({ openOnClick: true, shouldAutoLink: (href) => /^https?:\/\//.test(href), relativePath: this.options.relativePath, + openLink: this.options.openLink, }), LinkBubble, this.options.editing diff --git a/src/helpers/links.js b/src/helpers/links.js index 699b08890c3..4b9a0b9a8a1 100644 --- a/src/helpers/links.js +++ b/src/helpers/links.js @@ -64,19 +64,7 @@ const isLinkToSelfWithHash = function (href) { */ const openLink = function (href) { const linkUrl = new URL(href, window.location.href) - // Consider rerouting links to Collectives if already inside Collectives app - const collectivesUrlBase = '/apps/collectives' - if ( - window.OCA.Collectives?.vueRouter - && linkUrl.pathname.toString().startsWith(generateUrl(collectivesUrlBase)) - ) { - const collectivesUrl = linkUrl.href.substring( - linkUrl.href.indexOf(collectivesUrlBase) + collectivesUrlBase.length, - ) - window.OCA.Collectives.vueRouter.push(collectivesUrl) - return - } - window.open(linkUrl, '_blank') + window.open(linkUrl.href, '_blank') } export { domHref, isLinkToSelfWithHash, openLink, parseHref } diff --git a/src/marks/Link.js b/src/marks/Link.js index 3b02a3fc6f9..01d22245f33 100644 --- a/src/marks/Link.js +++ b/src/marks/Link.js @@ -6,7 +6,7 @@ import { getMarkRange, isMarkActive, markInputRule } from '@tiptap/core' import TipTapLink from '@tiptap/extension-link' import { defaultMarkdownSerializer } from 'prosemirror-markdown' -import { linkClicking } from '../plugins/links.js' +import { linkClicking } from '../plugins/links.ts' import { domHref, parseHref } from './../helpers/links.js' const PROTOCOLS_TO_LINK_TO = ['http:', 'https:', 'mailto:', 'tel:'] @@ -32,6 +32,7 @@ const Link = TipTapLink.extend({ return { ...this.parent?.(), relativePath: null, + openLink: undefined, } }, @@ -182,7 +183,7 @@ const Link = TipTapLink.extend({ }) // Custom click handler plugins - return [...plugins, linkClicking()] + return [...plugins, linkClicking(this.options.openLink)] }, // @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API diff --git a/src/plugins/links.js b/src/plugins/links.ts similarity index 73% rename from src/plugins/links.js rename to src/plugins/links.ts index 16dd1a55593..1a8b5a11d59 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.ts @@ -3,6 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { Editor } from '@tiptap/core' +import type { ResolvedPos } from '@tiptap/pm/model' +import type { Command } from '@tiptap/pm/state' + import { Plugin, PluginKey } from '@tiptap/pm/state' import { isLinkToSelfWithHash } from '../helpers/links.js' import LinkBubblePluginView from './LinkBubblePluginView.js' @@ -12,25 +16,27 @@ import { activeLinkFromSelection } from './linkHelpers.js' /* Set resolved to be the active element (if it has a link mark) * - * @params {ResolvedPos} resolved position of the action + * @params resolved - resolved position of the action */ -export const setActiveLink = (resolved) => (state, dispatch) => { - const mark = resolved.marks().find((m) => m.type.name === 'link') - if (!mark) { - return false +export const setActiveLink = + (resolved: ResolvedPos): Command => + (state, dispatch) => { + const mark = resolved.marks().find((m) => m.type.name === 'link') + if (!mark) { + return false + } + const nodeStart = resolved.pos - resolved.textOffset + const active = { mark, nodeStart } + if (dispatch) { + dispatch(state.tr.setMeta(linkBubbleKey, { active })) + } + return true } - const nodeStart = resolved.pos - resolved.textOffset - const active = { mark, nodeStart } - if (dispatch) { - dispatch(state.tr.setMeta(linkBubbleKey, { active })) - } - return true -} /* Hide the link bubble by setting active state to null * */ -export const hideLinkBubble = (state, dispatch) => { +export const hideLinkBubble: Command = (state, dispatch) => { const pluginState = linkBubbleKey.getState(state) if (!pluginState?.active) { return false @@ -44,10 +50,12 @@ export const hideLinkBubble = (state, dispatch) => { export const linkBubbleKey = new PluginKey('linkBubble') /** * Prosemirror link bubble plugin - * @param {object} options - options for the link bubble plugin view + * + * @param options - options for the link bubble plugin view + * @param options.editor - the editor */ -export function linkBubble(options) { - const linkBubblePlugin = new Plugin({ +export function linkBubble(options: { editor: Editor }) { + const linkBubblePlugin: Plugin = new Plugin({ key: linkBubbleKey, state: { init: () => ({ active: null }), @@ -79,7 +87,7 @@ export function linkBubble(options) { const sameDoc = oldState?.doc.eq(state.doc) // Don't open bubble on changes by other session members const noHistory = transactions.every( - (tr) => tr.meta.addToHistory === false, + (tr) => tr.getMeta('addToHistory') === false, ) if (sameSelection && (noHistory || sameDoc)) { return @@ -129,16 +137,23 @@ export const linkClickingKey = new PluginKey('textHandleClickLink') * - Open link in new tab on middle click rather than pasting. * - Only open link on ctrl/cmd + left click. * We use the link bubble otherwise. + * + * @param openLink - the openLink callback function */ -export function linkClicking() { +export function linkClicking( + openLink: (href: string) => void = (href) => { + window.open(href, '_blank') + }, +) { return new Plugin({ key: linkClickingKey, props: { handleDOMEvents: { // Open link in new tab on middle click auxclick: (view, event) => { + const linkEl = (event.target as Element | null)?.closest('a') if ( - event.target.closest('a') + linkEl && event.button === 1 && !event.ctrlKey && !event.metaKey @@ -146,16 +161,15 @@ export function linkClicking() { ) { event.preventDefault() event.stopImmediatePropagation() - - const linkElement = event.target.closest('a') - window.open(linkElement.href, '_blank') + // Open link in new tab on middle click (ignore custom link handler on purpose) + window.open(linkEl.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')) { + if ((event.target as Element | null)?.closest('a')) { event.stopPropagation() event.preventDefault() event.stopImmediatePropagation() @@ -163,7 +177,7 @@ export function linkClicking() { }, // Prevent open link for text-only links on left click. Required for read-only mode. click: (view, event) => { - const linkEl = event.target.closest('a') + const linkEl = (event.target as Element | null)?.closest('a') // Only text-only links need special handling (e.g. don't handle links inside preview or mermaid diagrams) if ( !linkEl @@ -176,12 +190,12 @@ export function linkClicking() { // Stop browser from opening the link event.preventDefault() - if (isLinkToSelfWithHash(linkEl.attributes.href?.value)) { + if (isLinkToSelfWithHash(linkEl.href)) { // Open anchor links directly - location.href = linkEl.attributes.href.value + location.href = linkEl.href } else if (event.ctrlKey || event.metaKey) { - // Open link in new tab on Ctrl/Cmd + left click - window.open(linkEl.href, '_blank') + // Open link directly on Ctrl/Cmd + left click + openLink(linkEl.href) } } }, diff --git a/src/tests/plugins/linkBubble.spec.js b/src/tests/plugins/linkBubble.spec.js index 02527076ea0..d734c7c5d92 100644 --- a/src/tests/plugins/linkBubble.spec.js +++ b/src/tests/plugins/linkBubble.spec.js @@ -5,7 +5,7 @@ import { EditorState, Plugin } from '@tiptap/pm/state' import { schema } from 'prosemirror-schema-basic' -import { hideLinkBubble, linkBubble, setActiveLink } from '../../plugins/links.js' +import { hideLinkBubble, linkBubble, setActiveLink } from '../../plugins/links.ts' describe('linkBubble prosemirror plugin', () => { test('signature', () => {