Code Monkey home page Code Monkey logo

use-editable's Introduction

Use Editable — Formidable, We build the modern web

A small React hook to turn elements into fully renderable & editable content surfaces, like code editors, using contenteditable (and magic)


NPM Version License Minified gzip size

useEditable is a small hook that enables elements to be contenteditable while still being fully renderable. This is ideal for creating small code editors or prose textareas in just 2kB!

It aims to allow any element to be editable while still being able to render normal React elements to it — no innerHTML and having to deal with operating with or rendering to raw HTML, or starting a full editor project from scratch.

Check out the full demo on CodeSandbox with prism-react-renderer!

Usage

First install use-editable alongside react:

yarn add use-editable
# or
npm install --save use-editable

You'll then be able to import useEditable and pass it an HTMLElement ref and an onChange handler.

import React, { useState, useRef } from 'react';
import { useEditable } from 'use-editable';

const RainbowCode = () => {
  const [code, setCode] = useState('function test() {}\nconsole.log("hello");');
  const editorRef = useRef(null);

  useEditable(editorRef, setCode);

  return (
    <div className="App">
      <pre
        style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}
        ref={editorRef}
      >
        {code.split(/\r?\n/).map((content, i, arr) => (
          <React.Fragment key={i}>
            <span style={{ color: `hsl(${((i % 20) * 17) | 0}, 80%, 50%)` }}>
              {content}
            </span>
            {i < arr.length - 1 ? '\n' : null}
          </React.Fragment>
        ))}
      </pre>
    </div>
  );
};

And just like that we've hooked up useEditable to our editorRef, which points to the <pre> element that is being rendered, and to setCode which drives our state containing some code.

Browser Compatibility

This library has been tested against and should work properly using:

  • Chrome
  • Safari
  • iOS Safari
  • Firefox

There are known issues in IE 11 due to the MutationObserver method being unable to read text nodes that have been removed via the contenteditable.

FAQ

How does it work?

Traditionally, there have been three options when choosing editing surfaces in React. Either one could go for a large project like ProseMirror / CodeMirror or similar which take control over much of the editing and rendering events and are hence rather opinionated, or it's possible to just use contenteditable and render to raw HTML that is replaced in the element's content, or lastly one could combine a textarea with an overlapping div that renders stylised content.

All three options don't allow much customisation in terms of what actually gets rendered or put unreasonable restrictions on how easy it is to render and manage an editable's content.

So what makes rendering to a contenteditable element so hard?

Typically this is tough because they edit the DOM directly. This causes most rendering libraries, like React and Preact to be confused, since their underlying Virtual DOMs don't match up with the actual DOM structure anymore. To prevent this issue use-editable creates a MutationObserver, which watches over all changes that are made to the contenteditable element. Before it reports these changes to React it first rolls back all changes to the DOM so that React sees what it expects.

Furthermore it also preserves the current position of the caret, the selection, and restores it once React has updated the DOM itself. This is a rather common technique for contenteditable editors, but the MutationObserver addition is what enables use-editable to let another view library update the element's content.

What's currently possible?

Currently either the rendered elements' text content has to eventually exactly match the code input, or your implementation must be able to convert the rendered text content back into what you're using as state. This is a limitation of how contenteditable's work, since they'll only capture the actual DOM content. Since use-editable doesn't aim to be a full component that manages the render cycle, it doesn't have to keep any extra state, but will only pass the DOM's text back to the onChange callback.

Using the onChange callback you'll also receive a Position object describing the cursor position, the current line number, and the line's contents up until the cursor, which is useful for auto-suggestions, which could then be applied with the update function that useEditable returns to update the cursor position.

API

useEditable

The first argument is elementRef and accepts a ref object of type RefObject<HTMLElement> which points to the element that should become editable. This ref is allowed to be null or change during the runtime of the hook. As long as the changes of the ref are triggered by React, everything should behave as expected.

The second argument is onChange and accepts a callback of type (text: string, pos: Position) => void that's called whenever the content of the contenteditable changes. This needs to be set up so that it'll trigger a rerender of the element's contents.

The text that onChange receives is just the textual representation of the element's contents, while the Position it receives contains the current position of the cursor, the line number (zero-indexed), and the content of the current line up until the cursor, which is useful for autosuggestions.

The third argument is an optional options object. This accepts currently two options to change the editing behavior of the hook:

  • The disabled option disables editing on the editable by removing the contentEditable attribute from it again.
  • The indentation option may be a number of displayed spaces for indentation. This also enables the improved Tab key behavior, which will indent the current line or dedent the current line when shift is held (Be aware that this will make the editor act as a focus trap!)

When options.indentation is set then useEditable will prevent the insertion of tab characters and will instead insert the specified amount of whitespaces, which makes handling of columns much easier.

Additionally the useEditable hook returns an Edit handle with several methods, as documented below.

Edit.update

Edit.update(content: string): void

Replaces the entire content of the editable while adjusting the caret position. This will shift the caret by the difference in length between the current content and the passed content.

Edit.insert

Edit.insert(append: string, offset?: number): void

Inserts new text at the caret position while deleting text in range of the offset (which accepts negative offsets). For example, when offset is set to -1 then a single character is deleted to the left of the caret before inserting any new text. When it's set to 2 then two characters to the right of the carets are deleted. The append text may also be set to an empty string to only apply deletions without inserting any text. When any text is selected then it's simply erased first and offset is ignored.

Edit.move

Edit.move(pos: number | { row: number; column: number }): void

This moves the caret to the specified position. The position may either be a character index (a number) or coordinates specifying a row and column separately.

Edit.getState

Edit.getState(): { text: string; position: Position }

This method allows getting the current state of the editable, which is the same as what onChange usually receives. This is useful when adding custom editing actions in a key down handler or when programmatically imitating onChange otherwise, while the editable is selected.

Acknowledgments

  • react-live, which I've worked on had one of the early tiny contenteditable editors. (But with raw HTML updates)
  • react-simple-code-editor was the first (?) library to use a split textarea and rendering surface implementation, which presented what a nice editing API should look like.
  • codejar contains the best tricks to manage selections, although it lacks some Firefox workarounds. It also uses raw HTML highlighting / updating.
  • codemirror.next is an invaluable source to see different techniques when handling text input and DOM update tricks.

Maintenance Status

Stable: Formidable is not planning to develop any new features for this project. We are still responding to bug reports and security concerns. We are still welcoming PRs for this project, but PRs that include new features should be small and easy to integrate and should not include breaking changes.

use-editable's People

Contributors

cpresler avatar jpdriver avatar kitten avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

use-editable's Issues

/n line added to contentEditable

Hi to everyone, thank you for the hook.

I've been using it and everything ok except for the /n line which causes that I cannot get the real length of the string to call a func or get and empty component to show a placeholder via css (I ended up making another empty component to render when the length was === 1).

Is there any possible solution?

n

Again, thank you!

No parent error.

Hey @kitten✋ , I was working on my codesandbox dropdown(not ready yet), then I realized something. When I use setCode sometimes it gives me this error:
Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
I worked on it for many hours and I found here's the problem:
line: 295, file: useEditable.ts

mutation.target.removeChild(mutation.addedNodes[i]);

some mutation.addedNodes[i] don't have parents(parentNode , ... = null) and they don't know mutation.target as their parent node.
so I convert the ts to js and I edited the above piece like this:

if (mutation.addedNodes[i].parentNode) {
              mutation.target.removeChild(mutation.addedNodes[i]);
 }

and it worked successfully.
in the Edit.js of the codesandbox you can comment the regular import and uncomment the relative import comment.

// import { useEditable } from "use-editable";
import { useEditable } from "./useEditable";

to see the successful result.
Can I make the PR for it?

onSelect event is not firing in Chrome & Safari

Hi @kitten, thanks for this useful hook!

I ran into an issue where the onSelect event is not firing in browsers that support contenteditable="plaintext-only" (like Chrome & Safari). This is due to the fact that React only fires the onSelect event when contenteditable="true".

I created a sandbox to demonstrate this (see console.log):
https://codesandbox.io/s/awesome-nightingale-s2gjb?file=/src/App.js
The above example works in Firefox (since it doesn't support plaintext-only).

This event would be handy to get the caret position when the user is navigating with the ← →-keys.

UseEditable keeps `state.position` after blur, and steals focus on rerender

Hi! I have

  • an "editable" div, next to
  • a text input that controls the styling div's text.

The text input is not controlled by use-editable. When the text input's value changes, the editable div rerenders.

If I have never edited the contents of the div, focus stays inside the text input as expected. If I first type some content into the div and then type inside the text input, the focus jumps into the div.

I can put together a simple snippet demonstrating if it'd help you reproduce the issue. But I think the main idea is that

  • state.position starts null here, and things behave fine.
  • Later, when state.position is non-null, use-editable forcibly focuses the div on rerender. I think. Maybe this line.

Not sure of the ideal solution -- listening for blurs, or checking the window's selection or something. I've worked around it by forcing the div to remount on blur, which isn't ideal but it's not blocking me.

(Very cool library, thanks for writing it!)

Dropdown-example

I've been working on a dropdown example, and I made it. I hope you like it and accept it. I'll be so happy with your opinions. codesandbox

Contribution

Hey @kitten, I really loved useEditable, And I'm reading its magical source as a junior, Do you have any idea that I can add to the project or some todos.
Thanks.

Feature Suggestion: Disable line break

Hey @kitten, thanks for this project, it's been helping me out a lot (dealing with contentEditable was a pain before I found your package :)

So I wanted to suggest that a user could pass an option to prevent the content from breaking lines... This would be particularly useful for my project, since I don't want the users to add break lines, and if you don't mind, I'd like to open a PR to add it to your main package.

My current "fix" for this looks something like this:

useEditable(titleRef, (txt) => {
    setData({
      ...data,
      title: txt.replace(/\n/g, '')
    })
  })

That's not working so great because whenever I hit ENTER, the caret loses the position, and the line break kinda blinks for a milisecond, so I imagine there might be a smarter way to disable that if I dig into your code a little bit, probably an if around here: https://github.com/kitten/use-editable/blob/main/src/useEditable.ts#L406

Thanks :)

