Here is the process...
<script type="importmap">
{
"imports": {
"@assets/selectors": "https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/@assets/selectors/index.js",
"react": "https://esm.sh/react@18.2.0",
"react-dom": "https://esm.sh/react-dom@18.2.0"
}
}
</script>
4) Add the following Routes in eaem-ue-rte-dm-openapi-picker\src\universal-editor-ui-1\web-src\src\components\App.js to show Panel / Asset Selector (click of ImageSearch button)
<Route exact={true} path="open-asset-picker-modal" element={<EaemAssetPickerModal />} />
<Route exact={true} path="eaem-rte-image-picker" element={<EaemRTEImagePicker />} />
5) Add the following code in eaem-ue-rte-dm-openapi-picker\src\universal-editor-ui-1\web-src\src\components\ExtensionRegistration.js to add a renderer for dataType eaem:rte-dm-open-api-images (which will be used in the block model later) and listen to update events...
import { Text } from "@adobe/react-spectrum";
import { register } from "@adobe/uix-guest";
import { extensionId, BROADCAST_CHANNEL_NAME, EVENT_AUE_CONTENT_DETAILS, EVENT_AUE_UI_SELECT, EVENT_AUE_UI_UPDATE } from "./Constants";
import metadata from '../../../../app-metadata.json';
function ExtensionRegistration() {
const init = async () => {
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
const guestConnection = await register({
id: extensionId,
metadata,
methods: {
canvas: {
getRenderers() {
return [
{
extension: 'eaem-rte-image-picker',
dataType: 'eaem:rte-dm-open-api-images',
url: '/index.html#/eaem-rte-image-picker',
icon: 'OpenIn'
}
];
},
},
events: {
listen: (eventName, eventData) => {
if (eventName === EVENT_AUE_UI_UPDATE ) {
channel.postMessage({
type: eventName,
data: eventData.data
});
}
}
},
},
});
};
init().catch(console.error);
return <Text>IFrame for integration with Host (AEM)...</Text>
}
export default ExtensionRegistration;
6) Add the following code in eaem-ue-rte-dm-openapi-picker\src\universal-editor-ui-1\web-src\src\components\EaemRTEImagePicker.js for showing Markers and Pick Images in panel...
import React, { useState, useEffect, useRef } from 'react'
import { attach } from "@adobe/uix-guest"
import {
Provider,
defaultTheme,
View,
Flex,
TextArea,
Text,
ActionButton,
Button
} from '@adobe/react-spectrum'
import ImageSearch from '@spectrum-icons/workflow/ImageSearch'
import { extensionId, BROADCAST_CHANNEL_NAME } from "./Constants"
export default function EaemRTEImagePicker () {
const [guestConnection, setGuestConnection] = useState()
const [aemToken, setAemToken] = useState('')
const [currentEditable, setCurrentEditable] = useState({});
const [editorState, setEditorState] = useState(null)
const [textValue, setTextValue] = useState('')
const [imageMarkers, setImageMarkers] = useState({})
const currentMarkerRef = useRef(null)
const getAemHost = (editorState) => {
return editorState.connections.aemconnection.substring(editorState.connections.aemconnection.indexOf('xwalk:') + 6);
}
const styleFieldArea = () => {
document.body.style.height = '430px';
}
const updateRichtext = async (item, editorState, token) => {
const payload = {
connections: [{
name: "aemconnection",
protocol: "xwalk",
uri: getAemHost(editorState)
}],
target: {
prop: item.prop,
resource: item.resource,
type: item.type
},
value: item.content
};
try {
const response = await fetch('https://universal-editor-service.adobe.io/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
return await response.json();
} catch (error) {
console.error('Error updating richtext:', error);
throw error;
}
}
const updateTextContent = (marker, linkUrl) => {
// First remove anchor tag if it exists
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const anchorRegex = new RegExp(`<a[^>]*href="[^"]*"[^>]*>${escapedMarker}</a>`, 'g');
let updatedTextValue = textValue.replace(anchorRegex, marker);
// Then create new anchor tag if linkUrl is not empty
if (linkUrl) {
updatedTextValue = updatedTextValue.replace(marker, `<a href="${linkUrl}">${marker}</a>`);
}
return updatedTextValue;
}
const extractImageMarkers = (content) => {
if (!content) return {};
const regex = /\/\/External Image.*?\/\//g;
const matches = content.match(regex) || [];
const markersObj = {};
matches.forEach(marker => {
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const anchorRegex = new RegExp(`<a[^>]*href="([^"]*)"[^>]*>${escapedMarker}</a>`, 'g');
const anchorMatch = content.match(anchorRegex);
if (anchorMatch) {
// Extract href from the anchor tag
const hrefMatch = anchorMatch[0].match(/href="([^"]*)"/);
markersObj[marker] = hrefMatch ? hrefMatch[1] : '';
} else {
// Marker not wrapped in anchor tag
markersObj[marker] = '';
}
});
return markersObj;
}
const handleTextAreaChange = async (imageMarkers) => {
let updatedTextValue = textValue;
for (const marker of Object.keys(imageMarkers)) {
const assetUrl = imageMarkers[marker];
// First remove anchor tag if it exists
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const anchorRegex = new RegExp(`<a[^>]*href="[^"]*"[^>]*>${escapedMarker}</a>`, 'g');
updatedTextValue = updatedTextValue.replace(anchorRegex, marker);
// Then create new anchor tag if assetUrl is not empty
if (assetUrl) {
updatedTextValue = updatedTextValue.replace(marker, `<a href="${assetUrl}">${marker}</a>`);
}
}
setTextValue(updatedTextValue);
return updatedTextValue;
}
const getCurrentEditable = (state) => {
if (!state.selected) return null;
const selectedId = Object.keys(state.selected).find(key => state.selected[key] === true) || null;
if(selectedId && state.editables) {
const editable = state.editables.find(item => item.id === selectedId);
return editable || null;
}
}
const initImageMarkers = (state) => {
const currentEditable = getCurrentEditable(state);
if (currentEditable) {
setCurrentEditable(currentEditable);
setTextValue( currentEditable.content || '');
setImageMarkers(extractImageMarkers(currentEditable.content || '') );
}
}
const fillEditableResourceIfEmpty = () => {
if(!currentEditable.resource) {
// Extract resource from selector: [data-aue-resource="urn:aemconnection:/content/..."]
const match = currentEditable.selector?.match(/data-aue-resource="([^"]+)"/);
if (match) {
currentEditable.resource = match[1];
}
}
}
const handleApplyAll = async () => {
const updatedTextValue = await handleTextAreaChange(imageMarkers);
currentEditable.content = updatedTextValue;
fillEditableResourceIfEmpty();
await updateRichtext(currentEditable, editorState, aemToken);
await guestConnection.host.editorActions.refreshPage();
initImageMarkers(editorState);
};
const showAssetSelectorModal = (marker) => {
currentMarkerRef.current = marker;
guestConnection.host.modal.showUrl({
url: '/index.html#open-asset-picker-modal',
width: '80vw',
height: '70vh',
});
};
useEffect(() => {
(async () => {
styleFieldArea();
const connection = await attach({ id: extensionId })
setGuestConnection(connection);
const state = await connection.host.editorState.get();
setEditorState(state);
const aemToken = await connection.sharedContext.get("token");
setAemToken(aemToken);
initImageMarkers(state);
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.onmessage = async (event) => {
if (!event.data.type) {
return;
}
if (event.data.type === 'EAEM_ASSET_PICKER_ASSET_SELECTED' && currentMarkerRef.current) {
setImageMarkers(prev => ({
...prev,
[currentMarkerRef.current]: event.data.assetUrl
}));
currentMarkerRef.current = null;
} else {
setImageMarkers(extractImageMarkers(event.data?.data?.value || '') );
}
return () => {
channel.close();
};
}
})()
}, [])
return (
<Provider theme={defaultTheme} colorScheme='dark' height='100vh'>
<View padding='size-200' UNSAFE_style={{ overflow: 'hidden' }}>
{Object.keys(imageMarkers).length === 0 ? (
<Text>No image markers found, a sample is shown below...
<br/><br/><span style={{fontStyle: 'italic'}}>This folowing image is picked from Dynamic Media Open API folder
<br/><br/>//External Image 1//</span></Text>
) : (
<Flex direction="column" gap="size-100">
<Flex direction="row" justifyContent="space-between" alignItems="center" marginBottom="size-100">
<Text UNSAFE_style={{ fontSize: '16px', fontWeight: 'bold' }}>Image Markers</Text>
<Button variant="primary" onPress={handleApplyAll}>
Apply
</Button>
</Flex>
{Object.keys(imageMarkers).map((marker, index) => (
<Flex key={index} direction="column" gap="size-100" marginBottom="size-200">
<Text>{marker}</Text>
<View UNSAFE_style={{ position: 'relative' }}>
<TextArea
width="100%"
value={imageMarkers[marker]}
onChange={(value) => setImageMarkers(prev => ({ ...prev, [marker]: value }))}
/>
<ActionButton
onPress={() => showAssetSelectorModal(marker)}
isQuiet
UNSAFE_style={{ position: 'absolute', bottom: '4px', right: '4px', cursor: 'pointer' }}>
<ImageSearch aria-label="Search Image" />
</ActionButton>
</View>
</Flex>
))}
</Flex>
)}
</View>
</Provider>
)
}
7) Add the following in eaem-ue-rte-dm-openapi-picker\src\universal-editor-ui-1\web-src\src\components\EaemAssetPickerModal.js to show the Asset Selector when user clicks the ImageSearch button in Marker Text Area. When an asset is selected the Delivery URL of asset is published to BroadcastChannel and later picked by EaemRTEImagePicker listening to the event EAEM_ASSET_PICKER_ASSET_SELECTED. Also we are dynamically loading the module @assets/selectors in browser at runtime so to prevent the bundler from trying to find the module in node_modules, we are using @vite-ignore to ignore the import statement, not try to analyze or resolve it at build time...
import React, { useState, useEffect } from "react";
import { attach } from "@adobe/uix-guest";
import { Provider, defaultTheme, Content } from "@adobe/react-spectrum";
import { extensionId, BROADCAST_CHANNEL_NAME } from "./Constants";
export default function EaemAssetPickerModal() {
const [guestConnection, setGuestConnection] = useState()
const [colorScheme, setColorScheme] = useState("dark");
const [assetSelectorProps, setAssetSelectorProps] = useState({});
const [AssetSelector, setAssetSelector] = useState(null);
const handleSelection = (assets) => {
const optimalRenditionLink = getOptimalRenditionLink(getAssetRenditionLinks(assets));
const assetDelLink = optimalRenditionLink.href.split('?')[0];
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.postMessage({
type: 'EAEM_ASSET_PICKER_ASSET_SELECTED',
assetUrl: assetDelLink
});
channel.close();
onCloseHandler();
};
const getAssetRenditionLinks = (selectedAssets) => {
const asset = selectedAssets?.[0];
return asset?._links?.['http://ns.adobe.com/adobecloud/rel/rendition'];
};
const getOptimalRenditionLink = (renditions) => {
return renditions.reduce((optimalRendition, currentRendition) => {
const optimalResolution = optimalRendition.width * optimalRendition.height;
const currentResolution = currentRendition.width * currentRendition.height;
return currentResolution > optimalResolution ? currentRendition : optimalRendition;
});
};
const onCloseHandler = () => {
guestConnection.host.modal.close();
};
const init = async () => {
// Dynamically import the asset selector module at runtime
// Using dynamic string to prevent Parcel from resolving at build time
const moduleName = "@assets" + "/" + "selectors";
const assetSelectorModule = await import(/* @vite-ignore */ moduleName);
setAssetSelector(() => assetSelectorModule.AssetSelector);
const connection = await attach({ id: extensionId });
setGuestConnection(connection);
const editorState = await connection.host.editorState.get();
const location = new URL(editorState.location);
const imsToken = connection.sharedContext.get("token");
const orgId = connection.sharedContext.get("orgId");
const selectorProps = {
repositoryId: `${location.host.replace('author', 'delivery')}`,
apiKey: "asset_search_service",
imsOrg: orgId,
imsToken: imsToken,
hideUploadButton: true,
hideTreeNav: true,
};
setAssetSelectorProps(selectorProps);
};
useEffect(() => {
init().catch((e) => console.log("Error loading asset picker modal--->", e));
}, []);
if (!AssetSelector) {
return (
<Provider theme={defaultTheme} colorScheme={colorScheme} height="100vh">
<div style={{ width: '100%', height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
Loading Asset Selector...
</div>
</Provider>
);
}
return (
<Provider theme={defaultTheme} colorScheme={colorScheme} height="100vh">
<div style={{ width: '100%', height: '100vh', overflow: 'hidden' }}>
<AssetSelector colorScheme={colorScheme}
onClose={onCloseHandler}
handleSelection={handleSelection}
{...assetSelectorProps} />
</div>
</Provider>
);
}
8) Set the custom dataType as "component": "eaem:rte-dm-open-api-images" in your Block Model blocks\experience-aem-rte-dm-open-api\_experience-aem-rte-dm-open-api.json
"models": [
{
"id": "eaem-rte-dm-open-api",
"fields": [
{
"component": "richtext",
"valueType": "string",
"name": "eaemRichText",
"label": "Rich Text"
},
{
"component": "eaem:rte-dm-open-api-images",
"name": "eaemDmOpenApiImages",
"label": "Select DM Open API Images",
"valueType": "string"
}
]
}
],
9) In Author Universal Editor, the markup generated adds class button-container so do some decoration to remove it, add the following code in blocks\experience-aem-rte-dm-open-api\experience-aem-rte-dm-open-api.js
export default async function decorate(block) {
//In Author Universal editor, remove button-container class from p elements and remove classes from anchor tags inside
const buttonContainers = block.querySelectorAll('p.button-container');
buttonContainers.forEach(p => {
p.className = '';
const anchor = p.querySelector('a');
if (anchor) {
anchor.className = '';
}
});
}
10) Finally on Author Preview, Page Preview and Live, to Replace the Anchored Markers with their hrefs and show the actual image, add following code in eaem-dev-eds\scripts\scripts.js (picked and adjusted from - https://github.com/hlxsites/franklin-assets-selector/blob/ext-images/scripts/scripts.js)
decorateMain()
export function decorateMain(main) {
if(!isInUniversalEditor()){
// decorate external images with implicit external image marker
decorateExternalImages(main);
decorateExternalImages(main, /^\/\/External Image.*\/\/$/);
}
// ... other decoration code ...
isInUniversalEditor(): to execute Image creation logic from Anchor tag ONLY IF NOT in Universal Editor
function isInUniversalEditor() {
return window.self !== window.top;
}
isExternalImage()
function isExternalImage(element, externalImageMarker) {
// if the element is not an anchor, it's not an external image
if (element.tagName !== 'A') return false;
if(!externalImageMarker) return false;
// if the element is an anchor with the external image marker pattern as text content,
// it's an external image (matches //External Image <anything>//)
const textContent = element.textContent.trim();
if (externalImageMarker.test(textContent)) {
return true;
}
// if the element is an anchor with the href as text content and the href has
// an image extension, it's an external image
if (element.textContent.trim() === element.getAttribute('href')) {
const ext = getUrlExtension(element.getAttribute('href'));
return ext && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext.toLowerCase());
}
}
decorateExternalImages()
function decorateExternalImages(ele, deliveryMarker) {
const extImages = ele.querySelectorAll('a');
extImages.forEach((extImage) => {
if (isExternalImage(extImage, deliveryMarker)) {
const extImageSrc = extImage.getAttribute('href');
const extPicture = createOptimizedPicture(extImageSrc);
/* copy query params from link to img */
const extImageUrl = new URL(extImageSrc);
const { searchParams } = extImageUrl;
extPicture.querySelectorAll('source, img').forEach((child) => {
if (child.tagName === 'SOURCE') {
const srcset = child.getAttribute('srcset');
if (srcset) {
child.setAttribute('srcset', appendQueryParams(new URL(srcset, extImageSrc), searchParams));
}
} else if (child.tagName === 'IMG') {
const src = child.getAttribute('src');
if (src) {
child.setAttribute('src', appendQueryParams(new URL(src, extImageSrc), searchParams));
}
}
});
extImage.parentNode.replaceChild(extPicture, extImage);
}
});
}
No comments:
Post a Comment