AEM Edge Delivery - Universal Editor Rich Text Extension (RTE) Selected Text Styles Picker


 Add a Universal Editor RTE Extension to Select Text and apply Site Specific Styles

               1) Create the Site Spreadsheet Config file universal-editor-config at site root path eg. /content/eaem-dev-eds
 
               2) Provide key RTE_STYLES_URL and set value to URL of CSS in your EDS code eg. /styles/rte-styles.css

               3) Select text in Richtext (of any block). When text is selected postSelectedTextToAIOExtensions of eaem-dev-eds/scripts/ue-extensions.js posts the message to aio extension iframe 

               4) Pick a Style from Dropdown available in the Styles Panel Extension in Right Rail, loaded with Styles from above CSS file...

               5) Click on Show Styled button to view application of Styles eg. <span class="classname">text</span> (Editor shows it by adding url parameter edsRTEShowStyled=true)

               6) Click on Show Marked button to view the marked representation (Edge Delivery Markdown does not understand span tag, so the marked representation and converted to span client side) eg. //[classname]text//

               7) Always Edit the RTE content in Marked Mode as Styled mode content (with span and css) is stripped by Universal Editor on Save. 

To load the extension running locally (aio app run) in browser : https://author-p10961-e880305.adobeaemcloud.com/ui#/@acsaem/aem/universal-editor/canvas/author-p10961-e880305.adobeaemcloud.com/content/eaem-dev-eds/index/rte-styles.html?devMode=true&ext=https://localhost:9080



Styled Mode



Marked Mode



Page Preview


Configuration



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 Panel... 

                              > aio login

                              > aio app init eaem-ue-rte-style-picker

3) Add the following code in eaem-ue-rte-style-picker\src\universal-editor-ui-1\web-src\src\components\ExtensionRegistration.js to add listeners 

import { Text } from "@adobe/react-spectrum";
import { register } from "@adobe/uix-guest";
import { extensionId, BROADCAST_CHANNEL_NAME, EVENT_AUE_UI_SELECT, EVENT_AUE_UI_UPDATE } from "./Constants";
import metadata from '../../../../app-metadata.json';

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

  const init = async () => {
    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-styles',
                'header': 'Experience AEM UE RTE Styles',
                'icon': 'ColorPalette',
                'url': '/#/experience-aem-ue-rte-styles-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-style-picker\src\universal-editor-ui-1\web-src\src\components\ExperienceAEMUERTEStylesRail.js which has the main extension code for loading styles and creating markers...

import React, { useState, useEffect, useMemo } from "react";
import { attach } from "@adobe/uix-guest";
import { Provider, Content, defaultTheme, Heading, View, ComboBox, Item, Text, Button, Flex, Link } from "@adobe/react-spectrum";

import { extensionId, UNIVERSAL_EDITOR_CONFIG_SPREADSHEET, RTE_STYLES_URL,
  RTE_STYLE_CLASS_PREFIX, MARKED_TEXT_PREFIX, MARKED_TEXT_SUFFIX,
  BROADCAST_CHANNEL_NAME, EVENT_AUE_UI_SELECT, EVENT_AUE_UI_UPDATE, SELECTION_MESSAGE_TYPE } from "./Constants";

/** Dropdown label: optional strip of rte- prefix, then kebab → Title Case words */
function classNameToDropdownLabel(className, prefix) {
  const raw = typeof className === 'string' ? className.trim() : ''
  if (!raw) return ''
  const body = prefix && raw.startsWith(prefix) ? raw.slice(prefix.length) : raw
  return body
    .split('-')
    .filter(Boolean)
    .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase())
    .join(' ')
}

/** Parsed //[STYLE_CLASS]text// segments from converted richtext content */
function extractStyledMarkedSegments(content) {
  if (!content || typeof content !== "string") {
    return [];
  }

  const re = /\/\/\[([^\]]+)\]\s*([\s\S]*?)\/\//g;
  const out = [];
  let m;

  while ((m = re.exec(content)) !== null) {
    out.push({
      styleClass: m[1].trim(),
      text: m[2].trim(),
    });
  }
  return out;
}

