Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lexical Editor: Link Feature: Missing _target attribute, incorrect href attribute in HTML conversion #6504

Closed
dlford opened this issue May 25, 2024 · 3 comments
Assignees
Labels
plugin: richtext-lexical @payloadcms/richtext-lexical

Comments

@dlford
Copy link

dlford commented May 25, 2024

Link to reproduction

No response

Payload Version

2.0.0

Node Version

18.19.0

Next.js Version

N/A

Describe the Bug

First the definite bug; if "Open in new tab" is selected when creating a link in Lexical Editor the _target attribute is not applied, this can be seen in the code:

Second, maybe not a bug, but not really covered in the documentation; When linking to an internal collection the href is the ID of the collection document, not an actual URL.

It looks like there are some attempts to correctly find the href, but I'm not sure how to make it work. I am using a slug field, but also tried using a url field with both relative URLs and full (http://...) URLs, to no avail.

I also tried (naively) to build my own implementation, but node.fields.doc.value is always the ID, never the full document, and trying to use payload.findByID within the converter causes an infinite loop that I am unable to track down.

Reproduction Steps

Apologies for not creating a reproduction, I don't think this warrants a full reproduction, but I can work on that if it is determined to be needed.

payload.config.ts

{
  // ...
  editor: lexicalEditor({
    features: () => [
      BoldTextFeature(),
      ItalicTextFeature(),
      UnderlineTextFeature(),
      StrikethroughTextFeature(),
      SubscriptTextFeature(),
      SuperscriptTextFeature(),
      InlineCodeTextFeature(),
      ParagraphFeature(),
      HeadingFeature({}),
      UnorderedListFeature(),
      OrderedListFeature(),
      LinkFeature({
        enabledCollections: ['pages', 'posts'],
      }),
      BlockQuoteFeature(),
      HTMLConverterFeature(),
    ],
  // ...
  }),
};

Text Block:

const TextBlock: Block = {
  slug: 'text',
  interfaceName: 'TextBlock',
  labels: {
    singular: 'Text',
    plural: 'Text',
  },
  fields: [
    {
      name: 'text',
      type: 'richText',
    },
    lexicalHTML('text', {
      name: 'html',
    }),
  ],
};

Relevant fields on link target schema:

[
  {
    name: 'url',
    type: 'text',
    admin: {
      hidden: true,
    },
  },
  {
    name: 'slug',
    type: 'text',
    required: true,
    unique: true,
    admin: {
      description:
        'The slug part of the page URL without a leading slash, e.g. "about-us" (use the special slug "home" for the home page)',
    },
    hooks: {
      beforeChange: [
        ({ data, value }) => {
          data.url = `http://localhost:3000/${value}`;
          return value;
        },
      ],
    },
    validate: (value, args) => {
      if (/[^a-z0-9-]/.test(value)) {
        return "Slug can only contain letters, numbers, and '-'";
      }
      if (/^\/.*/.test(value)) {
        return "Slug cannot start with '/'";
      }
      if (/\.\./.test(value)) {
        return "Slug cannot contain '..'";
      }
      if (/\/\//.test(value)) {
        return "Slug cannot contain '//'";
      }
      return text(value, args);
    },
  },
],

Attempt at writing my own converter:

import {
  HTMLConverter,
  LinkNode,
  SerializedLinkNode,
  convertLexicalNodesToHTML,
} from '@payloadcms/richtext-lexical';
import payload from 'payload';

const LinkHTMLConverter: HTMLConverter<SerializedLinkNode> = {
  converter: async function ({ converters, node, parent }) {
    const childrenText = await convertLexicalNodesToHTML({
      converters,
      lexicalNodes: node.children,
      parent: {
        ...node,
        parent,
      },
    });

    console.log(converters);

    const rel: string = node.fields.newTab
      ? ' rel="noopener noreferrer"'
      : '';

    const target: string = node.fields.newTab ? ' target="_blank"' : '';

    let href: string = '';
    if (node.fields.linkType === 'custom') {
      href = node.fields.url;
    } else if (
      !!node.fields.doc?.value &&
      node.fields.doc?.relationTo &&
      typeof node.fields.doc?.value === 'string' &&
      !node.fields.doc.value.startsWith('http')
    ) {
      // Uncommenting these lines causes an infinite loop
      //
      // const document = await payload.findByID({
      //   id: node.fields.doc.value,
      //   collection: node.fields.doc.relationTo,
      // });
      // href = `/${document.slug}`;
    } else {
      href = (node.fields.doc?.value as string) ?? '';
    }

    return `<a href="${href}"${target}${rel}>${childrenText}</a>`;
  },
  nodeTypes: [LinkNode.getType()],
};

