AEM Edge Delivery - Universal Editor Extension for RTE Links Open In New Tab


This Universal Editor Extension provides Open in New Tab feature for Links added in a Rich Text Editor of Blocks

Because of Universal Editor’s sanitization and transformation pipeline, target="_blank" is currently considered non‑semantic/unsafe and is stripped from Rich Text HTML when content is persisted or transformed for delivery. The intent is to enforce clean, semantic, and secure HTML, especially for edge/Headless delivery, so only a safe subset of attributes is preserved. So the extension just adds open_in_new_tab=true to the link href for persistence and in block decoration, reads it, adds the necessary target="_blank"

Demo | Github


Extension in Author


Preview showing target="_blank"


Link Config saved in JCR



Solution

1) For detailed steps on setting up Universal Editor Extension check this post 

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

                              > aio login

                              > aio app init eaem-ue-rte-open-new-tab

3) Add the following code in eaem-ue-rte-open-new-tab\src\universal-editor-ui-1\web-src\src\components\ExtensionRegistration.js


import { Text } from "@adobe/react-spectrum";
import { register } from "@adobe/uix-guest";
import metadata from '../../../../app-metadata.json';

import { extensionId, BROADCAST_CHANNEL_NAME, EVENT_AUE_UI_SELECT, EVENT_AUE_UI_UPDATE } from "./Constants";

function ExtensionRegistration() {
  const init = async () => {
    const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);

    const guestConnection = await register({
      id: extensionId,
      metadata,
      methods: {
        events: {
          listen: (eventName, eventData) => {
            if (eventName === EVENT_AUE_UI_SELECT || eventName === EVENT_AUE_UI_UPDATE) {
              channel.postMessage({
                type: eventName,
                data: eventData.data
              });
            }
          }
        },
        rightPanel: {
          addRails() {
            return [
              {
                'id': 'experience-aem-ue-rte-links-open-new-tab',
                'header': 'Experience AEM UE RTE Links Open New Tab',
                'icon': 'Link',
                'url': '/#/experience-aem-ue-rte-links-open-new-tab-rail'
              },
            ];
          },
        },
      },
    });
  };
  init().catch(console.error);

  return <Text>IFrame for integration with Host (AEM)...</Text>
}

export default ExtensionRegistration;


4) Add the following code in eaem-ue-rte-open-new-tab\src\universal-editor-ui-1\web-src\src\components\ExperienceAEMUERTELinksOpenNewTabRail.js

 
import React, { useState, useEffect } from 'react'
import { attach } from "@adobe/uix-guest"
import { Flex, Form, ProgressCircle, Provider, Content, defaultTheme, Text, Checkbox, ButtonGroup, Button, Heading, View } from '@adobe/react-spectrum'

import { extensionId, RICHTEXT_TYPE, BROADCAST_CHANNEL_NAME, EVENT_AUE_UI_SELECT, EVENT_AUE_UI_UPDATE } from "./Constants"

