AEM Edge Delivery - Universal Editor RTE Extension to show Asset Selector for DM OpenAPI Images


The following post adds an aio app to show the Asset Selector in a Modal for picking DM OpenAPI images in Delivery Domain and add them in RTE (Rich Text Editor). Any External Images (including DM OpenAPI images) are added as Anchor Tags in RTE and converted to Picture/Img tags in the function decorateExternalImages() of .\scripts\scripts.js (for Anchor -> Img process, check  : https://github.com/hlxsites/franklin-assets-selector/blob/ext-images/EXTERNAL_IMAGES.md)


Here is the process...

               1) Create a new Block Experience AEM RTE DM Open API with a richtext and custom component eaem:rte-dm-open-api-images
               2) In the Rich Text Editor for selecting DM OpenAPI images manually add Image Markers eg. //External Image 1//
               3) In the Properties Panel of Block, open Asset Selector and Pick Images Approved (images shown are from Delivery Domain eg. delivery-p10961-e880305.adobeaemcloud.com)
               4) Extension wraps the marker in an anchor tag with href of image delivery url 
               5) Universal Editor shows the anchor tag, but Author Preview, Page Preview and Live replace the anchor tag with image (thanks to decorateExternalImages())




Extension in Properties Panel



Asset Selector



Data Saved in CRX



Author Preview



Page Preview



Solution


1) For detailed instructions on setting up Universal Editor Extension and Publishing to your Org check this post, steps below load it from a locally running app so https://localhost:9080 

2) Open terminal, login to aio and create an app. While going through the prompts make sure you select the Universal Editor Extension Template and Add a Custom Renderer... 

                              > aio login

                              > aio app init eaem-ue-rte-dm-openapi-picker


3) Add the following code in 
eaem-ue-rte-dm-openapi-picker\src\universal-editor-ui-1\web-src\index.html 
to add Asset Selector module @assets/selectors (and react dependency) to importmap

<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