export default LinkHTMLConverter;

Adapters and Plugins

richtext-lexical

@dlford dlford added status: needs-triage Possible bug which hasn't been reproduced yet v3 labels May 25, 2024
@AlessioGr AlessioGr self-assigned this May 25, 2024
@AlessioGr AlessioGr added the plugin: richtext-lexical @payloadcms/richtext-lexical label May 25, 2024
@AlessioGr
Copy link
Member

Thank you for the detailed issue! This has been fixed by #6544. Keep in mind that the fix will only be available in the latest v3 beta (3.0.0-beta.37) and won't be backported to 2.0.

Target attributes have been added back to all default link converters.

I also tried (naively) to build my own implementation, but node.fields.doc.value is always the ID, never the full document, and trying to use payload.findByID within the converter causes an infinite loop that I am unable to track down.

Building an HTML converter for this was the right move, as there is no way for a default converter to know what the final URL of the internal link will be. The infinite loop from payload.findByID could have be due to 2 issues:

  1. I have noticed that the default converters list was growing with every field initialization. This could have caused lag. fix(richtext-lexical): various html converter fixes #6544 fixed this
  2. In 2.0, the payload object was not passed through to the converter functions. Using the imported one can cause issues. In 3.0, payload is now passed through as an argument. I have tried your provided example which uses payload.findByID, and it works perfectly!

@github-actions github-actions bot removed the status: needs-triage Possible bug which hasn't been reproduced yet label May 29, 2024
@dlford
Copy link
Author

dlford commented May 29, 2024

Thank you @AlessioGr!

I was able to apply your filtering logic to the converters passed in to my converter to resolve the issue for the time being. Looking forward to 3.0!

import {
  HTMLConverter,
  LinkNode,
  SerializedLinkNode,
  convertLexicalNodesToHTML,
} from '@payloadcms/richtext-lexical';
import payload from 'payload';

const LinkHTMLConverter: HTMLConverter<SerializedLinkNode> = {
  converter: async function ({ converters, node, parent }) {
    const foundNodeTypes: string[] = [];
    const filteredConverters: HTMLConverter[] = [];
    for (const converter of converters) {
      if (!converter.nodeTypes?.length) {
        continue;
      }
      const newConverter: HTMLConverter = {
        converter: converter.converter,
        nodeTypes: [...converter.nodeTypes],
      };
      newConverter.nodeTypes = newConverter.nodeTypes.filter(
        (nodeType) => {
          if (foundNodeTypes.includes(nodeType)) {
            return false;
          }
          foundNodeTypes.push(nodeType);
          return true;
        },
      );

      if (newConverter.nodeTypes.length) {
        filteredConverters.push(newConverter);
      }
    }

    const childrenText = await convertLexicalNodesToHTML({
      converters: filteredConverters,
      lexicalNodes: node.children,
      parent: {
        ...node,
        parent,
      },
    });

    const rel: string = node.fields.newTab
      ? ' rel="noopener noreferrer"'
      : '';

    const target: string = node.fields.newTab ? ' target="_blank"' : '';

    let href: string = '';
    if (node.fields.linkType === 'custom') {
      href = node.fields.url;
    } else if (
      !!node.fields.doc?.value &&
      node.fields.doc?.relationTo &&
      typeof node.fields.doc?.value === 'string' &&
      !node.fields.doc.value.startsWith('http')
    ) {
      const document = await payload.findByID({
        id: node.fields.doc.value,
        collection: node.fields.doc.relationTo,
      });
      href = `/${document.slug}`;
    } else {
      href = (node.fields.doc?.value as string) ?? '';
    }

    return `<a href="${href}"${target}${rel}>${childrenText}</a>`;
  },
  nodeTypes: [LinkNode.getType()],
};

export default LinkHTMLConverter;

Copy link
Contributor

github-actions bot commented Sep 7, 2024

This issue has been automatically locked.
Please open a new issue if this issue persists with any additional detail.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Sep 7, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
plugin: richtext-lexical @payloadcms/richtext-lexical
Projects
None yet
Development

No branches or pull requests

2 participants