Bug: Caret not positioning correctly when not clicking directly on the text

Hello! First of all, thank you for building such an amazing hook it is being useful!

While my development I've spotted a weird behavior when using it which I'm not sure how to fix. A video would be ten times more useful than words so here you have:

Screen.Recording.2021-08-12.at.04.51.17.PM.mp4

I'm not sure why does it happens but is something super annoying for some users. Could you help me?

Support meta data over data attributes

Hi, I was fiddling around and found a way to associate data with text that is being managed by useEditable.

Right now the use cases for use-editable would be limited because you always get back just textContent, so if you rendered any additional stuff to the dom - it gets lost because not representable by text, which I know is by design, but we can actually quite easily achieve a more versatile rendering engine.

We can serialize also dataset and not just textContent, basically just {...node.dataset}. I changed the internals from toString() to produce an object with a .toString() method Content<Item>, where Item generic is defined by useEditable caller. For all manipulations we rely on .toString() representation, but in onChange we return content: Content.

This has allowed me to move dom serialization logic to the caller and caller defines how things get serialized, while use-editable stays very small and doesn't care at all what the format is, except that it should provide a .toString() method

export interface Content<Item> {
  items: Array<Item>;
  toString: () => string;
}

In the end, it allowed me to attach an id to elements and in onChange callback convert content to react elements that have data awareness. A complete wysiwyg can essentially be built on top of this.

I can provide a POC PR with what I did

Feature Request: Select All

Thanks for the great library.

Is it possible to expose selectall functionality? I can achieve this by using the following on click, but it seems like programmatic control over range selections is possible with the library.

document.execCommand("selectAll", false, undefined);

Something like Edit.select(range)?

Setting contentEditable without setAttribute does not set the attribute in js-dom

Hello, I've been using your hook on a project feature and when writting tests I noticed that the attribute was not being set. Performing a bit of investigation I detected that we set the contentEditable attribute by doing the next:

element.contentEditable = 'BLA BLA'

After testing it I noticed that even though my test is getting inside the function the property was not being set. So I decided to modify the library and test if using setAttribute fixed the issue and it worked:

element.setAttribute('contenteditable', 'plaintext-only');

What's the reason behind setting directly the property?

Example code seems to be broken

Lovely approach, thanks a lot!

It may be due to browser (latest Chrome) or React version (17), but I THINK it's because the sample in the README is broken.

When your cursor is after the last character and you press enter, nothing happens. The second time you press enter it adds an enter to the top of the area, not really the intention of anyone I think.

In the Codesandbox everything works fine. When comparing I found that the example in the README only adds a line when not at the end of the array, while the Codepen always adds one.

README: { i < arr.length - 1 ? '\n' : null }
Codesandbox: { "\n" }

For me at least it appears the README example is fixed when always adding a \n. I know how sensitive these things can be with all browsers, so wanted to verify this is a mistake in the README and not done intentional to prevent other quirks.

Also it seems quite often Chrome loses focus after first getting focus for the first time and typing one character. I can think of some ugly workarounds, but perhaps also something I'm missing, since Codepen code is again not exhibiting this quirk.

Code used as good as a copy paste from README.

EDIT: Just confirmed that the Codesandbox is also broken when replacing it with the code from the README, e.g. "\n" to {i < tokens.length - 1 ? '\n' : null}

First selection + delete/backspace press does not work

This can be reproduced in codepen example:

  • Click anywhere on the text
  • Press and hold [delete] or [backspace] keys
    Desired behavior: text deletion should continue until user stop pressing the key
    Current behavior: selection is lost after one or two characters are deleted

Ctrl + Z also works erratically.

Note: this happens only on first time selecting it.
I managed to get it working OK by re-rendering the component (not an ideal solution).

Defocus every first typing

When you try to type the text for the first time, it will re-render and defocus from the input box.
So you need to select the input box and type again (it's annoying)

My workaround is force re-rendering for the first load. Is there another solution to fix this problem ??

Here is an example: code sandbox.

Environments

- astro: 4.0.3
- @astrojs/react: 3.0.7
- react: 18.2.0
- use-editable: 2.3.3
- prism-react-renderer: 2.3.0

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.