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


Add a Universal Editor RTE Extension to Mark 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 eg. https://raw.githubusercontent.com/schoudry/eaem-dev-eds/main/styles/rte-styles.css

               3) Mark text in Richtext (of any block) using // eg. //This is Text to be Styled//

               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 Style eg. <span class="classname">text</span>

               6) Click on Show Marked button to view the marked representation (saved in CRX) 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. 




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-styles

3) Add the following code in eaem-ue-rte-styles\src\universal-editor-ui-1\web-src\src\components\ExtensionRegistration.js to add a listener for events 'aue:ui-select''aue:content-update'

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': 'TextStyle',
                '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-styles\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 } from "react";
import { attach } from "@adobe/uix-guest";
import { Provider, Content, defaultTheme, Heading, View, ComboBox, Item, Text, Button, Flex } from "@adobe/react-spectrum";

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

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 [markedText, setMarkedText] = useState("");

  const getAemHost = (editorState) => {
    return editorState.connections.aemconnection.substring(
      editorState.connections.aemconnection.indexOf("xwalk:") + 6
    );
  };

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

    // Extract root something like /content/site-name
    const match = url.pathname.match(/^(\/content\/[^\/]+)/);
   
    return match ? match[1] : '';
  };

  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 handleSelectionChange = async (styleName) => {
    if(!markedText)  return;
   
    setSelectedStyle(styleName);
   
    let updatedTextValue = textValue;

    if (markedText && textValue) {
      // Replace //markedText// with //[styleName] markedText//
      const escapedMarkedText = markedText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      const oldPattern = new RegExp(`(?<!:)\/\/${escapedMarkedText}\/\/`, 'g');
      const newPattern = `//[${styleName}] ${markedText}//`;
     
      updatedTextValue = textValue.replace(oldPattern, newPattern);
      setTextValue(updatedTextValue);
    }

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

    await updateRichtext(updatedItem, editorState, await guestConnection.sharedContext.get("token"));

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

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

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

  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 extractMarkedText = (content) => {
    if (!content) return "";

    // Match //text// but NOT //[classname] text// ( ignore already styled text)
    const pattern = /(?<!:)\/\/(?!\[)([^\/]+?)\/\//;
    const match = content.match(pattern);

    return match ? match[1].trim() : "";
  };

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

      const response = await fetch(
        `${aemHost}/bin/querybuilder.json?path=${siteRoot}/${UNIVERSAL_EDITOR_CONFIG_SPREADSHEET}` +
        `&property=Key&property.value=${RTE_STYLES_URL}` +
        `&p.hits=selective&p.properties=Key Value`, 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 {};
    }
  };

  const loadRTEStyles = async (stylesUrl) => {
    try {
      const response = await fetch(stylesUrl);
      const cssText = await response.text();

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

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

      setRteStyles(matches);
      return matches;
    } 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"));

      await loadRTEStyles(ueConfig[RTE_STYLES_URL]);

      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);
            setMarkedText(extractMarkedText(convertedContent));
          }
        }

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

  return (
    <Provider theme={defaultTheme} colorScheme="dark" height="100vh">
      <Content height="100%">
        <View padding="size-200">
          <Heading marginBottom="size-100" level="3">Marked Text</Heading>
          <Text UNSAFE_style={{ fontStyle: markedText ? 'normal' : 'italic' }}>
            {markedText || "No marked text found, add using pattern // eg. //This is marked text//"}
          </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) => (
              <Item key={styleName}>{styleName}</Item>
            ))}
          </ComboBox>
          <Flex direction="row" gap="size-100" marginTop="size-500">
            <Button variant="secondary" onPress={handleShowMarked} flex={1}>Show Marked</Button>
            <Button variant="secondary" onPress={handleShowStyled} flex={1}>Show Styled</Button>
          </Flex>
        </View>
      </Content>
    </Provider>
  );
}


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

decorateMain()

export function decorateMain(main) {
  if(isInUniversalEditor()){
    decorateRTEStylesForUE(main);
  }else{
    decorateRTEStyles(main);
  }

  //more decorations here.....
}


isInUniversalEditor()

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


decorateRTEStyles(): Convert the Marked text to Styled text applying CSS classes in Span...

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


decorateRTEStylesForUE() : when eaemRTEShowStyled=true exists in url, apply CSS classes to show Styled Mode in Universal Editor, else Marked Mode... 

function decorateRTEStylesForUE(main) {
  const urlParams = new URLSearchParams(window.location.search);
  const showStyled = urlParams.get('eaemRTEShowStyled');
 
  if (showStyled === 'true') {
    decorateRTEStyles(main);
  }
}



No comments:

Post a Comment