Documentation
- Introduction
- Getting Started
- EditableContentContextProvider
- EditableContent
- RenderedContent
- EditTextButton
- Browser Behavior and Text
- Known Issues
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:
-
An
EditableContentContextProvider, which must be an ancestor to the other components. -
An
EditableContentcomponent in which a user can write, delete, and edit text -
One or more
EditTextButtoncomponents, which provide the ability to add, remove, and break text wrappers within anEditableContentcomponent -
A
RenderedContentcomponent, which is a non-editable version ofEditableContent
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:
-
children: ReactNode, standard use of children in a React context -
keyAndWrapperObjs: An array of typeKeyAndWrapperObj, which are the options for a user to wrap their text with. See "Defining Your Own React Component Wrappers" below -
initialHTML: (optional) string, which will be the HTML which initially populates anEditableContentorRenderedContentinstance -
initialProps: (optional) object of which each key corresponds to a portalId, and the value is an object of props with new values. For an example of this object, seeupdatePortalPropsin theuseEditableContentContextsection. When a portal is first created, its React component will populate with props from this object if there is a corresponding portalId that contains props to pass in.
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:
-
portalId: the id of the portal to which the wrapper will be appended -
getContext: a function which will be passed to your rendered wrapper automatically and will return theEditableContentContextProvider's value, similar touseEditableContentContext(more on that later)- IMPORTANT: Do NOT call
useEditableContentContextin the body of your wrapper. Because wrappers are passed as props to theEditableContentContextProvider, they do not initially render as descendants ofEditableContentContextProvider, and as such, this will cause an error withuseEditableContentContextchecking for a safe context. CallgetContextinstead. (For more on this issue, see "A Note On Contexts Used By Wrappers" below)
- IMPORTANT: Do NOT call
-
children: ReactNode, standard use of children in a React context
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:
-
A user types some text 'Alan Turing' into an
EditableContentcomponent -
The user, following the logic flow of the app, wants to indicate that this is a name
-
The user selects the text 'Alan Turing' and then clicks the 'NAME'
EditTextButton -
Clicking the button creates a React component which puts 'Alan Turing' into a div, but also adds the preceding text 'NAME:', so the total text content of the React component is now 'NAME: Alan Turing'
-
The user decides they are done with the text, so they click a button which removes the
EditableContentinstance and replaces it with aRenderedContentinstance. The React component's text is removed, the component is destroyed, and then the component is re-rendered in theRenderedContentinstance. However, because the component is rendering the text 'NAME' as a precedent to the text that it will receive, the full text of the component will now be 'NAME: NAME: Alan Turing'
-OR-
- If instead of rendering the text, the user instead decides they made a mistake and click the 'NAME' button again to remove the decoration, the React component will be removed, but the text 'NAME: Alan Turing' will then be rendered as plain text in its place.
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}
</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
-
contextInstanceIdRef: MutableRefObject<string>- This is a ref, the current value of which is a unique string which ties together the Provider with EditTextButtons and EditableContent to keep the
hasSelectionstate from being set to false when anEditTextButtonis clicked corresponding to that Provider. Note that there are known issues with this in strict mode/development, see "Known Issues" below.
- This is a ref, the current value of which is a unique string which ties together the Provider with EditTextButtons and EditableContent to keep the
-
contentRef: MutableRefObject<HTMLDivElement | null>- The contentRef is a ref object which corresponds to the
EditableContentorRenderedContentdiv which is being rendered to the DOM. This assignment is handled automatically by those components, and is one of several reasons why only one instance ofEditableContentorRenderedContentshould ever be rendered perEditableContentContextProviderat a time.
- The contentRef is a ref object which corresponds to the
-
contentRefCurrentInnerHTML: string- Stringified HTML representing the content of the
EditableContentorRenderedContentdiv, and should update on all changes.
- Stringified HTML representing the content of the
-
setContentRefCurrentInnerHTML: Dispatch<SetStateAction<string>>- The setter for
contentRefCurrentInnerHTMLand can be accessed directly, but is also called byEditableContentorRenderedContentnaturally as the HTML is changed (by user input or hydration of React components).
- The setter for
-
selectionToString: string- If the window's selection is inside of the contentRef, this is the
textContent(no markup) of the selection.
- If the window's selection is inside of the contentRef, this is the
-
setSelectionToString: Dispatch<SetStateAction<string>>- The setter for selectionToString, which is called when a selection is made within the
EditableContentdiv.
- The setter for selectionToString, which is called when a selection is made within the
-
selectionAnchorNode: Node | null- When the selection is within the
EditableContentdiv, this is the selection's anchor node. This is primarily used internally to trigger state updates when the selection changes.
- When the selection is within the
-
setSelectionAnchorNode: Dispatch<SetStateAction<Node | null>>- Setter for the selection anchor node, called automatically by a 'selectionchange' handler assigned to the document when
EditableContentis rendered.
- Setter for the selection anchor node, called automatically by a 'selectionchange' handler assigned to the document when
-
selectionAnchorOffset: number | null- When the selection is within the
EditableContentdiv, this is the selection's anchor offset. This is primarily used internally to trigger state updates when the selection changes.
- When the selection is within the
-
setSelectionAnchorOffset: Dispatch<SetStateAction<number | null>>- Setter for the selection anchor offset, called automatically by a 'selectionchange' handler assigned to the document when
EditableContentis rendered.
- Setter for the selection anchor offset, called automatically by a 'selectionchange' handler assigned to the document when
-
selectionFocusNode: Node | null- When the selection is within the
EditableContentdiv, this is the selection's focus node. This is primarily used internally to trigger state updates when the selection changes.
- When the selection is within the
-
setSelectionFocusNode: Dispatch<SetStateAction<Node | null>>- Setter for the selection focus node, called automatically by a 'selectionchange' handler assigned to the document when
EditableContentis rendered.
- Setter for the selection focus node, called automatically by a 'selectionchange' handler assigned to the document when
-
selectionFocusOffset: number | null- When the selection is within the
EditableContentdiv, this is the selection's focus offset. This is primarily used internally to trigger state updates when the selection changes.
- When the selection is within the
-
setSelectionFocusOffset: Dispatch<SetStateAction<number | null>>- When the selection is within the
EditableContentdiv, this is the selection's focus offset. This is primarily used internally to trigger state updates when the selection changes.
- When the selection is within the
-
hasSelection: boolean,- A boolean representing if the window's selection is within the
EditableContentdiv.
- A boolean representing if the window's selection is within the
-
setHasSelection: Dispatch<SetStateAction<boolean>>- The setter for hasSelection, which is called when the the contentRef's focus and blur events fire.
-
portals: Array<ReactPortal>- This is the array of ReactPortals which are appended to specific divs in the contentRef. These ReactPortals are directly rendered into the contentRef, and each portal has a key (referred to as the portalId) which is the unique id of the portal.
-
setPortals: Dispatch<SetStateAction<Array<ReactPortal>>>- This is the setter for portals and is called directly only in
EditableContentContextProvider, but is also called in other functions which are included inEditableContentContext.
- This is the setter for portals and is called directly only in
-
divToSetSelectionTo: HTMLElement | null- This is a div within the
EditableContentdiv that should be selected bywindow.getSelection()and represents a div which has (or is about to have) a portal appended to it.
- This is a div within the
-
setDivToSetSelectionTo: Dispatch<SetStateAction<HTMLElement | null>>- The setter for
divToSetSelectionTowhich is called inside of thecreateContentPortalfunction inEditableContentContextProvider.
- The setter for
-
prepareDehydratedHTML: (callback: (dehydratedHTML: string) => void) => void- This is a helper function which takes the
contentRefCurrentInnerHTMLand converts it into dehydrated html (all html added by React component wrappers removed), and then passes the dehydrated html to a callback taken as an argument. Internally, this is used to keepdehydratedHTMLup to date by callingprepareDehydratedHTML(setDehydratedHTML)whenever thecontentRefCurrentInnerHTMLis changed. Generally, usingdehydratedHTMLdirectly will likely suit your purposes.
- This is a helper function which takes the
-
updatePortalProps: (updateObj: PortalProps) => void- This is a function for updating one or more props passed to the portals rendered in
EditableContentorRenderedContent. The function takes anupdateObjof which each key corresponds to a portalId, and the value is an object of props with new values. For example:
{ 'some-portal-id': { 'prop-1-to-update': newValue1, 'prop-2-to-update': newValue2 }, 'other-portal-id': { 'prop-1-to-update': newValue1, 'prop-2-to-update': newValue3 } }- Each specified portalId will have its portal in
portalsreplaced with a clone of itself with the new values for any prop(s) specified. Portals which are not specified with a portalId will not be changed, and portals which are being changed do not need all props to be specified, the only changes that will be made will be the ones to explicitly included props. From the demo, here's an example of changing the props for all portals which thedata-button-key/dataKeyof 'propful-only':
// on componentBorderColor // change, updatePortalProps useEffect(function() { if (!contentRef.current) return; const divs = Array .from(contentRef .current .querySelectorAll( "div[data-button-key='propful-only']" ) ); const keys = divs .map(div => div .getAttribute('id') ?.split("portal-container-")[1] ); const updateObj = Object.assign( {}, ...keys.map(key => { if (typeof key != "string") { return {}; } // else return { [key]: { borderC: componentBorderColor } }; }) ); updatePortalProps(updateObj); }, [componentBorderColor]) - This is a function for updating one or more props passed to the portals rendered in
-
getAllPortalProps: () => PortalProps- Goes through the portals state and returns an object where the key is the portalId of a portal and the value is an object containing all props.
-
keyAndWrapperObjs: Array<KeyAndWrapperObj>- This is the same as the prop which is passed to
EditableContentContextProvider.
- This is the same as the prop which is passed to
-
updateContent: () => void- A function for re-formatting the
contentRef.currentdiv to keep it consistent with regard to text nodes, cleaning up empty elements, resetting the selection, passing the re-formatted HTML tosetContentRefCurrentInnerHTML, and then re-establishing focus oncontentRef.current.
- A function for re-formatting the
-
createContentPortal: (component: ReactElement, buttonKey: string) => string | undefined- A function which creates a portalId, a div which will house a ReactPortal, a ReactPortal for the passed in component, and extracts any selected text to be passed as a child to the component. This is exposed as a part of the context and is used internally, but is not recommended for direct use.
-
appendPortalToDiv: (containingDiv: HTMLDivElement) => void- A function which creates a React component and the ReactPortal which it will belong to, extracts the text from the containingDiv and passes it to the component, and attaches the ReactPortal to the containingDiv. This is exposed as a part of the context and is used internally, but is not recommended for direct use.
-
removePortal: (key: string) => void- Given a key, finds the portal with that portalId and removes it from portals.
-
updateSelection: () => void- Resets the scroll of the
contentRef.currentdiv, gets the current selection, and if it is withincontentRef.current, resets all of the state related to the selection (setAnchorNode,setAnchorOffset, etc.).
- Resets the scroll of the
-
dehydratedHTML: string- The inner HTML of
contentRef.currentstripped of all content which is marked as needing to be excluded from dehydrated, as well as all non-text nodes within a portal containing div. This is effectively the HTML with all React markup removed.
- The inner HTML of
-
resetPortalContainers: () => void- This function resets the portals by extracting the text from the portal containing divs, passing it to the cloned components and reestablishing the portals with their containing divs. This is used in both
EditableContentandRenderedContentto hydrate with React when theportalsstate is already populated.
- This function resets the portals by extracting the text from the portal containing divs, passing it to the cloned components and reestablishing the portals with their containing divs. This is used in both
-
assignContentRef: (newRef: null | HTMLDivElement) => void- This function assigns
contentRef.currentto the div which belongs to eitherEditableContentorRenderedContent.
- This function assigns
-
buttonUpdateTrigger: boolean- A boolean that is triggered when a button is clicked, simply to force the
EditableContentContextto update its state. This may be removed in later updates.
- A boolean that is triggered when a button is clicked, simply to force the
-
triggerButtonUpdate: () => void- Flips the value of
buttonUpdateTriggerto force a state update. This may be removed in later updates.
- Flips the value of
EditableContent
EditableContent is the component which houses the actual 'contenteditable' div. It takes only two props, both of which are optional:
-
className: string- The className which will be passed to the div for any desired CSS styling. This is especially useful for determining scroll behavior.
-
disableNewLines: boolean- If disableNewLines is true, pressing 'Enter' will prevent the default behavior of creating a
<br/>element to add a new line in the div. This is useful for creating<input>-like fields which do not extend beyond one line.
- If disableNewLines is true, pressing 'Enter' will prevent the default behavior of creating a
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:
-
className: string- The className which will be passed to the div for any desired CSS styling. This is especially useful for determining scroll behavior.
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
-
Each
EditTextButtonkeeps track of the ReactElement wrapper to which it is assigned. -
Each
EditTextButtonkeep track of a query which corresponds to the ReactElement, the most significant part of which is thedata-bkattribute. -
If all of the text in a selection is a descendant of one or more elements which match the query, the text is considered selected. If none or only some of the text in a selection matches the query, the text is not considered selected.
-
If the text is considered selected, the button will appear to be clicked. If the text is not considered selected, the button will not appear clicked.
-
If the focus is outside of the
EditableContentor the selected text may not be clicked (for example it is inside of an unbreakable element), the button will be disabled. -
If a button is not clicked, clicking it will first remove all elements matching the query in the selection, then will wrap the entire selection in a newly created element.
-
If a button is already clicked, clicking it will break up the surrounding element such that the selection will no longer be a part of an element which matches the query.
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.
-
No other element can exist within an unbreakable element, meaning that if the selection is within an unbreakable element, every
EditTextButtonwill be disabled, except for the one pertaining to that element. This also means that if the selection includes any element in whole or in part, theEditTextButtonfor an unbreakable component will be disabled. -
If a selection is partially in an unbreakable element and partially outside of it, the
EditTextButtonfor the unbreakable element will also be disabled. -
If a selection is inside of an unbreakable element, clicking the
EditTextButtonwill make the entire wrapper disappear, and in the case of a React component, will delete the component and remove it fromportals. The one exception to this is below. -
If a selection is inside of an unbreakable element and the cursor is collapsed and at the end of the text inside of the element, clicking the button will not affect the wrapper, but will move the cursor to the next available space after the wrapper. This is meant to intuitively follow as best as possible clicking a button to start a text style, then after finishing typing what should be in that style, "turning the style off" and continuing normally.
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:
-
isMUIButton?: boolean- If this is set to true,
EditTextButtonwill render as its base an MUIButtoncomponent instead of a plain<button>.
- If this is set to true,
-
dataKey: string- This is the key which must be the same as in one of the objects in the
KeyAndWrapperObjwhich is passed as a prop toEditableContentContextProvider. This key is responsible for tellingEditTextButtonwhich wrapper it is responsible for assigning/removing.
- This is the key which must be the same as in one of the objects in the
-
children?: ReactNode- The children of the button, ideally short text or an icon.
-
selectedClassName?: string- An optional prop which will add this string to the className if the button is selected.
-
deselectedClassName?: string- An optional prop which will add this string to the className if the button is deselected.
-
selectedVariant?: ButtonOwnProps["variant"]- An optional prop for use if
isMUIButtonis set to true. This will be the MUI variant the button takes on when the button is selected.
- An optional prop for use if
-
deselectedVariant?: ButtonOwnProps["variant"]- An optional prop for use if
isMUIButtonis set to true. This will be the MUI variant the button takes on when the button is deselected.
- An optional prop for use if
-
selectCallback?: htmlSelectCallback | reactSelectCallback- A function which will fire when the
EditTextButtonis clicked to become selected, to which the wrapper will be passed. If the wrapper is a React component, the portalId will be the second parameter passed to the function.
- A function which will fire when the
-
deselectCallback?: () => void | undefined- A function which will fire when the
EditTextButtonis clicked to make text deselected. This function is not passed any argument.
- A function which will fire when the
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.
- Pressing the spacebar will create a non-breaking space rather than a traditional space.
- Elements are padded with zero-width spaces so that the first and last character of text inside of any given element should both be zero-width spaces. These are being constantly managed by the processes involved in wrapping, unwrapping, and cleaning up text. Accordingly, these should not require any action from you in terms of getting these components to work correctly, but may require attention depending on how the actual content will be used in other parts of your application.
Known Issues
-
Undo
- Undoing (Ctrl+Z / Cmd+Z) does not work as desired as virtually all keyboard inputs invoke e.preventDefault, which among other things, prevents those inputs from being added to the browser's undo cache.
- In order to address this, it will likely be necessary to create a separate cache relative to each instance of
EditableContentorEditableContentContextProvider.
-
data-context-idassignment error in development mode- Upon loading in dev mode, an error is generated:
Warning: Prop `data-context-id` did not match. Server: "some-uuid-value" Client: "some-other-uuid-value".It is unclear why this error is occurring, but it does not happen outside of development mode. - Additionally unknown at the time is why clicking a button does not set the
hasSelectionstate to false, given that thedata-context-iddoes not match thecontextInstanceIdRef.currentin strict/development mode, and this should result in a call tosetHasSelection(false).
- Upon loading in dev mode, an error is generated:
-
This package includes @mui/material and @emotion/styled as dependencies to enable MUIbuttons. Because this is a large addition in terms of bundle, this may be changed in the future to either a conditional import or the MUI-enabled EditTextButton removed and placed in a new package.