/** Remove the n-th //[class]text// marker, leaving plain inner text in place */
function removeStyledMarkedSegmentAt(content, segmentIndex) {
  if (!content || typeof content !== "string") {
    return content;
  }
  const re = /\/\/\[([^\]]+)\]\s*([\s\S]*?)\/\//g;
  const matches = [...content.matchAll(re)];
  if (segmentIndex < 0 || segmentIndex >= matches.length) {
    return content;
  }
  const m = matches[segmentIndex];
  const inner = m[2];
  const start = m.index;
  const full = m[0];
  return content.slice(0, start) + inner + content.slice(start + full.length);
}

export default function ExperienceAEMUERTEStylesRail() {
  const [guestConnection, setGuestConnection] = useState();
  const [editorState, setEditorState] = useState(null);
  const [richtextItem, setRichtextItem] = useState({});
  const [textValue, setTextValue] = useState("");
  const [rteStyles, setRteStyles] = useState([]);
  const [selectedStyle, setSelectedStyle] = useState("");
  const [pageSelectedText, setPageSelectedText] = useState("");
  const [rteStylesUrlMissing, setRteStylesUrlMissing] = useState(false);

  const styledMarkedSegments = useMemo(
    () => extractStyledMarkedSegments(textValue),
    [textValue],
  );

  const getAemHost = (editorState) => {
    let host = editorState.connections.aemconnection.substring(editorState.connections.aemconnection.indexOf('xwalk:') + 6);
   
    if (host.includes('?ref=')) {
      host = host.split('?ref=')[0];
    }
   
    return host;
  }

  const getBranch = (editorState) => {
    const url = new URL(editorState.location);
    return url.searchParams.get('ref') || ""
  }

  const getSiteRoot = (editorState) => {
    const url = new URL(editorState.location);

    const tokenizedPath = url.pathname.match(/^(\/content\/[^/]+)/);
    if (tokenizedPath) {
      return tokenizedPath[1];
    }
  };

  const updateRichtextWithGuest = async (editable) => {
    const target = {
      editable: { id: editable.id }
    };

    const patch = [{
      op: "replace",
      path: "/" + editable.prop,
      value: editable.content
    }]

    await guestConnection.host.editorActions.update( { target, patch });
  }

  const handleSelectionChange = async (styleName) => {
    if(!pageSelectedText)  return;
   
    setSelectedStyle(styleName);
   
    let markedSelectedText = `//[${styleName}] ${pageSelectedText}//`;
    let updatedTextValue = textValue.replace(pageSelectedText, markedSelectedText);

    setTextValue(updatedTextValue);

    const updatedItem = {
      ...richtextItem,
      content: updatedTextValue
    };

    await updateRichtextWithGuest(updatedItem);

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

  const handleShowStyled = async () => {
    const url = new URL(editorState.location);
    url.searchParams.set('edsRTEShowStyled', 'true');
    await guestConnection.host.editorActions.navigateTo(url.toString())
  };

  const handleShowMarked = async () => {
    const url = new URL(editorState.location);
    url.searchParams.set('edsRTEShowStyled', 'false');
    await guestConnection.host.editorActions.navigateTo(url.toString())
  };

  const handleRemoveStyledSegment = async (segmentIndex) => {
    if (!guestConnection || !richtextItem?.id) {
      return;
    }

    const updatedContent = removeStyledMarkedSegmentAt(textValue, segmentIndex);
    setTextValue(updatedContent);

    const updatedItem = {
      ...richtextItem,
      content: updatedContent,
    };
   
    await updateRichtextWithGuest(updatedItem);
    await guestConnection.host.editorActions.refreshPage();
  };

  const convertSpanToMarkedText = (content) => {
    if (!content) return content;

    // Pattern: <span class="classname">text</span> to //[classname]text//
    const pattern = /<span class="([^"]+)">([^<]+)<\/span>/g;
   
    const converted = content.replace(pattern, '//[$1]$2//');
   
    return converted;
  };

  const loadUniversalEditorConfig = async (siteRoot, aemHost, aemToken) => {
    try {
      const requestOptions = {
        headers: {
          'Authorization': `Bearer ${aemToken}`
        }
      };

      const queryBuilderUrl =
        `${aemHost}/bin/querybuilder.json?path=${siteRoot}/${UNIVERSAL_EDITOR_CONFIG_SPREADSHEET}` +
        `&property=Key&property.value=${RTE_STYLES_URL}` +
        `&p.hits=selective&p.properties=Key Value`;

      const response = await fetch(queryBuilderUrl, requestOptions);

      const data = await response.json();
      const config = {};

      data.hits.forEach(hit => {
        if (hit.Key && hit.Value) {
          config[hit.Key] = hit.Value;
        }
      });

      return config;
    } catch (error) {
      console.error("Error loading Universal Editor config:", error);
      return {};
    }
  };

  useEffect(() => {
    const onMessage = (event) => {
      if (!event.data || event.data.type !== SELECTION_MESSAGE_TYPE) {
        return;
      }
      if (typeof event.data.text !== "string") {
        return;
      }

      setPageSelectedText(event.data.text);
    };

    window.addEventListener("message", onMessage);
   
    return () => window.removeEventListener("message", onMessage);
  }, []);

  const loadRTEStyles = async (stylesUrl, aemToken) => {
    try {
      const requestOptions = {
        headers: {
          'Authorization': `Bearer ${aemToken}`
        }
      };
     
      const response = await fetch(stylesUrl, requestOptions);

      const cssText = await response.text();

      // Extract class names from CSS using regex, Pattern: .classname { ... }
      const classNameRegex = /\.([a-zA-Z0-9_-]+)\s*\{/g;
      const rawNames = [];
      let match;

      while ((match = classNameRegex.exec(cssText)) !== null) {
        rawNames.push(match[1]);
      }

      const rteStylesFiltered = [...new Set(
        rawNames.filter((name) => name.startsWith(RTE_STYLE_CLASS_PREFIX))
      )];

      setRteStyles(rteStylesFiltered);
      return rteStylesFiltered;
    } catch (error) {
      console.error("Error loading RTE styles:", error);
      return [];
    }
  };

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

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

      const ueConfig = await loadUniversalEditorConfig(getSiteRoot(state), getAemHost(state),
                        await connection.sharedContext.get("token"));

      let stylesPath = typeof ueConfig[RTE_STYLES_URL] === "string"
          ? ueConfig[RTE_STYLES_URL].trim()
          : ueConfig[RTE_STYLES_URL];

       if (typeof stylesPath === 'string' && stylesPath) {
        const sep = stylesPath.includes('?') ? '&' : '?';
        stylesPath = `${stylesPath}${sep}ck=${Math.floor(Math.random() * 1e12)}`;
      }

      if (!stylesPath) {
        setRteStylesUrlMissing(true);
        setRteStyles([]);
      } else {
        setRteStylesUrlMissing(false);
        const stylesUrl = getAemHost(state) + getSiteRoot(state) + "." + getBranch(state) + ".resource" + stylesPath;
        await loadRTEStyles(stylesUrl,await connection.sharedContext.get("token"));
      }

      const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);

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

        if (event.data.type === EVENT_AUE_UI_SELECT || event.data.type === EVENT_AUE_UI_UPDATE) {
          state = await connection.host.editorState.get();
          setEditorState(state);

          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;
            }

            const convertedContent = convertSpanToMarkedText(item.content || "");

            setRichtextItem(item);
            setTextValue(convertedContent);
          }
        }

        return () => {
          channel.close();
        };
      };
    })();
  }, []);

  return (
    <Provider theme={defaultTheme} height="100vh" >
      <Content height="100%">
        <View padding="size-200">
          {rteStylesUrlMissing ? (
            <Text>
              No {RTE_STYLES_URL} configured in &apos;{UNIVERSAL_EDITOR_CONFIG_SPREADSHEET}&apos; spreadsheet
            </Text>
          ) : (
            <>
              <Heading marginBottom="size-100" level="3">Editable</Heading>
              <Text marginBottom="size-200">
                {richtextItem?.label ? (
                  richtextItem.label
                ) : (
                  <span style={{color: "var(--spectrum-semantic-negative-color-default, #e34850)"}}>None</span>
                )}
              </Text>
              <Heading marginBottom="size-100" level="3">Selected Text</Heading>
              <Text UNSAFE_style={{ fontStyle: pageSelectedText ? 'normal' : 'italic' }}>
                {pageSelectedText || "No text selected"}
              </Text>
              <Heading marginTop="size-300" marginBottom="size-100" level="3">Available Styles</Heading>
              <ComboBox selectedKey={selectedStyle} onSelectionChange={handleSelectionChange} width="100%" placeholder="Select Style" marginTop="size-200">
                {rteStyles.map((styleName) => {
                  const titleLabel = classNameToDropdownLabel(styleName, RTE_STYLE_CLASS_PREFIX)
                  return (
                    <Item key={styleName} textValue={titleLabel}>{titleLabel}</Item>
                  )
                })}
              </ComboBox>
              <Flex direction="row" gap="size-100" marginTop="size-300">
                <Button variant="secondary" onPress={handleShowMarked} flex={1}>Show Marked</Button>
                <Button variant="secondary" onPress={handleShowStyled} flex={1}>Show Styled</Button>
              </Flex>
              {styledMarkedSegments.length > 0 ? (
                <View marginTop="size-500">
                  <Heading marginBottom="size-100" level="4">
                    Styled Texts
                  </Heading>
                  {styledMarkedSegments.map((seg, i) => (
                    <Flex
                      key={`${seg.styleClass}-${i}`}
                      direction="column"
                      gap="size-50"
                      marginBottom="size-200"
                      width="100%"
                    >
                      <Text elementType="div">Class: {seg.styleClass}</Text>
                      <Text elementType="div">Text: {seg.text}</Text>
                      <Link onPress={() => handleRemoveStyledSegment(i)}>
                        Remove Class
                      </Link>
                    </Flex>
                  ))}
                </View>
              ) : null}
            </>
          )}
        </View>
      </Content>
    </Provider>
  );
}


