editable
editable-content logo
content
  • Main Demo
  • Stateful and Propful Components
  • Propful Only Components
  • Styling and Callbacks
  • Documentation

Documentation

Introduction

editable-content is a collection of components and context to give developers the ability to create limited rich-text editors, including React components to wrap text. At a high level, there are four basic components:

Only one EditableContent or RenderedContent should be rendered at a time per instance of EditableContentContextProvider.

Getting Started

To view the package on npm, visit https://www.npmjs.com/package/@kccpmg/editable-content. To install:

npm install @kccpmg/editable-content

Basic Example:

function EditableContentExample() {

  const editing = useState<boolean>(true);

  const keyAndWrapperObjs = [
    {
      dataKey: "strong",
      wrapper: <strong></strong>
    }
  ];

  return (
    <EditableContentContextProvider keyAndWrapperObjs={keyAndWrapperObjs}>
      {editing ? (
        <>
          <EditTextButton 
            dataKey="strong"
          >
            B
          </EditTextButton>
          <EditableContent />
        </>
      ) :
        <RenderedContent />
      }
    </EditableContentContextProvider>
  )
}

EditableContentContextProvider

The EditableContentContextProvider must be rendered as an ancestor to the EditableContent component. It takes only one prop (see Defining Your Own Wrappers) and will hold all the relevant state that the EditableContent and RenderedContent depend on. EditableContentContextProvider takes four props:

Populating Wrappers

A KeyAndWrapperObj is an object to be defined which will correspond to a text wrapper To make a wrapper available to your provider, it must be passed to the EditableContentContext in the prop keyAndWrapperObjs. This prop takes an array of objects, each of which represents a wrapper and has two key/value pairs:

type KeyAndWrapperObj = {
  dataKey: string
  wrapper: React.ReactElement,
}

In order to function properly, the value passed to dataKey must be unique among the objects in the array. Because the wrapper value must be a React.ReactElement, note that this can be either a functional React component or a simple (not nested) html wrapper. As for what is necessary for React wrappers, more on that next.

Defining Your Own React Component Wrappers

As a part of the rendering process, there are several props which will be passed to each React wrapper automatically, and should be included in the Type definition of your wrapper's Props. Those props are the following:

When declaring the PropTypes for your wrappers, make sure that any of the above props which you wish to access are declared as optional. Inside the component, make sure that any access of these props is conditional and safe.

Marking Content for Exclusion

When you 'unwrap' text from a React component, the process that is happening is that all of the text of that component is being extracted, the component is deleted, and then the text is put back in its correct place. For most purposes, this is fine for a text decoration, but there may be times where your React component aims to add text as part of the React component itself. For example, if you have a React component meant for a user to decree that text in a certain place is a name, you might have the following set of actions for a hypothetical app:

-OR-

This is obviously undesired behavior, but the EditableContentContextProvider can check for content which it should not "count" when it is removing a React component. The solution is that any html element which contains content which should not be factored in should be given the 'data-exclude-from-dehydrated' attribute. This is also exported from utils/constants as EXCLUDE_FROM_DEHYDRATED. Here is an example from the PropfulBox component in the demo site's "Propful Only" example:

return (
  <Box    
    onClick={increaseClicks} 
    {...rest}
  >
    <span 
      {...{[EXCLUDE_FROM_DEHYDRATED]: ""}}
    >
      {clickCount}&nbsp;
    </span>
    {children}
  </Box>
)

In this example, the clickCount is increased by clicking on the box and is rendered dynamically, but rendering the text or unwrapping the text from the React component will not cause the clickCount to be rendered as text.

A Note On Contexts Used By Wrappers

When a wrapper is passed to the keyAndWrapperObjs array it is initially called as a function at that level, meaning it will not initially have access to any context which is a descendant of the EditableContentContextWrapper.

For example:

function MyWrapper() {
  const { myValue } = useContext(MyContext);

  // return (...)
}

<EditableContentContextProvider
  keyAndWrapperObjs={[
    dataKey: "my-wrapper",
    wrapper: <MyWrapper />
  ]}
