diff --git a/blocks/edit/prose/image-utils.js b/blocks/edit/prose/image-utils.js new file mode 100644 index 000000000..c8a3da2cd --- /dev/null +++ b/blocks/edit/prose/image-utils.js @@ -0,0 +1,23 @@ +/** + * Rewrites an image src from an AEM preview/publish host to the DA preview host. + * + * Images stored in DA content may reference *.aem.page (preview) or *.aem.live + * (publish) URLs. These hosts require AEM authentication that the DA editor does + * not carry, causing 401 errors when the browser fetches them. The equivalent + * *.preview.da.live URL serves the same content and is accessible from the editor. + * + * @param {string} src - The original image src URL. + * @returns {string} The rewritten src, or the original if no rewrite was needed. + */ +export function rewriteImageSrcForEditor(src) { + try { + const url = new URL(src); + if (url.host.endsWith('.aem.page') || url.host.endsWith('.aem.live')) { + url.host = url.host.replace(/\.aem\.(page|live)$/, '.preview.da.live'); + return url.toString(); + } + } catch { + // relative or malformed src — leave unchanged + } + return src; +} diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index ef2b74199..466bdf12d 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -27,6 +27,7 @@ import { COLLAB_ORIGIN, DA_ORIGIN } from '../../shared/constants.js'; import { daFetch, getAuthToken } from '../../shared/utils.js'; import { getDiffClass, checkForLocNodes, addActiveView } from './diff/diff-utils.js'; import { debounce, initDaMetadata } from '../utils/helpers.js'; +import { rewriteImageSrcForEditor } from './image-utils.js'; async function checkDoc(path) { return daFetch(path, { method: 'HEAD' }); @@ -469,6 +470,20 @@ export default async function initProse({ path, permissions, doc, daContent, wsP state, dispatchTransaction, nodeViews: { + image(node) { + const img = document.createElement('img'); + img.src = rewriteImageSrcForEditor(node.attrs.src); + if (node.attrs.alt) img.alt = node.attrs.alt; + return { + dom: img, + update(updated) { + if (updated.type.name !== 'image') return false; + img.src = rewriteImageSrcForEditor(updated.attrs.src); + if (updated.attrs.alt) img.alt = updated.attrs.alt; + return true; + }, + }; + }, diff_added(node, view, getPos) { const LocAddedView = getDiffClass('da-diff-added', getSchema, dispatchTransaction, { isUpstream: false }); return new LocAddedView(node, view, getPos); diff --git a/blocks/edit/prose/plugins/imageFocalPoint.js b/blocks/edit/prose/plugins/imageFocalPoint.js index b7c046263..547a2d5a4 100644 --- a/blocks/edit/prose/plugins/imageFocalPoint.js +++ b/blocks/edit/prose/plugins/imageFocalPoint.js @@ -1,4 +1,5 @@ import { Plugin, PluginKey } from 'da-y-wrapper'; +import { rewriteImageSrcForEditor } from '../image-utils.js'; import inlinesvg from '../../../shared/inlinesvg.js'; import { openFocalPointDialog } from './focalPointDialog.js'; import { loadLibrary } from '../../da-library/helpers/helpers.js'; @@ -40,7 +41,7 @@ function shouldShowFocalPoint(tableName, blocks) { } function updateImageAttributes(img, attrs) { - img.src = attrs.src; + img.src = rewriteImageSrcForEditor(attrs.src); ['alt', 'title', 'width', 'height'].forEach((attr) => { if (attrs[attr]) { img[attr] = attrs[attr]; @@ -150,7 +151,16 @@ export default function imageFocalPoint() { if (isInTableCell(view.state, getPos())) { return new ImageWithFocalPointView(node, view, getPos); } - return null; + const img = document.createElement('img'); + updateImageAttributes(img, node.attrs); + return { + dom: img, + update(updated) { + if (updated.type.name !== 'image') return false; + updateImageAttributes(img, updated.attrs); + return true; + }, + }; }, }, }, diff --git a/test/unit/blocks/edit/prose/image-utils.test.js b/test/unit/blocks/edit/prose/image-utils.test.js new file mode 100644 index 000000000..ad7054190 --- /dev/null +++ b/test/unit/blocks/edit/prose/image-utils.test.js @@ -0,0 +1,37 @@ +import { expect } from '@esm-bundle/chai'; +import { rewriteImageSrcForEditor } from '../../../../../blocks/edit/prose/image-utils.js'; + +describe('rewriteImageSrcForEditor', () => { + it('rewrites aem.page host to preview.da.live', () => { + expect(rewriteImageSrcForEditor('https://main--site--org.aem.page/img.jpg')) + .to.equal('https://main--site--org.preview.da.live/img.jpg'); + }); + + it('rewrites aem.live host to preview.da.live', () => { + expect(rewriteImageSrcForEditor('https://main--site--org.aem.live/img.jpg')) + .to.equal('https://main--site--org.preview.da.live/img.jpg'); + }); + + it('preserves the path and query string when rewriting', () => { + expect(rewriteImageSrcForEditor('https://main--site--org.aem.page/media_abc.jpg?width=2000&format=webply')) + .to.equal('https://main--site--org.preview.da.live/media_abc.jpg?width=2000&format=webply'); + }); + + it('leaves unrelated URLs unchanged', () => { + expect(rewriteImageSrcForEditor('https://example.com/img.jpg')) + .to.equal('https://example.com/img.jpg'); + }); + + it('leaves content.da.live URLs unchanged', () => { + expect(rewriteImageSrcForEditor('https://content.da.live/org/repo/img.jpg')) + .to.equal('https://content.da.live/org/repo/img.jpg'); + }); + + it('leaves relative URLs unchanged', () => { + expect(rewriteImageSrcForEditor('/img/photo.jpg')).to.equal('/img/photo.jpg'); + }); + + it('leaves malformed URLs unchanged', () => { + expect(rewriteImageSrcForEditor('not a url')).to.equal('not a url'); + }); +}); diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index abecf61e4..dcf5f95b1 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -311,6 +311,32 @@ describe('prose/index initProse default export', () => { } }); + it('Image nodeView rewrites aem.page and aem.live srcs to preview.da.live', async () => { + const ydoc = new Y.Doc(); + const provider = buildFakeWsProvider({ withSynced: false }); + const wsPromise = Promise.resolve({ wsProvider: provider, ydoc }); + Object.defineProperty(fakeContent, 'proseEl', { + configurable: true, + set(v) { + v.getRootNode = () => ({ host: document.createElement('div') }); + this._proseEl = v; + }, + get() { return this._proseEl; }, + }); + await initProse({ path: 'https://admin.da.live/source/o/r/p.html', permissions: ['read', 'write'], doc: null, daContent: fakeContent, wsPromise }); + + const { image: imageNodeView } = window.view.props.nodeViews; + + const aemPage = imageNodeView({ attrs: { src: 'https://main--site--org.aem.page/img.jpg' } }); + expect(aemPage.dom.src).to.equal('https://main--site--org.preview.da.live/img.jpg'); + + const aemLive = imageNodeView({ attrs: { src: 'https://main--site--org.aem.live/img.jpg' } }); + expect(aemLive.dom.src).to.equal('https://main--site--org.preview.da.live/img.jpg'); + + const other = imageNodeView({ attrs: { src: 'https://example.com/img.jpg' } }); + expect(other.dom.src).to.equal('https://example.com/img.jpg'); + }); + it('Destroys an existing window.view before creating a new one', async () => { let destroyed = 0; window.view = { destroy: () => { destroyed += 1; } }; diff --git a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js index a72d5af4d..29281628e 100644 --- a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js +++ b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js @@ -128,7 +128,35 @@ describe('imageFocalPoint Plugin', () => { expect(icon.classList.contains('focal-point-icon-active')).to.be.true; }); - it('does not create node view for images outside table cells', () => { + it('rewrites aem.page and aem.live srcs to preview.da.live for table cell images', () => { + const plugin = imageFocalPoint(); + const createNodeView = plugin.props.nodeViews.image; + + const mockState = { + doc: { + resolve: () => ({ + depth: 3, + node: (d) => { + if (d === 3) return { type: { name: 'table_cell' } }; + if (d === 2) return { childCount: 1 }; + if (d === 1) return { child: () => ({ child: () => ({ textContent: 'hero' }) }) }; + return { type: { name: 'doc' } }; + }, + index: () => 0, + }), + }, + }; + const mockView = { state: mockState, dom: document.createElement('div') }; + const getPos = () => 10; + + const nodeView = createNodeView({ type: { name: 'image' }, attrs: { src: 'https://main--site--org.aem.page/img.jpg' } }, mockView, getPos); + expect(nodeView.dom.querySelector('img').src).to.equal('https://main--site--org.preview.da.live/img.jpg'); + + const nodeView2 = createNodeView({ type: { name: 'image' }, attrs: { src: 'https://main--site--org.aem.live/img.jpg' } }, mockView, getPos); + expect(nodeView2.dom.querySelector('img').src).to.equal('https://main--site--org.preview.da.live/img.jpg'); + }); + + it('creates a plain image node view for images outside table cells', () => { const plugin = imageFocalPoint(); const createNodeView = plugin.props.nodeViews.image; @@ -144,8 +172,9 @@ describe('imageFocalPoint Plugin', () => { const mockView = { state: mockState, dom: document.createElement('div') }; const getPos = () => 10; - const nodeView = createNodeView({}, mockView, getPos); - expect(nodeView).to.be.null; + const nodeView = createNodeView({ attrs: { src: 'https://example.com/img.jpg' } }, mockView, getPos); + expect(nodeView).to.not.be.null; + expect(nodeView.dom.tagName).to.equal('IMG'); }); it('updates node view correctly', async () => {