Skip to content

Feature: Handle any/unknown HTML tags with importDOM #8524

@levensta

Description

@levensta

Description

Currently there is now an importDOM API for an LexicalNode that allows you to define the import of a specific tag as a key-value relationship, where the key is the name of the tag, and the value is a function that returns the import function and its priority

There's no way to specify import handling for any node. The import definition in the current implementation of importDOM must always be in the node class, and DOMConversionMap always expects a specific tag name as a key, making it particularly difficult to intercept non-HTML tags (for example, if a user implements a WYSIWYG editor compatible with custom markup)

Workaround for unknown elements

Theoretically it is possible to process unknown elements, given that the editor already has conversion functions registered for known tags.

To do this, you need to use a local copy of the getConversionFunction function and use it in DOM preprocessing before sending it to $generateNodesFromDOM. The purpose of preprocessing is to ensure that each element has a corresponding conversion function, and if not, to wrap the node in a special tag that will later be processed during the import of special node like FallbackNode

This approach is quite fragile, especially if a DOM element is mutated during import. For example, if you need to add style display: inline for custom inline node #8391

Workaround for any existing elements

If you need to intercept the import of any existing element, for example to process global attributes as id and add NodeState to the node. You can loop through each class from RegisteredNodes and reassign a new static importDOM method based on the original one

Code example
const registeredNodes =
  typeof editorConfig.nodes === 'function'
    ? editorConfig.nodes()
    : editorConfig.nodes;
if (!registeredNodes) {
  return;
}

for (const klass of registeredNodes) {
  // skip replacement config
  if ('replace' in klass) return;

  const importMap: DOMConversionMap = {};

  // Wrap all node importers with a function that sets id
  // This doesn't work for classes where `importDOM` is declared via `config()`
  for (const [tag, fn] of Object.entries(klass.importDOM?.() || {})) {
    importMap[tag] = importNode => {
      const importer = fn(importNode);
      if (!importer) {
        return null;
      }
      return {
        ...importer,
        conversion: element => {
          const output = importer.conversion(element);
          if (element.id && output?.node && !Array.isArray(output.node)) {
            $setState(output.node, idState, element.id);
          }
          return output;
        },
      };
    };
  }

  klass.importDOM = () => importMap;
}

But the problem with this approach is that it doesn't work if importDOM is declared as a config property https://lexical.dev/docs/api/modules/lexical#importdom-6

Import context problem

Another issue related to imports is the context in which the import occurs. For example, it's important to consider that imports may occur while pasting HTML from the clipboard. In this case, it's best to skip elements for which the conversion function returns null, especially if the importDOM contains strict schema validation and needs to be relaxed when pasting HTML from the clipboard

Example of import with schema validation
import { z, ZodError } from 'zod';

const $UnorderedListSchema = z.preprocess(
  (attrs: NamedNodeMap) =>
    Object.fromEntries(Array.from(attrs).map((a) => [a.name, a.value])),
  z.strictObject({
    marker: z.enum(['disc', 'circle', 'square', 'none']).default('disc'),
  })
);

// class ExtendedListNode extends ListNode
static importDOM(): DOMConversionMap {
  return {
    // Validate here if you want other conversion functions to work after returning null
    ul: (domNode) => {
      return {
        conversion: (element) => {
          try {
            const { marker } = $UnorderedListSchema.parse(element.attributes);
            return {
              node: $createExtendedList('bullet', marker),
            };
          } catch (e) {
            if (e instanceof ZodError) {
              console.warn('ExtendedListNode\n', z.prettifyError(e));
              // no fallback conversions
              return null;
            }
            throw e;
          }
        },
      };
    },
  };
}

Relates

Impact

This may be useful if the user is implementing a WYSIWYG editor in which it is important to preserve the original markup or show a fallback element. And also for setting global states in nodes

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementImprovement over existing featurehtml

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions