From f750a822f416d1170f0d1483740a97b67d5eff9b Mon Sep 17 00:00:00 2001 From: Jonas Date: Sat, 23 Dec 2023 19:18:16 +0100 Subject: [PATCH 01/15] feat(editor): Add link view bubble to preview, edit and open links Signed-off-by: Jonas --- src/components/Link/LinkBubbleView.vue | 266 +++++++++++++++++++++++++ src/extensions/LinkBubble.js | 27 +++ src/extensions/LinkBubblePluginView.js | 236 ++++++++++++++++++++++ src/extensions/RichText.js | 2 + src/mixins/CopyToClipboardMixin.js | 41 ++++ 5 files changed, 572 insertions(+) create mode 100644 src/components/Link/LinkBubbleView.vue create mode 100644 src/extensions/LinkBubble.js create mode 100644 src/extensions/LinkBubblePluginView.js create mode 100644 src/mixins/CopyToClipboardMixin.js diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue new file mode 100644 index 00000000000..83589da96e5 --- /dev/null +++ b/src/components/Link/LinkBubbleView.vue @@ -0,0 +1,266 @@ + + + + + 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..bddb60f8b0f --- /dev/null +++ b/src/extensions/LinkBubblePluginView.js @@ -0,0 +1,236 @@ +import { VueRenderer } from '@tiptap/vue-2' +import tippy from 'tippy.js' +import { domHref } from '../helpers/links.js' +import LinkBubbleView from '../components/Link/LinkBubbleView.vue' + +class LinkBubblePluginView { + + component = null + preventHide = false + updateDebounceTimer = undefined + updateDelay = 250 + + 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 + if (event.button !== 0 || event.ctrlKey) { + 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.component.element.parentNode?.contains(event.relatedTarget)) { + return + } + + this.hide() + } + + tippyBlurHandler = (event) => { + this.blurHandler({ event }) + } + + 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', + }, + }) + + // maybe we have to hide tippy on its own blur event as well + if (this.tippy.popper.firstChild) { + (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) + }, this.updateDelay) + } + + updateFromSelection(view) { + 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 isLink = linkNode?.marks.some(m => m.type.name === 'link') + const hasBubbleFocus = this.component.element.contains(document.activeElement) + const hasEditorFocus = view.hasFocus() || hasBubbleFocus + const shouldShow = isLink && hasEditorFocus + + this.updateTooltip(view, shouldShow, linkNode, nodeStart) + } + + updateFromClick(view, clickedLinkPos) { + const nodeStart = clickedLinkPos.pos - clickedLinkPos.textOffset + const linkNode = clickedLinkPos.parent.maybeChild(clickedLinkPos.index()) + + const shouldShow = linkNode?.marks.some(m => m.type.name === 'link') + + this.updateTooltip(this.editor.view, shouldShow, linkNode, 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.component.element.addEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + this.tippy?.show() + } + + hide() { + this.component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + this.tippy?.hide() + } + + destroy() { + if (this.tippy?.popper.firstChild) { + (this.tippy.popper.firstChild).removeEventListener('blur', this.tippyBlurHandler) + } + this.tippy?.destroy() + this.component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + 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 + } + + if (!node?.isText || !node.marks.some(m => m.type.name === 'link')) { + // Selected node is not a text node with link mark + return + } + + return node + } + +} + +export default LinkBubblePluginView diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 24bccbcb80b..2e714921ad8 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' @@ -113,6 +114,7 @@ export default Extension.create({ suggestion: EmojiSuggestion(), }), LinkPicker, + LinkBubble, this.options.editing ? Placeholder.configure({ emptyNodeClass: 'is-empty', 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) + } + }, + }, +} From d14558b8615b1723bb7fa0ab0bab16aa646b46f6 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 24 Dec 2023 00:29:09 +0100 Subject: [PATCH 02/15] feat(paragraph): Don't show link previews Signed-off-by: Jonas --- src/css/prosemirror.scss | 4 +- src/nodes/Paragraph.js | 10 +-- src/nodes/ParagraphView.vue | 132 ---------------------------------- src/tests/nodes/Table.spec.js | 2 +- src/tests/tiptap.spec.js | 4 +- 5 files changed, 11 insertions(+), 141 deletions(-) delete mode 100644 src/nodes/ParagraphView.vue 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/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/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