5)  To show Styled text on Author Preview, Page Preview and Live, add following code in eaem-dev-eds\scripts\delayed.js 

import { loadCSS } from './aem.js';

function isInUniversalEditor() {
  return window.self !== window.top;
}

function decorateRTEStyles(main) {
  // Pattern: //[classname] TEXT TO BE STYLED //
  const pattern = /\/\/\[([^\]]+)\]\s*(.*?)\s*\/\//g;

  const paragraphs = main.querySelectorAll('p');

  paragraphs.forEach((p) => {
    if (pattern.test(p.textContent)) {
      p.innerHTML = p.innerHTML.replace(pattern, '<span class="$1">$2</span>');
    }
    pattern.lastIndex = 0; // Reset regex for next test
  });
}

function decorateRTEStylesForUE(main) {
  const urlParams = new URLSearchParams(window.location.search);
  const showStyled = urlParams.get('edsRTEShowStyled');

  if (showStyled === 'true') {
    decorateRTEStyles(main);
  }
}

async function loadDelayedScripts() {
  const main = document.querySelector('main');
  loadCSS(`${window.hlx.codeBasePath}/styles/rte-styles.css`);

  if (isInUniversalEditor()) {
    const { default: registerUEExtensions } = await import('./ue-extensions.js');
    registerUEExtensions();

    decorateRTEStylesForUE(main);
  } else {
    decorateRTEStyles(main);
  }
}