export default function ExperienceAEMUERTELinksOpenNewTabRail ()  {
  const [guestConnection, setGuestConnection] = useState()
  const [editorState, setEditorState] = useState(null)
  const [richtextItem, setRichtextItem] = useState({})
  const [textValue, setTextValue] = useState('')
  const [itemLinks, setItemLinks] = useState([])

  const updateRichtext = async (item, editorState, token) => {
    const aemHost = editorState.connections.aemconnection.substring(editorState.connections.aemconnection.indexOf('xwalk:') + 6);
   
    const payload = {
      connections: [{
        name: "aemconnection",
        protocol: "xwalk",
        uri: aemHost
      }],
      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 extractLinks = (htmlContent) => {
    const parser = new DOMParser();
    const doc = parser.parseFromString(htmlContent, 'text/html');
    const links = doc.querySelectorAll('a');
    return Array.from(links).map(link => {
      const href = link.getAttribute('href') || '';
      const hasOpenInNewTab = href.includes('open_in_new_tab=true') || link.getAttribute('target') === '_blank';
      return {
        text: link.textContent,
        outerHTML: link.outerHTML,
        isOpenInNewTab: hasOpenInNewTab
      };
    });
  }

  const handleLinkTargetChange = (itemId, linkOuterHTML, isChecked) => {
    const currentContent = textValue;
    const hrefMatch = linkOuterHTML.match(/href="([^"]*)"/);

    if (!hrefMatch) return;
   
    const oldHref = hrefMatch[1];
    let newHref = oldHref;
   
    if (isChecked) {
      if (newHref.includes('?')) {
        newHref = newHref.includes('open_in_new_tab=')
          ? newHref.replace(/open_in_new_tab=(true|false)/, 'open_in_new_tab=true')
          : newHref + '&open_in_new_tab=true';
      } else {
        newHref = newHref + '?open_in_new_tab=true';
      }
    } else {
      newHref = newHref
        .replace(/[?&]open_in_new_tab=true/, '')
        .replace(/\?&/, '?');
    }
   
    const updatedLink = linkOuterHTML.replace(`href="${oldHref}"`, `href="${newHref}"`);
    const updatedContent = currentContent.replace(linkOuterHTML, updatedLink);

    setTextValue(updatedContent);

    setItemLinks(prev => prev.map(link =>
      link.outerHTML === linkOuterHTML
        ? { ...link, isOpenInNewTab: isChecked, outerHTML: updatedLink }
        : link
    ));
  }

  const handleSave = async (item) => {
    const token = guestConnection.sharedContext.get("token");
    const updatedContent = textValue || item.content;
   
    const updatedItem = {
      ...item,
      content: updatedContent
    };

    await updateRichtext(updatedItem, editorState, token);

    await guestConnection.host.editorActions.refreshPage();
  }

  useEffect(() => {
    (async () => {
      const connection = await attach({ id: extensionId })
      setGuestConnection(connection);

      const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
   
      channel.onmessage = async (event) => {
        if (!event.data.type) {
          return;
        }

        const state = await connection.host.editorState.get();
        setEditorState(state);

        if(event.data.type) {
          const resource = (event.data.type === EVENT_AUE_UI_SELECT) ? event.data.data.resource : event.data.data.request.target.resource;
          const item = state.editables.filter(editableItem => editableItem.resource === resource)[0];

          if (item) {
            if(!item.content && item.children && item.children.length > 0){
              //for custom blocks "richtext" is child of the custom block
              let child = state.editables.filter(editableItem => editableItem.id === item.children[0])[0];
              child.resource = item.resource;
              item = child;
            }

            setRichtextItem(item);

            setTextValue( item.content || '');
           
            setItemLinks(extractLinks(item.content || ''));
          }
        }
 
        return () => {
          channel.close();
        };
      };
    })()
  }, [])

  return (
    <Provider theme={defaultTheme} colorScheme='light' height='100vh'>
      <Content height='100%'>
        <View padding='size-200'>
          <Heading marginBottom='size-100' level='3'>Links in Richtext</Heading>
          <View>
            {richtextItem?.id && (
              <Flex direction='column' gap='size-65' marginBottom='size-200' key={richtextItem.id}>
                <Flex direction='column'>
                  {itemLinks.length > 0 ? (
                    itemLinks.map((link, idx) => (
                      <Flex key={idx} direction='column' marginTop='size-100' marginBottom='size-100'>
                        <View borderWidth='thin' borderColor='gray-400' borderRadius='medium' padding='size-100' backgroundColor='gray-50'>
                          <Flex direction='column'>
                            <Text marginBottom='size-100'>
                              {link.text}
                            </Text>
                            <Checkbox isSelected={link.isOpenInNewTab} onChange={(isChecked) => handleLinkTargetChange(richtextItem.id, link.outerHTML, isChecked)}>
                              Open in new tab
                            </Checkbox>
                          </Flex>
                        </View>
                      </Flex>
                    ))
                  ) : (
                    <Text>No links found</Text>
                  )}
                  {itemLinks.length > 0 && (
                    <Flex direction='row' marginTop='size-100'>
                      <Button variant="primary" onPress={() => handleSave(richtextItem)} isDisabled={textValue === richtextItem.content} UNSAFE_style={{ cursor: "pointer" }}>Save</Button>
                    </Flex>
                  )}
                </Flex>
              </Flex>
            )}
          </View>
        </View>
      </Content>
    </Provider>
  )
}


5) On Preview and Live, Block decoration JS reads open_in_new_tab=true and adds target="_blank", add the following code in your block js file eg. eaem-dev-eds\blocks\euetext\euetext.js

export default async function decorate(block) {
    if (window.location.hostname.startsWith('author-')) {
        return;
    }
   
    const links = block.querySelectorAll('a');
   
    links.forEach(link => {
        const href = link.getAttribute('href');
        if (href && href.includes('open_in_new_tab=true')) {
            link.setAttribute('target', '_blank');
           
            const cleanHref = href
                .replace(/[?&]open_in_new_tab=true/, '')
                .replace(/\?$/, '');
           
            link.setAttribute('href', cleanHref);
        }
    });
}




No comments:

Post a Comment