>
  <MyContextProvider>
    <EditableContent />
  </MyContextProvider>
</EditableContentContextProvider>

This will fail, because even though MyWrapper will only be rendered to the DOM within the EditableContent, which itself is within MyContextProvider, the initial call of the function is outside the scope of MyContextProvider

In order to use context, you can do one of two things. The first of which is to make all calls to context safe by using default destructuring logic, as below:

function MyWrapper() {
  const { myValue = 0 } = useContext(MyContext);

  // return (...)
}

The second option is to simply change the order in which the context providers render, so that MyWrapper is called within the scope of MyContextProvider from the beginning:

function MyWrapper() {
  const { myValue } = useContext(MyContext);

  // return (...)
}

<MyContextProvider>
  <EditableContentContextProvider
    keyAndWrapperObjs={[
      dataKey: "my-wrapper",
      wrapper: <MyWrapper />
    ]}
  >
    <EditableContent />
  </EditableContentContextProvider>
</MyContextProvider>

useEditableContentContext

useEditableContentContext is a simple custom hook which exposes the context at work in the EditableContentContextProvider component, and is the same hook which is used by the EditableContent and RenderedContent components themselves. If EditableContentContext is not found at the time that useEditableContentContext is called, it will throw an error. For that reason, it must not be used in your wrappers (as per "A Note on Contexts Used By Wrappers" above). However, this context can be consumed within your wrappers by using the getContext function.

Additionally, there may be other components you may wish to use within the scope of EditableContentContextProvider for the purposes of viewing or extracting data from the Provider. For example, you may wish to extract the dehydratedHTML for the purposes of processing, saving to a database, sending an API request, etc.

The following are the properties which can be extracted from useEditableContentContext

EditableContent

EditableContent is the component which houses the actual 'contenteditable' div. It takes only two props, both of which are optional:

The EditableContent component does not require anything beyond these props and being placed within the scope of an EditableContentContextProvider. Selection changes, portal hydration, etc. are handled internally.

A Note on EditableContent Rendering

EditableContent makes significant usage of useEffect to populate and update its text content, the selection, and React components. Because of the logic which operates directly on the DOM, 'strict mode' double-renders can cause DOM-modifying logic to restart before it's finished, resulting in the deletion of content. To counter this, there is a check within EditableContent to look at process.env.NODE_ENV, and if it is set to "development", the order of useEffect operations is altered to allow for the initial render to completely finish (both calls) before resuming the useEffect logic related to portals.

RenderedContent

RenderedContent is effectively a pared-down version of EditableContent, because it renders the same way but has no logic for keyboard inputs or selection changes. It takes only one prop, which is optional:

RenderedContent is affected by the same logic as EditableContent when it comes to renders, see "A Note on EditableContent Rendering" above.

EditTextButton

EditTextButton is the control used for wrapping and unwrapping selected text. Before getting into the component logic, it's important to understand how these buttons behave.

Basic Rules for EditTextButton Behavior

Special Rules for Unbreakable Elements

Some elements are considered "unbreakable", which means that they are affected by specific rules. An element is "unbreakable" either because it is specifically declared as being unbreakable (by passing it the attribute data-unbreakable="") or it is a React component. If an element is "unbreakable", the button will have slightly different behavior in some scenarios.

EditTextButton Props

The EditTextButton, in addition to the props below, accepts any props from MaterialUI's ButtonOwnProps (except for 'color'), as well as any props from React.ComponentPropsWithoutRef<'button'>. All of these props will be passed automatically to the Button/button. Here are the additional explicit props:

Browser Behavior and Text

By default, browsers take certain actions in managing white space and cursor placement, sometimes creating undesirable behavior. For example, typing in a given element, unwrapping the element, and then hitting the space bar would give the user an expectation of continuing to write in plain text from that point. However, Chrome will prevent this, because this would mean a new Text Node beginning with a space, which Chrome assumes is wrong, and so Chrome would extend the wrapper element over the cursor that the user just tried to break out of the previous wrapper.

As a result, a great deal of default behavior in using a contenteditable div has been overridden here.

Known Issues

Back to Main Demo