Suggestions for creating custom nodes and best practices

The provided code defines a custom floating menu component called CustomFloatingMenu in React. This component renders a menu with toggle buttons for bullet lists, ordered lists, articles, and images. When users interact with the menu, it triggers various actions such as toggling between different list types, opening a dialog to insert a document, or uploading and inserting an image. The component aims to provide a user-friendly interface for editing and enhancing content within an editor.

import React, { useState } from 'react';
import { isNodeEmpty } from '@tiptap/react';
import API from '@common/crud';

import { Menu, ToggleButtonGroup, ToggleButton } from '@mui/material';
import ArticleIcon from '@mui/icons-material/Article';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import ImageIcon from '@mui/icons-material/Image';

import InsertDocumentDialog from '@forethought/pages/document/menus/insert-document-dialog';

const CustomFloatingMenu = ({ anchorEl, onClose, editor, node, pos: nodeStartPos }) => {
  const [openDialog, setOpenDialog] = useState(false);
  const nodeSize = node.content.size;
  const emptyNodeStartPos = nodeStartPos + 2; // https://tinyurl.com/emptysize
  const endOfNodeContent = nodeStartPos + nodeSize;

  /**
   * @param {string} listType - 'toggleBulletList' or 'toggleOrderedList'
   */
  const handleToggleEmptyNode = listType => {
    editor.chain().focus(emptyNodeStartPos)[listType]().run();
  };

  const handleTogglePopulatedNode = listType => {
    editor.chain().focus(endOfNodeContent).run();
    editor.chain().enter().run();
    editor
      .chain()
      .focus(endOfNodeContent + 4) // Account for the enter command
      [listType]()
      .run();
  };

  const handleToggleList = listType => {
    if (isNodeEmpty(node)) {
      handleToggleEmptyNode(listType);
    } else {
      handleTogglePopulatedNode(listType);
    }
    onClose();
  };

  /**
   * @param {string} documentId
   */
  const handleDocumentInsert = documentId => {
    onClose();

    // Insert the document view node at the determined position
    const nextNodeStart = nodeStartPos + nodeSize + 2;
    const documentInsertPos = isNodeEmpty(node) ? emptyNodeStartPos : nextNodeStart;
    const documentViewNode = `<document-view data-guid="${documentId}"/>`;
    const insertOptions = {
      updateSelection: true,
      parseOptions: {
        preserveWhitespace: 'full'
      }
    };
    editor.commands.insertContentAt(documentInsertPos, documentViewNode, insertOptions);

    /**
     * Checks if a given position is out of range based on the size of the document content.
     *
     * @param {number} pos - The position to check.
     */
    const isOutOfRange = pos => {
      return pos > editor.view.state.doc.content.size;
    };
    const isFirstNodeEmpty = nodeStartPos === 0 && isNodeEmpty(node);

    const resolvedPos = isFirstNodeEmpty
      ? isOutOfRange(emptyNodeStartPos)
        ? documentInsertPos
        : emptyNodeStartPos
      : isOutOfRange(documentInsertPos + 2)
      ? documentInsertPos
      : documentInsertPos + 2;

    const {
      state: { schema, tr },
      view
    } = editor;

    // Insert node into the document
    const emptyNode = schema.nodes.paragraph.create();
    const transaction = tr.insert(resolvedPos, emptyNode);
    view.dispatch(transaction);

    // Set the selection to the newly inserted node
    const selection = view.state.selection.constructor.near(
      view.state.doc.resolve(resolvedPos)
    );
    view.dispatch(view.state.tr.setSelection(selection));

    setOpenDialog(false);
  };

  const handleArticleDialog = () => {
    setOpenDialog(true);
  };

  const handleImage = async e => {
    onClose();
    const file = e.target.files[0];
    const { url } = await API().createFile('/FrameworkAPI/files2', file, 'upload');
    editor.commands.insertContent(`<img src="${url}" />`).run();
  };

  return (
    <Menu
      sx={{
        '& .MuiList-root': {
          p: 0
        }
      }}
      open={!!anchorEl}
      anchorEl={anchorEl}
      onClose={() => onClose()}
    >
      <InsertDocumentDialog
        open={openDialog}
        onClose={() => setOpenDialog(false)}
        onInsert={handleDocumentInsert}
      />
      <ToggleButtonGroup
        size="small"
        sx={{
          backgroundColor: 'rgba(255,255,255,0.7)',
          backdropFilter: 'blur(5px)'
        }}
      >
        <ToggleButton
          value="bulletList"
          onClick={() => handleToggleList('toggleBulletList')}
        >
          <FormatListBulletedIcon fontSize="small" />
        </ToggleButton>
        <ToggleButton
          value="orderedList"
          onClick={() => handleToggleList('toggleOrderedList')}
        >
          <FormatListNumberedIcon fontSize="small" />
        </ToggleButton>
        <ToggleButton size="small" value="standard" onClick={() => handleArticleDialog()}>
          <ArticleIcon fontSize="small" />
        </ToggleButton>
        <ToggleButton size="small" value="image" component="label">
          <ImageIcon fontSize="small" />
          <input type="file" hidden onChange={handleImage} />
        </ToggleButton>
      </ToggleButtonGroup>
    </Menu>
  );
};

export default CustomFloatingMenu;

I provided the entire component for reference but I really want to focus on the handleDocumentInsert. When called, it determines the appropriate position within the existing content to incorporate the document. It creates a specific view node representing the document and inserts it at the determined position. Additionally, it takes care of setting the selection to the newly inserted document, ensuring a smooth transition for further editing. It adds an empty paragraph node and then sets the selection there. Overall, the function facilitates the seamless insertion of documents, enhancing the editing experience within the context of the application.

I’m very new to ProseMirror and Tip Tap in general and I’m just wondering if I’m on the right track here. This essentially works but is it good practice? Any suggestions would be great or directions to working sandboxes would help. I want to lay a good foundation for my work. Thanks for all the help!

I am just wondering, why you opted for Tip-tap with prose-mirror together. Tiptip was built on top of prose-mirror.

It’s optional to choose between the two, all the functionality Tiptap provides prose-mirror does, the overhead Tiptap brings is that it makes it very simple to start with.