loadDelayedScripts(); // add delayed functionality here


6)  To send the selected text from Editor to AIO Extension in right rail add the following code in eaem-dev-eds\scripts\ue-extensions.js 

export default function registerUEExtensions() {
  const SELECTION_MESSAGE_TYPE = 'eds-user-text-selection';
  let selectionDebounce;

  const postSelectedTextToAIOExtensions = (text) => {
    const payload = { type: SELECTION_MESSAGE_TYPE, text };
    let rootDocument = document;

    try {
      if (window.parent && window.parent !== window) {
        rootDocument = window.parent.document;
      }
    } catch {
      // Cross-origin parent frame — document not accessible
    }

    const iframes = rootDocument.querySelectorAll('iframe[data-uix-guest="true"]');

    iframes.forEach((frame) => {
      const win = frame.contentWindow;

      if (!win) {
        return;
      }

      let targetOrigin = '*';

      try {
        if (frame.getAttribute('src')) {
          targetOrigin = new URL(
            frame.getAttribute('src'),
            rootDocument.baseURI || document.baseURI,
          ).origin;
        }
      } catch {
        targetOrigin = '*';
      }

      try {
        win.postMessage(payload, targetOrigin);
      } catch {
        // postMessage can fail for cross-origin or detached frames; ignore
      }
    });
  };

  document.addEventListener('selectionchange', () => {
    clearTimeout(selectionDebounce);
    selectionDebounce = setTimeout(() => {
      const text = window.getSelection()?.toString() ?? '';
      if (text.trim()) {
        postSelectedTextToAIOExtensions(text);
      }
    }, 200);
  });
}


No comments:

Post a Comment