Getting "Target container is not a DOM element" error when using createPortal in Next.js
Asked Answered
H

2

5

I am trying to make an editor using slate-react. I have made a hover menu but there is a styling issue with Next.js rendering. So I am trying to createPortal using React under Next.js' default id __next. But I'm getting Error: Target container is not a DOM element. error.

Here is my code below:

import React, { useRef, useEffect, useState } from "react";
import ReactDOM from "react-dom";
import { ReactEditor, useSlate } from "slate-react";
import { Button } from "@material-ui/core";
import { Menu, Portal } from "./component";
import FormatBoldIcon from "@material-ui/icons/FormatBold";
import FormatItalicIcon from "@material-ui/icons/FormatItalic";
import FormatUnderlinedIcon from "@material-ui/icons/FormatUnderlined";
import TextFieldsIcon from "@material-ui/icons/TextFields";
import FormatSizeIcon from "@material-ui/icons/FormatSize";
import FormatQuoteIcon from "@material-ui/icons/FormatQuote";
import LinkIcon from "@material-ui/icons/Link";
import LinkOffIcon from "@material-ui/icons/LinkOff";
import {
  Editor,
  Transforms,
  Text,
  Range,
  Element as SlateElement,
} from "slate";

import { css } from "@emotion/css";

const LIST_TYPES = ["numbered-list", "bulleted-list"];

const HoveringToolbar = () => {
  const ref = useRef();
  const editor = useSlate();
  const [mount, setMount] = useState(false);
  var root = null;
  //var root;

  //window.document.getElementById("__next");
  useEffect(() => {
    // Will be execute once in client-side
    setMount(true);
    return () => setMount(false);
  }, []);

  useEffect(() => {
    const el = ref.current;
    const { selection } = editor;
    if (!el) {
      return;
    }
    if (
      !selection ||
      !ReactEditor.isFocused(editor) ||
      Range.isCollapsed(selection) ||
      Editor.string(editor, selection) === ""
    ) {
      el.removeAttribute("style");
      return;
    }

    const domSelection = window.getSelection();
    const domRange = domSelection.getRangeAt(0);
    const rect = domRange.getBoundingClientRect();
    el.style.opacity = "1";
    el.style.top = `${rect.top + window.pageYOffset - el.offsetHeight}px`;
    el.style.left = `${
      rect.left + window.pageXOffset + 150 - el.offsetWidth / 2 + rect.width / 2
    }px`;
  });

  if (mount) {
    root = document.getElementById("__next");
  }

  //const root = Document.getElementById("__next");

  return ReactDOM.createPortal(
    <Portal>
      <Menu
        ref={ref}
        className={css`
          padding: 8px 7px 6px;
          position: absolute;
          height: 60px;
          margin-top: -6px;
          background-color: rgba(17, 105, 84, 0.94) !important;
          border-radius: 4px;
          transition: opacity 0.75s;
          display: flex;
          justify-content: center;
          align-items: center;
          box-sizing: border-box;
          z-index: 999;
        `}>
        <FormatButton format='bold' icon='FormatBoldIcon' />
        <FormatButton format='italic' icon='FormatItalicIcon' />
        <FormatButton format='underline' icon='FormatUnderlinedIcon' />
        <BlockButton format='h1' icon='TextFieldsIcon' />
        <BlockButton format='h2' icon='FormatSizeIcon' />
        <BlockButton format='block-quote' icon='FormatQuoteIcon' />
        <LinkButton />
        <RemoveLinkButton />
      </Menu>
    </Portal>,
    root,
  );
};

const isFormatActive = (editor, format) => {
  const [match] = Editor.nodes(editor, {
    match: (n) => n[format] === true,
    mode: "all",
  });
  return !!match;
};

const toggleFormat = (editor, format) => {
  const isActive = isFormatActive(editor, format);
  Transforms.setNodes(
    editor,
    { [format]: isActive ? null : true },
    { match: Text.isText, split: true },
  );
};

const FormatButton = ({ format, icon }) => {
  const editor = useSlate();
  return (
    <button
      active={isFormatActive(editor, format)}
      onMouseDown={(event) => {
        event.preventDefault();
        toggleFormat(editor, format);
      }}>
      {icon === "FormatBoldIcon" ? (
        <img src='/images/icons/np_bold.svg' alt='bold' />
      ) : icon === "FormatItalicIcon" ? (
        <img src='/images/icons/np_italic.svg' alt='italic' />
      ) : icon === "TextFieldsIcon" ? (
        <TextFieldsIcon />
      ) : icon === "FormatSizeIcon" ? (
        <FormatSizeIcon />
      ) : (
        <FormatUnderlinedIcon />
      )}
    </button>
  );
};

