From 710b350cd655784731929871904c768360d0948f Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 15 Mar 2024 15:11:04 +0100 Subject: [PATCH 01/23] refactor(links): move linkBubble plugin to plugins/links Signed-off-by: Max --- src/extensions/LinkBubble.js | 11 ++--------- src/{extensions => plugins}/LinkBubblePluginView.js | 0 src/plugins/links.js | 11 +++++++++++ 3 files changed, 13 insertions(+), 9 deletions(-) rename src/{extensions => plugins}/LinkBubblePluginView.js (100%) diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js index ed4a9b7151c..d68265dc733 100644 --- a/src/extensions/LinkBubble.js +++ b/src/extensions/LinkBubble.js @@ -1,6 +1,5 @@ import { Extension } from '@tiptap/core' -import { Plugin, PluginKey } from '@tiptap/pm/state' -import LinkBubblePluginView from './LinkBubblePluginView.js' +import { linkBubble } from '../plugins/links.js' const LinkBubble = Extension.create({ name: 'linkViewBubble', @@ -13,13 +12,7 @@ const LinkBubble = Extension.create({ addProseMirrorPlugins() { return [ - new Plugin({ - key: new PluginKey(this.options.pluginKey), - view: (view) => new LinkBubblePluginView({ - editor: this.editor, - view, - }), - }), + linkBubble(this.editor, this.options.pluginKey), ] }, }) diff --git a/src/extensions/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js similarity index 100% rename from src/extensions/LinkBubblePluginView.js rename to src/plugins/LinkBubblePluginView.js diff --git a/src/plugins/links.js b/src/plugins/links.js index 90f9dfc103d..3daaabbd0a0 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -21,6 +21,17 @@ */ import { Plugin, PluginKey } from '@tiptap/pm/state' +import LinkBubblePluginView from './LinkBubblePluginView.js' + +export function linkBubble(editor, pluginKey) { + return new Plugin({ + key: new PluginKey(pluginKey), + view: (view) => new LinkBubblePluginView({ + editor, + view, + }), + }) +} export function linkClicking() { return new Plugin({ From e6049b0c00901c3671e9ccb57647bcfcb3a7141a Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 15 Mar 2024 15:20:36 +0100 Subject: [PATCH 02/23] refactor(links): pass editor via options to LinkBubblePluginView Signed-off-by: Max --- src/extensions/LinkBubble.js | 4 +++- src/plugins/LinkBubblePluginView.js | 4 ++-- src/plugins/links.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js index d68265dc733..a8eac4c4d0d 100644 --- a/src/extensions/LinkBubble.js +++ b/src/extensions/LinkBubble.js @@ -12,7 +12,9 @@ const LinkBubble = Extension.create({ addProseMirrorPlugins() { return [ - linkBubble(this.editor, this.options.pluginKey), + linkBubble(this.options.pluginKey, { + editor: this.editor, + }), ] }, }) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 3f5d09f53dd..d9bce51dbd3 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -11,8 +11,8 @@ class LinkBubblePluginView { #component = null #hadUpdateFromClick = false - constructor({ editor, view }) { - this.editor = editor + constructor({ view, options }) { + this.editor = options.editor this.view = view // When editor is used in Viewer component, it should render comopnent using Viewer's Vue constructor, diff --git a/src/plugins/links.js b/src/plugins/links.js index 3daaabbd0a0..1e28be06145 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -23,12 +23,12 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' import LinkBubblePluginView from './LinkBubblePluginView.js' -export function linkBubble(editor, pluginKey) { +export function linkBubble(pluginKey, options) { return new Plugin({ key: new PluginKey(pluginKey), view: (view) => new LinkBubblePluginView({ - editor, view, + options, }), }) } From d87127782d8ede08b02ece6ed16811f3f672e997 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 15 Mar 2024 15:38:59 +0100 Subject: [PATCH 03/23] refactor(links): untangle LinkBubblePluginView from editor It is a prosemirror plugin view. No need for it to know tiptap data structures Signed-off-by: Max --- src/extensions/LinkBubble.js | 1 + src/plugins/LinkBubblePluginView.js | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js index a8eac4c4d0d..1c0e2125850 100644 --- a/src/extensions/LinkBubble.js +++ b/src/extensions/LinkBubble.js @@ -14,6 +14,7 @@ const LinkBubble = Extension.create({ return [ linkBubble(this.options.pluginKey, { editor: this.editor, + parent: this.editor.contentComponent, }), ] }, diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index d9bce51dbd3..f2af1a7036e 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -12,7 +12,7 @@ class LinkBubblePluginView { #hadUpdateFromClick = false constructor({ view, options }) { - this.editor = options.editor + this.options = options this.view = view // When editor is used in Viewer component, it should render comopnent using Viewer's Vue constructor, @@ -20,9 +20,9 @@ class LinkBubblePluginView { const ViewerVue = getViewerVue() const LinkBubbleViewConstructor = ViewerVue ? ViewerVue.extend(LinkBubbleView) : LinkBubbleView this.#component = new VueRenderer(LinkBubbleViewConstructor, { - parent: this.editor.contentComponent, + parent: this.options.parent, propsData: { - editor: this.editor, + editor: this.options.editor, href: null, }, }) @@ -49,17 +49,17 @@ class LinkBubblePluginView { } // Only regard clicks that resolve to a prosemirror position - const { pos } = this.editor.view.posAtCoords({ left: event.clientX, top: event.clientY }) + const { pos } = this.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) + const clickedPos = this.view.state.doc.resolve(pos) // we use `setTimeout` to make sure `selection` is already updated - setTimeout(() => this.updateFromClick(this.editor.view, clickedPos)) + setTimeout(() => this.updateFromClick(this.view, clickedPos)) } keydownHandler = (event) => { @@ -70,7 +70,7 @@ class LinkBubblePluginView { } createTooltip() { - const { element: editorElement } = this.editor.options + const editorElement = this.options.editor.options.element const editorIsAttached = !!editorElement.parentElement if (this.tippy || !editorIsAttached) { @@ -138,7 +138,7 @@ class LinkBubblePluginView { setTimeout(() => { this.#hadUpdateFromClick = false }, 500) - this.updateTooltip(this.editor.view, shouldShow, clickedNode, nodeStart) + this.updateTooltip(this.view, shouldShow, clickedNode, nodeStart) } updateTooltip(view, shouldShow, linkNode, nodeStart) { From e4340f87d5ec3f017845bbb2d38c2ffc8e5d2f00 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 17 Mar 2024 18:18:16 +0100 Subject: [PATCH 04/23] refactor(links): use prosemirror tr and state to track clicks Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 80 +++++++++++------------------ src/plugins/links.js | 32 +++++++++++- 2 files changed, 60 insertions(+), 52 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index f2af1a7036e..db8cd261559 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -9,11 +9,11 @@ import { getViewerVue } from '../ViewerVue.js' class LinkBubblePluginView { #component = null - #hadUpdateFromClick = false - constructor({ view, options }) { + constructor({ view, options, plugin }) { this.options = options this.view = view + this.plugin = plugin // When editor is used in Viewer component, it should render comopnent using Viewer's Vue constructor, // Otherwise there are VNodes with different Vue constructors in a single Virtual DOM which is not fully supported by Vue @@ -28,7 +28,6 @@ class LinkBubblePluginView { }) this.view.dom.addEventListener('dragstart', this.dragOrScrollHandler) - this.view.dom.addEventListener('click', this.clickHandler) document.addEventListener('scroll', this.dragOrScrollHandler, { capture: true }) } @@ -40,28 +39,6 @@ class LinkBubblePluginView { this.hide() } - // 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.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.view.state.doc.resolve(pos) - - // we use `setTimeout` to make sure `selection` is already updated - setTimeout(() => this.updateFromClick(this.view, clickedPos)) - } - keydownHandler = (event) => { if (event.key === 'Escape') { event.preventDefault() @@ -92,23 +69,34 @@ class LinkBubblePluginView { } 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 + const clicked = this.linkClicked(view, oldState) + if (clicked) { + this.updateFromClick(view, clicked) + } else if (this.selectionUpdated(view, oldState)) { + this.updateFromSelection(view) + } + } - if (composing || isSame) { - return + linkClicked(view, oldState) { + const { clicked } = this.plugin.getState(view.state) + const { clicked: oldClicked } = this.plugin.getState(oldState) + if (clicked !== oldClicked) { + return clicked + } + } + + selectionUpdated(view, oldState) { + if (view.composing) { + return false } + const selectionChanged = !oldState?.selection.eq(view.state.selection) + const docChanged = !oldState?.doc.eq(view.state.doc) + return selectionChanged || docChanged - this.updateFromSelection(view) } updateFromSelection = debounce((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 @@ -126,25 +114,18 @@ class LinkBubblePluginView { const shouldShow = !!linkNode && hasEditorFocus - this.updateTooltip(view, shouldShow, linkNode, nodeStart) + this.updateTooltip(view, shouldShow, linkNode?.marks, nodeStart) }, 250) - 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 - }, 500) - this.updateTooltip(this.view, shouldShow, clickedNode, nodeStart) + updateFromClick(view, clicked) { + const marks = clicked?.resolved.marks() + this.updateTooltip(this.view, !!marks, marks, clicked.nodeStart) } - updateTooltip(view, shouldShow, linkNode, nodeStart) { + updateTooltip(view, shouldShow, marks, nodeStart) { this.createTooltip() - if (!shouldShow || !linkNode) { + if (!shouldShow) { this.hide() return } @@ -156,7 +137,7 @@ class LinkBubblePluginView { const clientRect = referenceEl?.getBoundingClientRect() this.#component.updateProps({ - href: domHref(linkNode.marks.find(m => m.type.name === 'link')), + href: domHref(marks.find(m => m.type.name === 'link')), }) this.tippy?.setProps({ @@ -181,7 +162,6 @@ class LinkBubblePluginView { destroy() { this.tippy?.destroy() this.view.dom.removeEventListener('dragstart', this.dragOrScrollHandler) - this.view.dom.removeEventListener('click', this.clickHandler) document.removeEventListener('scroll', this.dragOrScrollHandler, { capture: true }) } diff --git a/src/plugins/links.js b/src/plugins/links.js index 1e28be06145..bf32961f46a 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -24,19 +24,47 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' import LinkBubblePluginView from './LinkBubblePluginView.js' export function linkBubble(pluginKey, options) { - return new Plugin({ + const linkBubblePlugin = new Plugin({ key: new PluginKey(pluginKey), + state: { + init: () => ({ clicked: null }), + apply: (tr, cur) => { + const meta = tr.getMeta(linkClickingKey) + if (meta?.clicked) { + return { clicked: meta.clicked } + } else { + return cur + } + }, + }, view: (view) => new LinkBubblePluginView({ view, options, + plugin: linkBubblePlugin, }), }) + return linkBubblePlugin } +export const linkClickingKey = new PluginKey('textHandleClickLink') export function linkClicking() { return new Plugin({ - key: new PluginKey('textHandleClickLink'), + key: linkClickingKey, props: { + // 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. + handleClickOn: (view, pos, node, nodePos, event, direct) => { + // Only regard left clicks without Ctrl/Meta + if (event.button !== 0 || event.ctrlKey || event.metaKey) { + return false + } + const { dispatch, state } = view + const resolved = state.doc.resolve(pos) + const nodeStart = resolved.pos - resolved.textOffset + const clicked = { pos, resolved, nodePos, event, direct, nodeStart } + dispatch(state.tr.setMeta(linkClickingKey, { clicked })) + }, handleDOMEvents: { // Open link in new tab on middle click auxclick: (view, event) => { From 90b99beb2e2cc922c7df1e6dcd452fcac5d1a086 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 17 Mar 2024 18:52:05 +0100 Subject: [PATCH 05/23] refactor(links): move click handling into link bubble plugin Signed-off-by: Max --- src/plugins/links.js | 27 ++++++++----- src/tests/plugins/linkBubble.spec.js | 57 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 src/tests/plugins/linkBubble.spec.js diff --git a/src/plugins/links.js b/src/plugins/links.js index bf32961f46a..f1fdab5177e 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -29,7 +29,7 @@ export function linkBubble(pluginKey, options) { state: { init: () => ({ clicked: null }), apply: (tr, cur) => { - const meta = tr.getMeta(linkClickingKey) + const meta = tr.getMeta(linkBubblePlugin) if (meta?.clicked) { return { clicked: meta.clicked } } else { @@ -42,29 +42,36 @@ export function linkBubble(pluginKey, options) { options, plugin: linkBubblePlugin, }), - }) - return linkBubblePlugin -} -export const linkClickingKey = new PluginKey('textHandleClickLink') -export function linkClicking() { - return new Plugin({ - key: linkClickingKey, props: { // 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. handleClickOn: (view, pos, node, nodePos, event, direct) => { // Only regard left clicks without Ctrl/Meta - if (event.button !== 0 || event.ctrlKey || event.metaKey) { + if (!direct + || event.button !== 0 + || event.ctrlKey + || event.metaKey) { return false } const { dispatch, state } = view const resolved = state.doc.resolve(pos) const nodeStart = resolved.pos - resolved.textOffset const clicked = { pos, resolved, nodePos, event, direct, nodeStart } - dispatch(state.tr.setMeta(linkClickingKey, { clicked })) + dispatch(state.tr.setMeta(linkBubblePlugin, { clicked })) }, + }, + + }) + return linkBubblePlugin +} + +export const linkClickingKey = new PluginKey('textHandleClickLink') +export function linkClicking() { + return new Plugin({ + key: linkClickingKey, + props: { handleDOMEvents: { // Open link in new tab on middle click auxclick: (view, event) => { diff --git a/src/tests/plugins/linkBubble.spec.js b/src/tests/plugins/linkBubble.spec.js new file mode 100644 index 00000000000..282a138fe59 --- /dev/null +++ b/src/tests/plugins/linkBubble.spec.js @@ -0,0 +1,57 @@ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { linkBubble } from '../../plugins/links.js' +import { Plugin, EditorState } from '@tiptap/pm/state' +import { schema } from '@tiptap/pm/schema-basic' + +describe('linkBubble prosemirror plugin', () => { + + test('signature', () => { + expect(linkBubble).toBeInstanceOf(Function) + expect(new linkBubble('key')).toBeInstanceOf(Plugin) + }) + + test('usage as plugin', () => { + const plugin = new linkBubble('linkBubble') + const state = createState({ plugins: [ plugin ] }) + expect(state.plugins).toContain(plugin) + expect(plugin.getState(state)).toEqual({"clicked": null}) + }) + + test('updates plugin state clicked on transaction', () => { + const plugin = new linkBubble('linkBubble') + const state = createState({ plugins: [ plugin ] }) + const dummy = { was: 'clicked' } + const tr = state.tr.setMeta(plugin, { clicked: dummy }) + const after = state.apply(tr) + expect(plugin.getState(after)).toEqual({"clicked": dummy}) + }) + +}) + +function createState(options = {}) { + return EditorState.create({ + schema, + ...options, + }) +} From 356509b027d56660004a63cfeb25f38cf3d940fb Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 17 Mar 2024 19:27:04 +0100 Subject: [PATCH 06/23] refactor(links): only hand link mark to updateTooltip Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 17 ++++++++--------- src/plugins/links.js | 4 +++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index db8cd261559..5eef0eb81b4 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -69,7 +69,7 @@ class LinkBubblePluginView { } update(view, oldState) { - const clicked = this.linkClicked(view, oldState) + const clicked = this.clickedChanged(view, oldState) if (clicked) { this.updateFromClick(view, clicked) } else if (this.selectionUpdated(view, oldState)) { @@ -77,7 +77,7 @@ class LinkBubblePluginView { } } - linkClicked(view, oldState) { + clickedChanged(view, oldState) { const { clicked } = this.plugin.getState(view.state) const { clicked: oldClicked } = this.plugin.getState(oldState) if (clicked !== oldClicked) { @@ -111,18 +111,17 @@ class LinkBubblePluginView { const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus + const mark = linkNode?.marks.find(m => m.type.name === 'link') + const shouldShow = mark && hasEditorFocus - const shouldShow = !!linkNode && hasEditorFocus - - this.updateTooltip(view, shouldShow, linkNode?.marks, nodeStart) + this.updateTooltip(view, shouldShow, mark, nodeStart) }, 250) updateFromClick(view, clicked) { - const marks = clicked?.resolved.marks() - this.updateTooltip(this.view, !!marks, marks, clicked.nodeStart) + this.updateTooltip(this.view, !!clicked.mark, clicked.mark, clicked.nodeStart) } - updateTooltip(view, shouldShow, marks, nodeStart) { + updateTooltip(view, shouldShow, mark, nodeStart) { this.createTooltip() if (!shouldShow) { @@ -137,7 +136,7 @@ class LinkBubblePluginView { const clientRect = referenceEl?.getBoundingClientRect() this.#component.updateProps({ - href: domHref(marks.find(m => m.type.name === 'link')), + href: domHref(mark), }) this.tippy?.setProps({ diff --git a/src/plugins/links.js b/src/plugins/links.js index f1fdab5177e..4243888c778 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -57,8 +57,10 @@ export function linkBubble(pluginKey, options) { } const { dispatch, state } = view const resolved = state.doc.resolve(pos) + const mark = resolved.marks() + .find(m => m.type.name === 'link') const nodeStart = resolved.pos - resolved.textOffset - const clicked = { pos, resolved, nodePos, event, direct, nodeStart } + const clicked = { mark, nodeStart } dispatch(state.tr.setMeta(linkBubblePlugin, { clicked })) }, }, From e30c78c3398dd249368dabcaaee7fe9e62a33227 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 18 Mar 2024 09:52:14 +0100 Subject: [PATCH 07/23] refactor(links): introduce setActiveLink function Also rename `clicked` state to `active`. We will also use it for activating via keypress Signed-off-by: Max --- src/extensions/LinkBubble.js | 8 +---- src/plugins/LinkBubblePluginView.js | 20 ++++++------ src/plugins/links.js | 41 ++++++++++++++++------- src/tests/plugins/linkBubble.spec.js | 49 +++++++++++++++++++++++----- 4 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js index 1c0e2125850..2c035b18de6 100644 --- a/src/extensions/LinkBubble.js +++ b/src/extensions/LinkBubble.js @@ -4,15 +4,9 @@ import { linkBubble } from '../plugins/links.js' const LinkBubble = Extension.create({ name: 'linkViewBubble', - addOptions() { - return { - pluginKey: 'linkViewBubble', - } - }, - addProseMirrorPlugins() { return [ - linkBubble(this.options.pluginKey, { + linkBubble({ editor: this.editor, parent: this.editor.contentComponent, }), diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 5eef0eb81b4..79716a265e7 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -69,19 +69,19 @@ class LinkBubblePluginView { } update(view, oldState) { - const clicked = this.clickedChanged(view, oldState) - if (clicked) { - this.updateFromClick(view, clicked) + const active = this.activeChanged(view, oldState) + if (active) { + this.updateFromClick(view, active) } else if (this.selectionUpdated(view, oldState)) { this.updateFromSelection(view) } } - clickedChanged(view, oldState) { - const { clicked } = this.plugin.getState(view.state) - const { clicked: oldClicked } = this.plugin.getState(oldState) - if (clicked !== oldClicked) { - return clicked + activeChanged(view, oldState) { + const { active } = this.plugin.getState(view.state) + const { active: oldActive } = this.plugin.getState(oldState) + if (active !== oldActive) { + return active } } @@ -117,8 +117,8 @@ class LinkBubblePluginView { this.updateTooltip(view, shouldShow, mark, nodeStart) }, 250) - updateFromClick(view, clicked) { - this.updateTooltip(this.view, !!clicked.mark, clicked.mark, clicked.nodeStart) + updateFromClick(view, active) { + this.updateTooltip(this.view, !!active.mark, active.mark, active.nodeStart) } updateTooltip(view, shouldShow, mark, nodeStart) { diff --git a/src/plugins/links.js b/src/plugins/links.js index 4243888c778..622c71215f4 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -23,15 +23,36 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' import LinkBubblePluginView from './LinkBubblePluginView.js' -export function linkBubble(pluginKey, options) { +// Commands + +/* Set resolved to be the active element (if it has a link mark) + * + * @params {ResolvedPos} 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 + } + const nodeStart = resolved.pos - resolved.textOffset + const active = { mark, nodeStart } + if (dispatch) { + dispatch(state.tr.setMeta(linkBubbleKey, { active })) + } + return true +} + +export const linkBubbleKey = new PluginKey('linkBubble') +export function linkBubble(options) { const linkBubblePlugin = new Plugin({ - key: new PluginKey(pluginKey), + key: linkBubbleKey, state: { - init: () => ({ clicked: null }), + init: () => ({ active: null }), apply: (tr, cur) => { - const meta = tr.getMeta(linkBubblePlugin) - if (meta?.clicked) { - return { clicked: meta.clicked } + const meta = tr.getMeta(linkBubbleKey) + if (meta?.active) { + return { active: meta.active } } else { return cur } @@ -55,13 +76,9 @@ export function linkBubble(pluginKey, options) { || event.metaKey) { return false } - const { dispatch, state } = view + const { state, dispatch } = view const resolved = state.doc.resolve(pos) - const mark = resolved.marks() - .find(m => m.type.name === 'link') - const nodeStart = resolved.pos - resolved.textOffset - const clicked = { mark, nodeStart } - dispatch(state.tr.setMeta(linkBubblePlugin, { clicked })) + setActiveLink(resolved)(state, dispatch, view) }, }, diff --git a/src/tests/plugins/linkBubble.spec.js b/src/tests/plugins/linkBubble.spec.js index 282a138fe59..d98a2debe0e 100644 --- a/src/tests/plugins/linkBubble.spec.js +++ b/src/tests/plugins/linkBubble.spec.js @@ -20,7 +20,7 @@ * */ -import { linkBubble } from '../../plugins/links.js' +import { linkBubble, setActiveLink } from '../../plugins/links.js' import { Plugin, EditorState } from '@tiptap/pm/state' import { schema } from '@tiptap/pm/schema-basic' @@ -32,23 +32,56 @@ describe('linkBubble prosemirror plugin', () => { }) test('usage as plugin', () => { - const plugin = new linkBubble('linkBubble') + const plugin = new linkBubble() const state = createState({ plugins: [ plugin ] }) expect(state.plugins).toContain(plugin) - expect(plugin.getState(state)).toEqual({"clicked": null}) + expect(plugin.getState(state)).toEqual({"active": null}) }) - test('updates plugin state clicked on transaction', () => { - const plugin = new linkBubble('linkBubble') + test('updates plugin state active on transaction', () => { + const plugin = new linkBubble() const state = createState({ plugins: [ plugin ] }) - const dummy = { was: 'clicked' } - const tr = state.tr.setMeta(plugin, { clicked: dummy }) + const dummy = { was: 'active' } + const tr = state.tr.setMeta(plugin, { active: dummy }) const after = state.apply(tr) - expect(plugin.getState(after)).toEqual({"clicked": dummy}) + expect(plugin.getState(after)).toEqual({"active": dummy}) + }) + + test('setActiveLink requires a link mark', () => { + const noMarks = { marks: () => [] } + expect(setActiveLink(noMarks)(null, null)).toBe(false) + const otherMark = { marks: () => [{type: {name: 'other'}}] } + expect(setActiveLink(otherMark)(null, null)).toBe(false) + const mark = { marks: () => [{type: {name: 'link'}}] } + expect(setActiveLink(mark)(null, null)).toBe(true) + }) + + test('setActiveLink extracts the link mark', () => { + const plugin = new linkBubble() + const state = createState({ plugins: [ plugin ] }) + const flow = createFlow(state) + const mark = { type: { name: 'link' } } + const resolved = { marks: () => [mark] } + setActiveLink(resolved)(flow.state, flow.dispatch) + expect(plugin.getState(flow.state).active.mark) + .toEqual(mark) }) }) +// simulate the data flow in prosemirror +function createFlow(initialState) { + let state = initialState + return { + get state() { + return state + }, + dispatch: tr => { + state = state.apply(tr) + }, + } +} + function createState(options = {}) { return EditorState.create({ schema, From d956fddbef91328543b646205cfb3ab890bd2ef3 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 18 Mar 2024 15:10:50 +0100 Subject: [PATCH 08/23] fix(links): also update if active was unset Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 79716a265e7..43c395c43ea 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -69,19 +69,12 @@ class LinkBubblePluginView { } update(view, oldState) { - const active = this.activeChanged(view, oldState) - if (active) { - this.updateFromClick(view, active) - } else if (this.selectionUpdated(view, oldState)) { - this.updateFromSelection(view) - } - } - - activeChanged(view, oldState) { const { active } = this.plugin.getState(view.state) const { active: oldActive } = this.plugin.getState(oldState) if (active !== oldActive) { - return active + this.updateTooltip(view, !!active.mark, active.mark, active.nodeStart) + } else if (this.selectionUpdated(view, oldState)) { + this.updateFromSelection(view) } } @@ -117,10 +110,6 @@ class LinkBubblePluginView { this.updateTooltip(view, shouldShow, mark, nodeStart) }, 250) - updateFromClick(view, active) { - this.updateTooltip(this.view, !!active.mark, active.mark, active.nodeStart) - } - updateTooltip(view, shouldShow, mark, nodeStart) { this.createTooltip() From e02c5ad7945f9aaed6380b4eab0d556fd1ce7c6a Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 18 Mar 2024 16:04:13 +0100 Subject: [PATCH 09/23] refactor(links): operate on state with `linkNodeFromSelection` In appendTransation callbacks there is no view available. Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 43c395c43ea..aa32b154478 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -100,7 +100,7 @@ class LinkBubblePluginView { const resolved = view.state.doc.resolve(from) const nodeStart = resolved.pos - resolved.textOffset - const linkNode = this.linkNodeFromSelection(view) + const linkNode = this.linkNodeFromSelection(state) const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus @@ -153,16 +153,13 @@ class LinkBubblePluginView { document.removeEventListener('scroll', this.dragOrScrollHandler, { capture: true }) } - linkNodeFromSelection(view) { - const { state } = view - const { selection } = state - + linkNodeFromSelection({ selection, doc }) { // 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 resolved = doc.resolve(from) // ignore links in previews if (resolved.parent.type.name === 'preview') { From c1e4bd8da7a6d7a296ce5a97a2d4da74753f67a8 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 18 Mar 2024 16:08:43 +0100 Subject: [PATCH 10/23] refactor(links): move linkNodeFromSelection into helper Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 45 +--------------------- src/plugins/linkHelpers.js | 58 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 43 deletions(-) create mode 100644 src/plugins/linkHelpers.js diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index aa32b154478..33226d11d9f 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -3,6 +3,7 @@ import tippy from 'tippy.js' import debounce from 'debounce' import { domHref } from '../helpers/links.js' import LinkBubbleView from '../components/Link/LinkBubbleView.vue' +import { linkNodeFromSelection } from './linkHelpers.js' import { getViewerVue } from '../ViewerVue.js' @@ -85,12 +86,9 @@ class LinkBubblePluginView { const selectionChanged = !oldState?.selection.eq(view.state.selection) const docChanged = !oldState?.doc.eq(view.state.doc) return selectionChanged || docChanged - } updateFromSelection = debounce((view) => { - // Don't update directly after updateFromClick. Prevents race condition in read-only documents in Chrome. - const { state } = view const { selection } = state @@ -100,7 +98,7 @@ class LinkBubblePluginView { const resolved = view.state.doc.resolve(from) const nodeStart = resolved.pos - resolved.textOffset - const linkNode = this.linkNodeFromSelection(state) + const linkNode = linkNodeFromSelection(state) const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus @@ -153,45 +151,6 @@ class LinkBubblePluginView { document.removeEventListener('scroll', this.dragOrScrollHandler, { capture: true }) } - linkNodeFromSelection({ selection, doc }) { - // 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 = doc.resolve(from) - - // ignore links in previews - if (resolved.parent.type.name === 'preview') { - return false - } - - 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/plugins/linkHelpers.js b/src/plugins/linkHelpers.js new file mode 100644 index 00000000000..ef20c997bfa --- /dev/null +++ b/src/plugins/linkHelpers.js @@ -0,0 +1,58 @@ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +export function linkNodeFromSelection({ selection, doc }) { + // 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 = doc.resolve(from) + + // ignore links in previews + if (resolved.parent.type.name === 'preview') { + return false + } + + 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 isLinkNode(node) ? node : null +} + +function 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 +} From 96097aace49c7b3b53a2a9bb2843c1e54cb8e1ad Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 18 Mar 2024 16:49:36 +0100 Subject: [PATCH 11/23] refactor(links): handle selection changes in plugin Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 40 +++++------------------------ src/plugins/links.js | 25 ++++++++++++++++-- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 33226d11d9f..0715ecf0bcc 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -1,9 +1,7 @@ import { VueRenderer } from '@tiptap/vue-2' import tippy from 'tippy.js' -import debounce from 'debounce' import { domHref } from '../helpers/links.js' import LinkBubbleView from '../components/Link/LinkBubbleView.vue' -import { linkNodeFromSelection } from './linkHelpers.js' import { getViewerVue } from '../ViewerVue.js' @@ -72,41 +70,17 @@ class LinkBubblePluginView { update(view, oldState) { const { active } = this.plugin.getState(view.state) const { active: oldActive } = this.plugin.getState(oldState) - if (active !== oldActive) { - this.updateTooltip(view, !!active.mark, active.mark, active.nodeStart) - } else if (this.selectionUpdated(view, oldState)) { - this.updateFromSelection(view) + if (view.composing && !active.clicked) { + return } - } - - selectionUpdated(view, oldState) { - if (view.composing) { - return false + if (active === oldActive) { + return } - const selectionChanged = !oldState?.selection.eq(view.state.selection) - const docChanged = !oldState?.doc.eq(view.state.doc) - return selectionChanged || docChanged - } - - updateFromSelection = debounce((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 = linkNodeFromSelection(state) - const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus - const mark = linkNode?.marks.find(m => m.type.name === 'link') - const shouldShow = mark && hasEditorFocus - - this.updateTooltip(view, shouldShow, mark, nodeStart) - }, 250) + const shouldShow = active.mark && (active.clicked || hasEditorFocus) + this.updateTooltip(view, shouldShow, active.mark, active.nodeStart) + } updateTooltip(view, shouldShow, mark, nodeStart) { this.createTooltip() diff --git a/src/plugins/links.js b/src/plugins/links.js index 622c71215f4..99a3ac3d958 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -22,6 +22,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state' import LinkBubblePluginView from './LinkBubblePluginView.js' +import { linkNodeFromSelection } from './linkHelpers.js' // Commands @@ -36,7 +37,7 @@ export const setActiveLink = (resolved) => (state, dispatch) => { return false } const nodeStart = resolved.pos - resolved.textOffset - const active = { mark, nodeStart } + const active = { mark, nodeStart, clicked: true } if (dispatch) { dispatch(state.tr.setMeta(linkBubbleKey, { active })) } @@ -51,19 +52,38 @@ export function linkBubble(options) { init: () => ({ active: null }), apply: (tr, cur) => { const meta = tr.getMeta(linkBubbleKey) - if (meta?.active) { + if (meta && meta.active !== cur.active) { return { active: meta.active } } else { return cur } }, }, + view: (view) => new LinkBubblePluginView({ view, options, plugin: linkBubblePlugin, }), + appendTransaction: (transactions, oldState, state) => { + const sameSelection = oldState?.selection.eq(state.selection) + const sameDoc = oldState?.doc.eq(state.doc) + if (sameSelection && sameDoc) { + return + } + const { selection } = state + // support for CellSelections + const { ranges } = selection + const from = Math.min(...ranges.map(range => range.$from.pos)) + const resolved = state.doc.resolve(from) + const nodeStart = resolved.pos - resolved.textOffset + const linkNode = linkNodeFromSelection(state) + const mark = linkNode?.marks.find(m => m.type.name === 'link') + const active = { mark, nodeStart } + return state.tr.setMeta(linkBubbleKey, { active }) + }, + props: { // Required for read-only mode on Firefox. // For some reason, editor selection doesn't get updated @@ -80,6 +100,7 @@ export function linkBubble(options) { const resolved = state.doc.resolve(pos) setActiveLink(resolved)(state, dispatch, view) }, + }, }) From 4a3165b91bb9b829fe32fd696e31e98a33af01ec Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 Mar 2024 08:17:48 +0100 Subject: [PATCH 12/23] refactor(links): use hideLinkBubble command for esc Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 9 --------- src/plugins/links.js | 25 +++++++++++++++++++++++-- src/tests/plugins/linkBubble.spec.js | 22 ++++++++++++++++++++-- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 0715ecf0bcc..172786c3ce5 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -38,13 +38,6 @@ class LinkBubblePluginView { this.hide() } - keydownHandler = (event) => { - if (event.key === 'Escape') { - event.preventDefault() - this.hide() - } - } - createTooltip() { const editorElement = this.options.editor.options.element const editorIsAttached = !!editorElement.parentElement @@ -109,11 +102,9 @@ class LinkBubblePluginView { show() { this.tippy?.show() - this.view.dom.addEventListener('keydown', this.keydownHandler) } hide() { - this.view.dom.removeEventListener('keydown', this.keydownHandler) setTimeout(() => { this.tippy?.hide() }, 100) diff --git a/src/plugins/links.js b/src/plugins/links.js index 99a3ac3d958..c21b99265ab 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -44,6 +44,20 @@ export const setActiveLink = (resolved) => (state, dispatch) => { return true } +/* Hide the link bubble by setting active state to null + * + */ +export const hideLinkBubble = (state, dispatch) => { + const pluginState = linkBubbleKey.getState(state) + if (!pluginState?.active) { + return false + } + if (dispatch) { + dispatch(state.tr.setMeta(linkBubbleKey, { active: null })) + } + return true +} + export const linkBubbleKey = new PluginKey('linkBubble') export function linkBubble(options) { const linkBubblePlugin = new Plugin({ @@ -88,7 +102,7 @@ export function linkBubble(options) { // 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. - handleClickOn: (view, pos, node, nodePos, event, direct) => { + handleClickOn: (view, pos, _node, _nodePos, event, direct) => { // Only regard left clicks without Ctrl/Meta if (!direct || event.button !== 0 @@ -98,7 +112,14 @@ export function linkBubble(options) { } const { state, dispatch } = view const resolved = state.doc.resolve(pos) - setActiveLink(resolved)(state, dispatch, view) + return setActiveLink(resolved)(state, dispatch) + }, + + handleKeyDown: (view, event) => { + const { state, dispatch } = view + if (event.key === 'Escape') { + return hideLinkBubble(state, dispatch) + } }, }, diff --git a/src/tests/plugins/linkBubble.spec.js b/src/tests/plugins/linkBubble.spec.js index d98a2debe0e..2d8b2007b3b 100644 --- a/src/tests/plugins/linkBubble.spec.js +++ b/src/tests/plugins/linkBubble.spec.js @@ -20,7 +20,7 @@ * */ -import { linkBubble, setActiveLink } from '../../plugins/links.js' +import { linkBubble, setActiveLink, hideLinkBubble } from '../../plugins/links.js' import { Plugin, EditorState } from '@tiptap/pm/state' import { schema } from '@tiptap/pm/schema-basic' @@ -28,7 +28,7 @@ describe('linkBubble prosemirror plugin', () => { test('signature', () => { expect(linkBubble).toBeInstanceOf(Function) - expect(new linkBubble('key')).toBeInstanceOf(Plugin) + expect(new linkBubble()).toBeInstanceOf(Plugin) }) test('usage as plugin', () => { @@ -67,6 +67,24 @@ describe('linkBubble prosemirror plugin', () => { .toEqual(mark) }) + test('hideLinkBubble requires an active menu bubble', () => { + const plugin = new linkBubble() + const state = createState({ plugins: [ plugin ] }) + expect(hideLinkBubble(state, null)).toBe(false) + }) + + test('hideLinkBubble clears the active state', () => { + const plugin = new linkBubble() + const state = createState({ plugins: [ plugin ] }) + const flow = createFlow(state) + const mark = { type: { name: 'link' } } + const resolved = { marks: () => [mark] } + setActiveLink(resolved)(flow.state, flow.dispatch) + hideLinkBubble(flow.state, flow.dispatch) + expect(plugin.getState(flow.state).active) + .toEqual(null) + }) + }) // simulate the data flow in prosemirror From ed923f4cea0d2df4b308f6be8dd7ec71cef11d93 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 Mar 2024 12:11:03 +0100 Subject: [PATCH 13/23] fix(links): simplify updateTooltip and handle active null Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 13 +++++-------- src/plugins/links.js | 10 +++++++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 172786c3ce5..7123a5bae3b 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -71,18 +71,15 @@ class LinkBubblePluginView { } const hasBubbleFocus = this.#component.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || hasBubbleFocus - const shouldShow = active.mark && (active.clicked || hasEditorFocus) - this.updateTooltip(view, shouldShow, active.mark, active.nodeStart) - } - - updateTooltip(view, shouldShow, mark, nodeStart) { this.createTooltip() - - if (!shouldShow) { + if (active?.mark && (active.clicked || hasEditorFocus)) { + this.updateTooltip(view, active) + } else { this.hide() - return } + } + updateTooltip(view, { mark, nodeStart }) { let referenceEl = view.nodeDOM(nodeStart) if (Object.prototype.toString.call(referenceEl) === '[object Text]') { referenceEl = referenceEl.parentElement diff --git a/src/plugins/links.js b/src/plugins/links.js index c21b99265ab..c37df449c79 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -67,7 +67,15 @@ export function linkBubble(options) { apply: (tr, cur) => { const meta = tr.getMeta(linkBubbleKey) if (meta && meta.active !== cur.active) { - return { active: meta.active } + if (!cur.active || !meta.active) { + return { ...cur, active: meta.active } + } + // keep clicked as long as the node stays the same + const sameNode = cur.active.nodeStart == meta.active.nodeStart + const clicked = meta.active.clicked + || ( cur.active.clicked && sameNode ) + const active = { ...meta.active, clicked } + return { ...cur, active } } else { return cur } From 4bc7586fbd4f822f8225d93119575aa04032963d Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 Mar 2024 12:21:58 +0100 Subject: [PATCH 14/23] fix(links): remove special handling for clicked and focus Signed-off-by: Max --- src/plugins/LinkBubblePluginView.js | 6 ++---- src/plugins/links.js | 12 ++---------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 7123a5bae3b..55c23e5e425 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -63,16 +63,14 @@ class LinkBubblePluginView { update(view, oldState) { const { active } = this.plugin.getState(view.state) const { active: oldActive } = this.plugin.getState(oldState) - if (view.composing && !active.clicked) { + if (view.composing) { return } if (active === oldActive) { return } - const hasBubbleFocus = this.#component.element.contains(document.activeElement) - const hasEditorFocus = view.hasFocus() || hasBubbleFocus this.createTooltip() - if (active?.mark && (active.clicked || hasEditorFocus)) { + if (active?.mark) { this.updateTooltip(view, active) } else { this.hide() diff --git a/src/plugins/links.js b/src/plugins/links.js index c37df449c79..9c09f0ec3dc 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -37,7 +37,7 @@ export const setActiveLink = (resolved) => (state, dispatch) => { return false } const nodeStart = resolved.pos - resolved.textOffset - const active = { mark, nodeStart, clicked: true } + const active = { mark, nodeStart } if (dispatch) { dispatch(state.tr.setMeta(linkBubbleKey, { active })) } @@ -67,15 +67,7 @@ export function linkBubble(options) { apply: (tr, cur) => { const meta = tr.getMeta(linkBubbleKey) if (meta && meta.active !== cur.active) { - if (!cur.active || !meta.active) { - return { ...cur, active: meta.active } - } - // keep clicked as long as the node stays the same - const sameNode = cur.active.nodeStart == meta.active.nodeStart - const clicked = meta.active.clicked - || ( cur.active.clicked && sameNode ) - const active = { ...meta.active, clicked } - return { ...cur, active } + return { ...cur, active: meta.active } } else { return cur } From 7fe10f9a30959607e9f53979e6c4a70f62c53f8c Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 Mar 2024 12:31:45 +0100 Subject: [PATCH 15/23] fix(links): handle esc in DOMEvents rather than prop The `handleKeyDown` prop does not work in read only mode. Signed-off-by: Max --- src/plugins/links.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/plugins/links.js b/src/plugins/links.js index 9c09f0ec3dc..e0993dda657 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -115,11 +115,14 @@ export function linkBubble(options) { return setActiveLink(resolved)(state, dispatch) }, - handleKeyDown: (view, event) => { - const { state, dispatch } = view - if (event.key === 'Escape') { - return hideLinkBubble(state, dispatch) - } + handleDOMEvents: { + // Handled here because `handleKeyDown` does not work in read only editor. + keydown: (view, event) => { + const { state, dispatch } = view + if (event.key === 'Escape') { + return hideLinkBubble(state, dispatch) + } + }, }, }, From d79b67ca8326be47478c2f95834fbab1ebb69e6b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 Mar 2024 13:04:42 +0100 Subject: [PATCH 16/23] fix(links) handle linkNodeFromSelection returning false Signed-off-by: Max --- src/plugins/links.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/links.js b/src/plugins/links.js index e0993dda657..053bc3114d1 100644 --- a/src/plugins/links.js +++ b/src/plugins/links.js @@ -93,7 +93,10 @@ export function linkBubble(options) { const resolved = state.doc.resolve(from) const nodeStart = resolved.pos - resolved.textOffset const linkNode = linkNodeFromSelection(state) - const mark = linkNode?.marks.find(m => m.type.name === 'link') + if (!linkNode) { + return state.tr.setMeta(linkBubbleKey, { active: null }) + } + const mark = linkNode.marks.find(m => m.type.name === 'link') const active = { mark, nodeStart } return state.tr.setMeta(linkBubbleKey, { active }) }, From 4f5277e073186fefbcc2c761cbdceb5431107707 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 19 Mar 2024 13:05:09 +0100 Subject: [PATCH 17/23] enh(links): close link bubble when opening preview toggle Signed-off-by: Max --- src/components/Editor/PreviewOptions.vue | 3 ++- src/extensions/LinkBubble.js | 10 +++++++++- src/nodes/ParagraphView.vue | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/Editor/PreviewOptions.vue b/src/components/Editor/PreviewOptions.vue index 7242b4f3ba0..5cbfeb3b041 100644 --- a/src/components/Editor/PreviewOptions.vue +++ b/src/components/Editor/PreviewOptions.vue @@ -20,7 +20,8 @@ - -->