diff --git a/playwright/e2e/conflict.spec.ts b/playwright/e2e/conflict.spec.ts index 13ee9704602..87bdad842a2 100644 --- a/playwright/e2e/conflict.spec.ts +++ b/playwright/e2e/conflict.spec.ts @@ -335,12 +335,14 @@ test('conflict dialog is sticky when scrolling', async ({ const pushPromise = page.waitForRequest(/push/) await editor.typeHeading('Long content\n') await pushPromise - await setOffline() for (let i = 1; i < 8; i++) { await editor.typeHeading(`Section ${i}`) await editor.type('\n\nLorem ipsum dolor sit amet.\n\n') } + + await setOffline() + await editor.type('unsaved changes') await close() await setOnline() diff --git a/src/EditorFactory.ts b/src/EditorFactory.ts index 88505340ce0..63c28825d76 100644 --- a/src/EditorFactory.ts +++ b/src/EditorFactory.ts @@ -45,12 +45,14 @@ const createRichEditor = ({ relativePath, isEmbedded = false, mentionSearch = undefined, + openLink = undefined, }: { extensions?: Extension[] connection?: Connection relativePath?: string isEmbedded?: boolean mentionSearch?: (query: string) => Promise> + openLink?: (href: string) => void } = {}) => { return new Editor({ editorProps, @@ -60,6 +62,7 @@ const createRichEditor = ({ relativePath, isEmbedded, mentionSearch, + openLink, }), FocusTrap, ...extensions, diff --git a/src/components/Editor.provider.ts b/src/components/Editor.provider.ts index 9031c5a4c32..b75fb4efb09 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 ATTACHMENT_RESOLVER = Symbol('attachment:resolver') @@ -11,7 +10,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 = { @@ -55,13 +53,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 ce83a685997..463c2534e95 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -96,6 +96,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, HOOK_MENTION_SEARCH, @@ -269,7 +270,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, @@ -277,6 +279,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 65b827751d4..3fc3ffdba92 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 92f7c08a3b1..d5807c3e737 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -69,6 +69,7 @@ export default Extension.create({ relativePath: null, isEmbedded: false, mentionSearch: undefined, + openLink: undefined, } }, @@ -131,6 +132,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.ts b/src/marks/Link.ts index cc679387191..e506553cc85 100644 --- a/src/marks/Link.ts +++ b/src/marks/Link.ts @@ -11,7 +11,7 @@ import type { Mark, Node } from '@tiptap/pm/model' import type { MarkdownSerializerState } from 'prosemirror-markdown' import { defaultMarkdownSerializer } from 'prosemirror-markdown' import { domHref, parseHref } from '../helpers/links.js' -import { linkClicking } from '../plugins/links.js' +import { linkClicking } from '../plugins/links' const PROTOCOLS_TO_LINK_TO = ['http:', 'https:', 'mailto:', 'tel:'] @@ -33,6 +33,7 @@ const extractHrefFromMarkdownLink = (match: ExtendedRegExpMatchArray) => { export interface RelativePathLinkOptions extends LinkOptions { relativePath?: string + openLink?: (href: string) => void } const parentDefaults: LinkOptions = { @@ -98,6 +99,7 @@ const Link = TipTapLink.extend({ return { ...this.parent?.(), relativePath: undefined, + openLink: undefined, ...parentDefaults, } }, @@ -247,7 +249,7 @@ const Link = TipTapLink.extend({ .filter((plugin) => !plugin.props.handleClick) // Add our own click handler plugin - 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', () => {