const toggleBlock = (editor, format) => {
  const isActive = isBlockActive(editor, format);
  const isList = LIST_TYPES.includes(format);

  Transforms.unwrapNodes(editor, {
    match: (n) =>
      LIST_TYPES.includes(
        !Editor.isEditor(n) && SlateElement.isElement(n) && n.type,
      ),
    split: true,
  });
  const newProperties = {
    type: isActive ? "paragraph" : isList ? "list-item" : format,
  };
  Transforms.setNodes(editor, newProperties);

  if (!isActive && isList) {
    const block = { type: format, children: [] };
    Transforms.wrapNodes(editor, block);
  }
};

const BlockButton = ({ format, icon }) => {
  const editor = useSlate();
  return (
    <button
      active={isBlockActive(editor, format)}
      onMouseDown={(event) => {
        event.preventDefault();
        toggleBlock(editor, format);
      }}>
      {icon === "TextFieldsIcon" ? (
        <img src='/images/icons/np_text_large.svg' alt='heading' />
      ) : icon === "FormatQuoteIcon" ? (
        <img src='/images/icons/np_quote.svg' alt='quote' />
      ) : (
        <img src='/images/icons/np_text_small.svg' alt='small' />
      )}
    </button>
  );
};

const withLinks = (editor) => {
  const { insertData, insertText, isInline } = editor;

  editor.isInline = (element) => {
    return element.type === "link" ? true : isInline(element);
  };

  editor.insertText = (text) => {
    if (text && isUrl(text)) {
      wrapLink(editor, text);
    } else {
      insertText(text);
    }
  };

  editor.insertData = (data) => {
    const text = data.getData("text/plain");
    if (text && isUrl(text)) {
      wrapLink(editor, text);
    } else {
      insertData(data);
    }
  };

  return editor;
};

const insertLink = (editor, url) => {
  if (editor.selection) {
    wrapLink(editor, url);
  }
};

const isLinkActive = (editor) => {
  const [link] = Editor.nodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === "link",
  });
  return !!link;
};

const unwrapLink = (editor) => {
  Transforms.unwrapNodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === "link",
  });
};

const wrapLink = (editor, url) => {
  if (isLinkActive(editor)) {
    unwrapLink(editor);
  }

  const { selection } = editor;
  const isCollapsed = selection && Range.isCollapsed(selection);
  const link = {
    type: "link",
    url,
    children: isCollapsed ? [{ text: url }] : [],
  };

  if (isCollapsed) {
    Transforms.insertNodes(editor, link);
  } else {
    Transforms.wrapNodes(editor, link, { split: true });
    Transforms.collapse(editor, { edge: "end" });
  }
};

const LinkButton = () => {
  const editor = useSlate();
  return (
    <button
      active={isLinkActive(editor)}
      onMouseDown={(event) => {
        event.preventDefault();
        const url = window.prompt("Enter the URL of the link:");
        if (!url) return;
        insertLink(editor, url);
      }}>
      <LinkIcon />
    </button>
  );
};

const RemoveLinkButton = () => {
  const editor = useSlate();

  return (
    <button
      active={isLinkActive(editor)}
      onMouseDown={(event) => {
        if (isLinkActive(editor)) {
          unwrapLink(editor);
        }
      }}>
      <LinkOffIcon />
    </button>
  );
};

const isBlockActive = (editor, format) => {
  const [match] = Editor.nodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format,
  });

  return !!match;
};

export default HoveringToolbar;

I am storing div in the root variable and passing it to ReactDOM.createPortal as the second parameter.

Hydroxy answered 28/9, 2021 at 6:37 Comment(0)
G
4

You should only call createPortal on the client-side, when you can actually retrieve the container element that needs to be passed to it, and avoid SSR issues.

return mount ? ReactDOM.createPortal(...) : null;

However, I would suggest you encapsulate the Portal creation logic into its own component, as described in the official with-portals example.

import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';

export default function ClientOnlyPortal({ children, selector }) {
    const ref = useRef();
    const [mount, setMount] = useState(false);

    useEffect(() => {
        ref.current = document.querySelector(selector);
        setMount(true);
    }, [selector]);

    return mount ? createPortal(children, ref.current) : null;
}

You can then use it in your example as follows.

const HoveringToolbar = () => {
    // Remaining code

    return (
        <ClientOnlyPortal selector="#__next">
            <Portal>
                // Remaining JSX here
            </Portal>
        </ClientOnlyPortal>
    );
};
Gull answered 28/9, 2021 at 11:38 Comment(0)
R
0

You need to add <div id="portal"></div> to your public index.js

Rodneyrodolfo answered 15/7, 2022 at 22:41 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.