') }) }) From da24829eeb02a788ac98e31320b26ff4d36451fe Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 24 Dec 2023 00:32:44 +0100 Subject: [PATCH 03/15] refactor(link): Improve readability of clickHandler function Signed-off-by: Jonas --- src/marks/Link.js | 4 ++-- src/plugins/link.js | 20 +++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/marks/Link.js b/src/marks/Link.js index 70a9f48c8bc..0ea39b68eee 100644 --- a/src/marks/Link.js +++ b/src/marks/Link.js @@ -69,7 +69,7 @@ const Link = TipTapLink.extend({ addProseMirrorPlugins() { const plugins = this.parent() - // remove original handle click + // remove upstream link click handle plugin .filter(({ key }) => { return !key.startsWith('handleClickLink') }) @@ -78,7 +78,7 @@ const Link = TipTapLink.extend({ return plugins } - // add custom click handler + // add custom click handle plugin return [ ...plugins, clickHandler({ diff --git a/src/plugins/link.js b/src/plugins/link.js index 43e7d32ecd6..02d6d5c89d7 100644 --- a/src/plugins/link.js +++ b/src/plugins/link.js @@ -4,24 +4,30 @@ import { logger } from '../helpers/logger.js' const clickHandler = ({ editor, type, onClick }) => { return new Plugin({ + key: new PluginKey('textHandleClickLink'), props: { - key: new PluginKey('textLink'), handleClick: (view, pos, event) => { + // Only regard left clicks without Ctrl + if (event.button !== 0 || event.ctrlKey) { + 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 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) - } + + event.stopPropagation() + return onClick?.(event, link.attrs) }, }, }) @@ -29,8 +35,8 @@ const clickHandler = ({ editor, type, onClick }) => { const clickPreventer = () => { return new Plugin({ + key: new PluginKey('textAvoidClickLink'), props: { - key: new PluginKey('textAvoidLinkClick'), handleDOMEvents: { click: (view, event) => { if (!view.editable) { From df2e5c35e9ccf86178259372847daf8fadc581f4 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 9 Jan 2024 14:25:51 +0100 Subject: [PATCH 04/15] refactor(RichText): remove option to disable Link extension This option is not used anyway. We always use the Link extension. Signed-off-by: Jonas --- src/extensions/RichText.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 2e714921ad8..d3bb572a6af 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -114,6 +114,12 @@ export default Extension.create({ suggestion: EmojiSuggestion(), }), LinkPicker, + Link.configure({ + ...this.options.link, + openOnClick: true, + validate: href => /^https?:\/\//.test(href), + relativePath: this.options.relativePath, + }), LinkBubble, this.options.editing ? Placeholder.configure({ @@ -124,14 +130,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)), From bbee8a06b7155f1750d2c4f2273d7ecc6427f019 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 9 Jan 2024 14:09:31 +0100 Subject: [PATCH 05/15] feat(editor): Refactor link click handlers New link click behaviour: * Link left clicks without Ctrl/Meta open link bubble (Fixes: #3691) * Link left clicks with Ctrl/Meta open link in new tab * Link middle clicks open link in new tab * Link middle clicks on Linux don't paste content (Fixes: #2198) * No more custom open link handler in editor Implementation details: * Moved link click handler plugins back into Link mark class. * Added 'data-md-href' attribute to a-elements in DOM, to be used in onClick handlers. Signed-off-by: Jonas --- src/components/RichTextReader.vue | 23 ----------- src/extensions/LinkBubblePluginView.js | 4 +- src/helpers/links.js | 4 +- src/marks/Link.js | 55 ++++++++++++++++++++------ src/plugins/link.js | 52 ------------------------ 5 files changed, 46 insertions(+), 92 deletions(-) delete mode 100644 src/plugins/link.js 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/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index bddb60f8b0f..18e5dbe5183 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -40,8 +40,8 @@ class LinkBubblePluginView { // 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 - if (event.button !== 0 || event.ctrlKey) { + // Only regard left clicks without Ctrl/Meta + if (event.button !== 0 || event.ctrlKey || event.metaKey) { return false } diff --git a/src/helpers/links.js b/src/helpers/links.js index d31d091690e..61093e4a6f7 100644 --- a/src/helpers/links.js +++ b/src/helpers/links.js @@ -93,7 +93,7 @@ const parseHref = function(dom) { return ref } -const openLink = function(event, _attrs) { +const openLink = function(event, target = '_self') { const linkElement = event.target.closest('a') const htmlHref = linkElement.href const query = OC.parseQueryString(htmlHref) @@ -128,7 +128,7 @@ const openLink = function(event, _attrs) { return } } - window.open(htmlHref) + window.open(htmlHref, target) return true } diff --git a/src/marks/Link.js b/src/marks/Link.js index 0ea39b68eee..0dad50e37a8 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,6 +62,7 @@ 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] }, @@ -74,19 +74,48 @@ const Link = TipTapLink.extend({ return !key.startsWith('handleClickLink') }) - if (!this.options.openOnClick) { - return plugins - } - - // add custom click handle plugin + // 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 on left click (required for read-only mode) + // Open link in new tab on Ctrl/Cmd + left click + click: (view, event) => { + if (event.target.closest('a')) { + if (event.button === 0) { + event.preventDefault() + if (event.ctrlKey || event.metaKey) { + const linkElement = event.target.closest('a') + window.open(linkElement.href, '_blank') + } + } + } + }, + }, + }, }), - clickPreventer(), ] }, }) diff --git a/src/plugins/link.js b/src/plugins/link.js deleted file mode 100644 index 02d6d5c89d7..00000000000 --- a/src/plugins/link.js +++ /dev/null @@ -1,52 +0,0 @@ -import { Plugin, PluginKey } from '@tiptap/pm/state' - -import { logger } from '../helpers/logger.js' - -const clickHandler = ({ editor, type, onClick }) => { - return new Plugin({ - key: new PluginKey('textHandleClickLink'), - props: { - handleClick: (view, pos, event) => { - // Only regard left clicks without Ctrl - if (event.button !== 0 || event.ctrlKey) { - 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 - 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 - } - - event.stopPropagation() - return onClick?.(event, link.attrs) - }, - }, - }) -} - -const clickPreventer = () => { - return new Plugin({ - key: new PluginKey('textAvoidClickLink'), - props: { - handleDOMEvents: { - click: (view, event) => { - if (!view.editable) { - event.preventDefault() - return false - } - }, - }, - }, - }) -} - -export { clickHandler, clickPreventer } From dc13b01f3f25017e66f885a7236f7d9ee49f4b21 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 22 Jan 2024 17:58:00 +0100 Subject: [PATCH 06/15] fix(LinkBubble): Prevent update race condition in read-only in Chrome Signed-off-by: Jonas --- src/extensions/LinkBubblePluginView.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index 18e5dbe5183..cf4fe5cbe60 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -7,6 +7,7 @@ class LinkBubblePluginView { component = null preventHide = false + hadUpdateFromClick = false updateDebounceTimer = undefined updateDelay = 250 @@ -128,6 +129,11 @@ class LinkBubblePluginView { } 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 @@ -153,6 +159,10 @@ class LinkBubblePluginView { const shouldShow = linkNode?.marks.some(m => m.type.name === 'link') + this.hadUpdateFromClick = true + setTimeout(() => { + this.hadUpdateFromClick = false + }, 200) this.updateTooltip(this.editor.view, shouldShow, linkNode, nodeStart) } From 648cf2036f229eaa5b00e8d4f83c763da1d5d564 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 23 Jan 2024 14:34:47 +0100 Subject: [PATCH 07/15] chore(LinkBubble): Remove code to customize link click handling We no longer allow custom link click handlers in Text. Instead, the reference widgets for link previews have to implement their own click handlers. Signed-off-by: Jonas --- src/components/Editor.provider.js | 9 ---- .../Editor/MarkdownContentEditor.vue | 10 +---- src/editor.js | 4 +- src/extensions/RichText.js | 2 - src/helpers/links.js | 43 ------------------- 5 files changed, 2 insertions(+), 66 deletions(-) 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/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/RichText.js b/src/extensions/RichText.js index d3bb572a6af..7ae707f3f1e 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -69,7 +69,6 @@ export default Extension.create({ addOptions() { return { editing: true, - link: {}, extensions: [], component: null, relativePath: null, @@ -115,7 +114,6 @@ export default Extension.create({ }), LinkPicker, Link.configure({ - ...this.options.link, openOnClick: true, validate: href => /^https?:\/\//.test(href), relativePath: this.options.relativePath, diff --git a/src/helpers/links.js b/src/helpers/links.js index 61093e4a6f7..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, target = '_self') { - 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, target) - return true -} - export { domHref, parseHref, - openLink, } From 9a819168fe3290f1096f3c845ac0d01d625010c1 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 18 Jan 2024 18:03:58 +0100 Subject: [PATCH 08/15] test(cypress): Fix link tests with link bubble Signed-off-by: Jonas --- cypress/e2e/nodes/Links.spec.js | 113 ++++++++++++++++++++------------ cypress/e2e/workspace.spec.js | 9 ++- 2 files changed, 78 insertions(+), 44 deletions(-) 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') From 37021385a99a5a638fb99bb1ca3ec341c6db5054 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 24 Jan 2024 16:34:33 +0100 Subject: [PATCH 09/15] fix(LinkBubble): Use private variables for LinkBubbblePluginView class Signed-off-by: Jonas --- src/extensions/LinkBubblePluginView.js | 47 +++++++++++++------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index cf4fe5cbe60..b57c412a853 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -3,19 +3,20 @@ 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 - updateDelay = 250 + #component = null + #preventHide = false + #hadUpdateFromClick = false + #updateDebounceTimer = undefined constructor({ editor, view }) { this.editor = editor this.view = view - this.component = new VueRenderer(LinkBubbleView, { + this.#component = new VueRenderer(LinkBubbleView, { parent: this.editor.contentComponent, propsData: { editor: this.editor, @@ -35,7 +36,7 @@ class LinkBubblePluginView { } pointerdownHandler = () => { - this.preventHide = true + this.#preventHide = true } // Required for read-only mode on Firefox. For some reason, editor selection doesn't get @@ -66,12 +67,12 @@ class LinkBubblePluginView { } blurHandler = ({ event }) => { - if (this.preventHide) { - this.preventHide = false + if (this.#preventHide) { + this.#preventHide = false return } - if (event?.relatedTarget && this.component.element.parentNode?.contains(event.relatedTarget)) { + if (event?.relatedTarget && this.#component.element.parentNode?.contains(event.relatedTarget)) { return } @@ -93,7 +94,7 @@ class LinkBubblePluginView { this.tippy = tippy(editorElement, { duration: 100, getReferenceClientRect: null, - content: this.component.element, + content: this.#component.element, interactive: true, trigger: 'manual', placement: 'bottom', @@ -119,18 +120,18 @@ class LinkBubblePluginView { return } - if (this.updateDebounceTimer) { - clearTimeout(this.updateDebounceTimer) + if (this.#updateDebounceTimer) { + clearTimeout(this.#updateDebounceTimer) } - this.updateDebounceTimer = window.setTimeout(() => { + this.#updateDebounceTimer = window.setTimeout(() => { this.updateFromSelection(view) - }, this.updateDelay) + }, updateDelay) } updateFromSelection(view) { // Don't update directly after updateFromClick. Prevents race condition in read-only documents in Chrome. - if (this.hadUpdateFromClick) { + if (this.#hadUpdateFromClick) { return } @@ -146,7 +147,7 @@ class LinkBubblePluginView { const linkNode = this.linkNodeFromSelection(view) const isLink = linkNode?.marks.some(m => m.type.name === 'link') - const hasBubbleFocus = this.component.element.contains(document.activeElement) + const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus const shouldShow = isLink && hasEditorFocus @@ -159,9 +160,9 @@ class LinkBubblePluginView { const shouldShow = linkNode?.marks.some(m => m.type.name === 'link') - this.hadUpdateFromClick = true + this.#hadUpdateFromClick = true setTimeout(() => { - this.hadUpdateFromClick = false + this.#hadUpdateFromClick = false }, 200) this.updateTooltip(this.editor.view, shouldShow, linkNode, nodeStart) } @@ -180,7 +181,7 @@ class LinkBubblePluginView { } const clientRect = referenceEl?.getBoundingClientRect() - this.component.updateProps({ + this.#component.updateProps({ href: domHref(linkNode.marks.find(m => m.type.name === 'link')), }) @@ -192,12 +193,12 @@ class LinkBubblePluginView { } show() { - this.component.element.addEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + this.#component.element.addEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.tippy?.show() } hide() { - this.component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + this.#component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.tippy?.hide() } @@ -206,7 +207,7 @@ class LinkBubblePluginView { (this.tippy.popper.firstChild).removeEventListener('blur', this.tippyBlurHandler) } this.tippy?.destroy() - this.component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) + this.#component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.view.dom.removeEventListener('dragstart', this.dragOrScrollHandler) this.view.dom.removeEventListener('click', this.clickHandler) document.removeEventListener('scroll', this.dragOrScrollHandler, { capture: true }) From 9f7da38b7924c177124745b11d56518b2e03bff8 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 24 Jan 2024 16:45:49 +0100 Subject: [PATCH 10/15] fix(LinkBubble): Restore selection after updating the link Signed-off-by: Jonas --- src/components/Link/LinkBubbleView.vue | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue index 83589da96e5..3a07b9d002e 100644 --- a/src/components/Link/LinkBubbleView.vue +++ b/src/components/Link/LinkBubbleView.vue @@ -207,7 +207,19 @@ export default { }, setLinkUrl(href) { - this.editor.chain().extendMarkRange('link').setLink({ href }).focus().run() + // Store current selection to restore it after setLink + const selection = { ...this.editor.view.state.selection } + const { ranges } = selection + const from = Math.min(...ranges.map(range => range.$from.pos)) + const to = Math.max(...ranges.map(range => range.$to.pos)) + + console.debug('selection', selection) + this.editor.chain() + .extendMarkRange('link') + .setLink({ href }) + .setTextSelection({ from, to }) + .focus() + .run() }, removeLink() { From 07217adf6525981c515e6b14bd7067b9ada349ff Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 24 Jan 2024 21:55:00 +0100 Subject: [PATCH 11/15] fix(LinkBubble): Improved blur event handler registration * Clicking outside the bubble always hides it * Clicking inside the bubble never hides it Signed-off-by: Jonas --- src/extensions/LinkBubblePluginView.js | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index b57c412a853..c9c6500d60d 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -72,15 +72,15 @@ class LinkBubblePluginView { return } - if (event?.relatedTarget && this.#component.element.parentNode?.contains(event.relatedTarget)) { + if (event?.relatedTarget && this.tippy?.popper.firstChild.contains(event.relatedTarget)) { return } this.hide() } - tippyBlurHandler = (event) => { - this.blurHandler({ event }) + tippyBlurHandler = () => { + this.hide() } createTooltip() { @@ -104,10 +104,9 @@ class LinkBubblePluginView { }, }) - // maybe we have to hide tippy on its own blur event as well - if (this.tippy.popper.firstChild) { - (this.tippy.popper.firstChild).addEventListener('blur', this.tippyBlurHandler) - } + 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) { @@ -146,7 +145,7 @@ class LinkBubblePluginView { const nodeStart = resolved.pos - resolved.textOffset const linkNode = this.linkNodeFromSelection(view) - const isLink = linkNode?.marks.some(m => m.type.name === 'link') + const isLink = !!linkNode?.marks.some(m => m.type.name === 'link') const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus const shouldShow = isLink && hasEditorFocus @@ -193,21 +192,17 @@ class LinkBubblePluginView { } show() { - this.#component.element.addEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.tippy?.show() } hide() { - this.#component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.tippy?.hide() } destroy() { - if (this.tippy?.popper.firstChild) { - (this.tippy.popper.firstChild).removeEventListener('blur', this.tippyBlurHandler) - } + this.tippy?.popper.firstChild?.removeEventListener('blur', this.tippyBlurHandler) + this.tippy?.popper.firstChild?.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.tippy?.destroy() - this.#component.element.removeEventListener('pointerdown', this.pointerdownHandler, { capture: true }) this.view.dom.removeEventListener('dragstart', this.dragOrScrollHandler) this.view.dom.removeEventListener('click', this.clickHandler) document.removeEventListener('scroll', this.dragOrScrollHandler, { capture: true }) From 60c1711ad5ca1b70239c444aab77b5d3cf53da11 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 25 Jan 2024 14:02:49 +0100 Subject: [PATCH 12/15] fix(Link): Don't open link bubble for anchor links, jump to anchor directly Signed-off-by: Jonas --- src/extensions/LinkBubblePluginView.js | 28 +++++++++++++++++--------- src/marks/Link.js | 17 ++++++++-------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index c9c6500d60d..21254050bf5 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -145,25 +145,24 @@ class LinkBubblePluginView { const nodeStart = resolved.pos - resolved.textOffset const linkNode = this.linkNodeFromSelection(view) - const isLink = !!linkNode?.marks.some(m => m.type.name === 'link') const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus - const shouldShow = isLink && hasEditorFocus + + const shouldShow = !!linkNode && hasEditorFocus this.updateTooltip(view, shouldShow, linkNode, nodeStart) } updateFromClick(view, clickedLinkPos) { const nodeStart = clickedLinkPos.pos - clickedLinkPos.textOffset - const linkNode = clickedLinkPos.parent.maybeChild(clickedLinkPos.index()) - - const shouldShow = linkNode?.marks.some(m => m.type.name === 'link') + 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, linkNode, nodeStart) + this.updateTooltip(this.editor.view, shouldShow, clickedNode, nodeStart) } updateTooltip = (view, shouldShow, linkNode, nodeStart) => { @@ -229,12 +228,21 @@ class LinkBubblePluginView { return } - if (!node?.isText || !node.marks.some(m => m.type.name === 'link')) { - // Selected node is not a text node with link mark - 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 node + return true } } diff --git a/src/marks/Link.js b/src/marks/Link.js index 0dad50e37a8..bb1db5210f1 100644 --- a/src/marks/Link.js +++ b/src/marks/Link.js @@ -100,16 +100,17 @@ const Link = TipTapLink.extend({ event.stopImmediatePropagation() } }, - // Prevent open link on left click (required for read-only mode) + // 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) => { - if (event.target.closest('a')) { - if (event.button === 0) { - event.preventDefault() - if (event.ctrlKey || event.metaKey) { - const linkElement = event.target.closest('a') - window.open(linkElement.href, '_blank') - } + 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') } } }, From a5911b876b3edbfaaae5f10659b4e5cf51af1693 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 26 Jan 2024 12:52:42 +0100 Subject: [PATCH 13/15] fix(LinkBubble): Allow to close link bubble with Escape Signed-off-by: Jonas --- src/extensions/LinkBubblePluginView.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index 21254050bf5..eac33098866 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -83,6 +83,13 @@ class LinkBubblePluginView { this.hide() } + keydownHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault() + this.hide() + } + } + createTooltip() { const { element: editorElement } = this.editor.options const editorIsAttached = !!editorElement.parentElement @@ -192,9 +199,11 @@ class LinkBubblePluginView { show() { this.tippy?.show() + this.view.dom.addEventListener('keydown', this.keydownHandler) } hide() { + this.view.dom.removeEventListener('keydown', this.keydownHandler) this.tippy?.hide() } From aa673aba3bf954e84552faf2e27826a20c1aa70a Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 29 Jan 2024 11:11:13 +0100 Subject: [PATCH 14/15] fix(LinkBubble): Hide bubble after 100ms delay Should allow Cypress to click on it before blur event happens. Signed-off-by: Jonas --- src/extensions/LinkBubblePluginView.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index eac33098866..8cb90a77b30 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -204,7 +204,9 @@ class LinkBubblePluginView { hide() { this.view.dom.removeEventListener('keydown', this.keydownHandler) - this.tippy?.hide() + setTimeout(() => { + this.tippy?.hide() + }, 100) } destroy() { From b73181a2587efdb4cb40d1fc42ad6cd888972c4b Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 30 Jan 2024 12:43:08 +0100 Subject: [PATCH 15/15] fix(LinkBubble): Add some padding and border radius Signed-off-by: Jonas --- src/components/Link/LinkBubbleView.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue index 3a07b9d002e..d1a1e46ca82 100644 --- a/src/components/Link/LinkBubbleView.vue +++ b/src/components/Link/LinkBubbleView.vue @@ -260,16 +260,24 @@ export default { } &__reference-list { - padding-right: 4px; + padding: 4px; + padding-top: 0; :deep(a.widget-default) { margin: 0; border: 0; border-radius: unset; } + + :deep(img.widget-default--image) { + border-radius: var(--border-radius-large); + } } &__edit { + padding: 4px; + padding-top: 0; + .input-field { margin-bottom: 12px; }