reactjs - 在 Next.js 中使用 createPortal 时出现 "Target container is not a DOM element"错误

标签 reactjs next.js react-portal

我正在尝试使用 slate-react 制作一个编辑器。我制作了悬停菜单,但 Next.js 渲染存在样式问题。所以我尝试在 Next.js 的默认 ID __next 下使用 React createPortal。但是我收到了 Error: Target container is not a DOM element. 错误。

下面是我的代码:

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;

我将 div 存储在根变量中并将其作为第二个参数传递给 ReactDOM.createPortal

最佳答案

您应该只在客户端调用 createPortal,当您实际上可以检索需要传递给它的容器元素时,并避免 SSR 问题。

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

但是,我建议您将 Portal 创建逻辑封装到它自己的组件中,如官方 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;
}

然后您可以在您的示例中使用它,如下所示。

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

    return (
        <ClientOnlyPortal selector="#__next">
            <Portal>
                // Remaining JSX here
            </Portal>
        </ClientOnlyPortal>
    );
};

关于reactjs - 在 Next.js 中使用 createPortal 时出现 "Target container is not a DOM element"错误,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/69356851/

相关文章:

javascript - NextJS - 使用浅路由器推送时无法识别页面组件上发生状态更改的位置

javascript - 在 Framer Motion 中错开 sibling 来执行共享按钮动画?

html - 无法将 `Dialog/Modal` 从使用 React Portal 和 Tailwind CSS 的 `@headlessui/react` 居中?

javascript - 如何访问 ReactJS 中复选框的状态?

reactjs - 为什么我的 create-react-app 显示 README.md,而不是 index.html?

javascript - 调用render方法时ReactJS是 "clever"吗?

javascript - P5 的 react 速度为 60 FPS

reactjs - React Typescript Hook 错误 - 该表达式不可调用

javascript - 使用 Portal React 打开新标签页