From dd4191bc9accdc0a7d6d3a073b194b80b3b96f28 Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Thu, 6 Nov 2025 16:49:28 +0100 Subject: [PATCH 1/2] Allow adding links to images in rich text editor --- .../editor/extensions/decidim_kit/index.js | 2 + .../decidim/editor/extensions/link/index.js | 39 ++++++++++++++++++- packages/core/package.json | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/decidim-core/app/packs/src/decidim/editor/extensions/decidim_kit/index.js b/decidim-core/app/packs/src/decidim/editor/extensions/decidim_kit/index.js index 04228ee11bbef..8f119b54bd9dd 100644 --- a/decidim-core/app/packs/src/decidim/editor/extensions/decidim_kit/index.js +++ b/decidim-core/app/packs/src/decidim/editor/extensions/decidim_kit/index.js @@ -16,6 +16,7 @@ import Mention from "src/decidim/editor/extensions/mention"; import MentionResource from "src/decidim/editor/extensions/mention_resource"; import VideoEmbed from "src/decidim/editor/extensions/video_embed"; import Emoji from "src/decidim/editor/extensions/emoji"; +import ImageLink from "tiptap-extension-image-link"; export default Extension.create({ name: "decidimKit", @@ -47,6 +48,7 @@ export default Extension.create({ }), CharacterCount.configure(this.options.characterCount), Link.configure({ openOnClick: false, ...this.options.link }), + ImageLink, Bold, Dialog, Indent, diff --git a/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js b/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js index 6503b4c0dffa1..f9149e3b70c55 100644 --- a/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js +++ b/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js @@ -55,17 +55,34 @@ export default Link.extend({ linkDialog: () => async ({ dispatch, commands }) => { if (dispatch) { + // Check if the selection is an image + const isImage = this.editor.isActive("image"); + // If the cursor is within the link but the link is not selected, the // link would not be correctly updated. Also if only a part of the // link is selected, the link would be split to separate links, only // the current selection getting the updated link URL. - commands.extendMarkRange("link"); + if (!isImage) { + commands.extendMarkRange("link"); + } this.storage.bubbleMenu.hide(); const { allowTargetControl } = this.options; let { href, target } = this.editor.getAttributes("link"); + let src = null; + + // If it's an image, get the src attribute + if (isImage) { + const imageAttrs = this.editor.getAttributes("image"); + src = imageAttrs.src; + // Check if the image is already wrapped in an imageLink + const imageLinkAttrs = this.editor.getAttributes("imageLink"); + if (imageLinkAttrs.href) { + href = imageLinkAttrs.href; + } + } const inputs = { href: { type: "text", label: i18n.hrefLabel } }; if (allowTargetControl) { @@ -95,9 +112,29 @@ export default Link.extend({ } if (!href || href.trim().length < 1) { + if (isImage) { + // For images, we don't unset anything if there's no href + return this.editor.chain().focus(null, { scrollIntoView: false }).run(); + } return this.editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run(); } + // If it's an image, use setImageLink command + if (isImage) { + this.editor.chain() + .focus(null, { scrollIntoView: false }) + .setImageLink({ + href, + src, + HTMLAttributes: { + target: target || "_blank" + } + }) + .run(); + + return true; + } + return this.editor.chain().focus(null, { scrollIntoView: false }).setLink({ href, target }).toggleLinkBubble().run(); } diff --git a/packages/core/package.json b/packages/core/package.json index daf073a851de7..aed06fa6f7fba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,7 @@ "@tiptap/pm": "2.1.13", "@tiptap/starter-kit": "2.1.13", "@tiptap/suggestion": "2.1.13", + "tiptap-extension-image-link": "^1.0.0", "a11y-accordion-component": "^1.2.6", "a11y-dialog-component": "^5.5.1", "a11y-dropdown-component": "^1.2.0", From 98415dddb329215f81603472d267ca901e858b8f Mon Sep 17 00:00:00 2001 From: Eduardo Martinez Echevarria Date: Thu, 6 Nov 2025 16:52:10 +0100 Subject: [PATCH 2/2] Preserve the image width after adding a link --- .../editor/extensions/image/node_view.js | 37 +++++++++++++------ .../decidim/editor/extensions/link/index.js | 34 ++++++++++++++++- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/decidim-core/app/packs/src/decidim/editor/extensions/image/node_view.js b/decidim-core/app/packs/src/decidim/editor/extensions/image/node_view.js index 72e0d3f7d6e7d..1ca53b599eae6 100644 --- a/decidim-core/app/packs/src/decidim/editor/extensions/image/node_view.js +++ b/decidim-core/app/packs/src/decidim/editor/extensions/image/node_view.js @@ -77,26 +77,33 @@ export default (self) => { naturalWidth = tmpImg.naturalWidth; naturalHeight = tmpImg.naturalHeight; + // Get the current width from the node (it might have been updated) + const currentNode = editor.view.state.doc.nodeAt(getPos()); + const currentNodeWidth = currentNode ? currentNode.attrs.width : givenWidth; + // Set currentWidth and currentHeight - if (givenWidth === null) { + if (currentNodeWidth === null) { currentWidth = naturalWidth; currentHeight = naturalHeight; } else { - currentWidth = givenWidth; + currentWidth = currentNodeWidth; currentHeight = Math.round(naturalHeight * (currentWidth / naturalWidth)); } // Force node update in order to set the initial dimensions - [{ ...node.attrs, width: 1 }, node.attrs].forEach((newAttrs) => { - // The `setTimeout` below is to push the node updates to the next JS - // event loop so that we are not triggering a change in the element - // before it is created as would happen e.g. during the Jest tests. - setTimeout(() => { - editor.view.dispatch( - editor.view.state.tr.setNodeMarkup(getPos(), self.type, newAttrs) - ); - }, 0); - }); + // Only do this if the node wasn't already updated with a specific width + if (currentNodeWidth === givenWidth) { + [{ ...node.attrs, width: 1 }, node.attrs].forEach((newAttrs) => { + // The `setTimeout` below is to push the node updates to the next JS + // event loop so that we are not triggering a change in the element + // before it is created as would happen e.g. during the Jest tests. + setTimeout(() => { + editor.view.dispatch( + editor.view.state.tr.setNodeMarkup(getPos(), self.type, newAttrs) + ); + }, 0); + }); + } } tmpImg.src = img.src; @@ -187,6 +194,12 @@ export default (self) => { const { alt, src, title, width } = updatedNode.attrs; + // Update currentWidth and currentHeight if width has changed + if (width !== null && width !== undefined && width !== currentWidth) { + currentWidth = width; + currentHeight = Math.round(naturalHeight * (currentWidth / naturalWidth)); + } + // We set the value through an attribute change here because otherwise // we would trigger a mutation in the DOM which causes the update method // to be called recursively. diff --git a/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js b/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js index f9149e3b70c55..39dcc9b68f5e3 100644 --- a/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js +++ b/decidim-core/app/packs/src/decidim/editor/extensions/link/index.js @@ -36,6 +36,20 @@ export default Link.extend({ addCommands() { const i18n = getDictionary("editor.extensions.link"); + const findNodeByAttribute = (doc, nodeType, attrName, attrValue) => { + let foundNode = null; + let foundPos = null; + + doc.descendants((node, pos) => { + if (node.type.name === nodeType && node.attrs[attrName] === attrValue) { + foundNode = node; + foundPos = pos; + return false; // stop searching + } + }); + + return { node: foundNode, pos: foundPos }; + }; return { ...this.parent?.(), @@ -72,11 +86,13 @@ export default Link.extend({ let { href, target } = this.editor.getAttributes("link"); let src = null; + let originalWidth = null; - // If it's an image, get the src attribute + // If it's an image, get the src attribute and preserve the width if (isImage) { const imageAttrs = this.editor.getAttributes("image"); src = imageAttrs.src; + originalWidth = imageAttrs.width; // Check if the image is already wrapped in an imageLink const imageLinkAttrs = this.editor.getAttributes("imageLink"); if (imageLinkAttrs.href) { @@ -121,6 +137,7 @@ export default Link.extend({ // If it's an image, use setImageLink command if (isImage) { + // First apply the image link this.editor.chain() .focus(null, { scrollIntoView: false }) .setImageLink({ @@ -132,6 +149,21 @@ export default Link.extend({ }) .run(); + // After setImageLink, find the image node by its src and update the width + // Small delay to ensure the node is fully created after setImageLink + setTimeout(() => { + const { state, view } = this.editor; + const { node: imageNode, pos: imagePos } = findNodeByAttribute(state.doc, "image", "src", src); + + if (imageNode && imagePos !== null) { + const tr = state.tr.setNodeMarkup(imagePos, null, { + ...imageNode.attrs, + width: originalWidth + }); + view.dispatch(tr); + } + }, 10); + return true; }