draft-js icon indicating copy to clipboard operation
draft-js copied to clipboard

RFC: Composite entities

Open johanneslumpe opened this issue 8 years ago • 26 comments

This is more of a brainstorming/discussion issue. Currently we can only apply a single entity per character. For simple things this is totally fine. But as soon as we want to handle more complex things like adding text when applying an entity, things get hairy.

Imagine we have the following situation: A user can highlight some text and assign some kind of highlight to it. This highlight should:

  • Provide a link to an external page
  • Append a link which links to an anchor on the same page (a collection of all used links)

Since decorators currently must not modify the text they are decorating (for obvious reasons), we cannot just append the link in the decorator. This has to happen when adding the entity, so we can modify the state correctly.

So here's my (high-level) idea: When adding an entity to a text we can specify a composite entity, which allows us to subdivide the range we want to annotate into sub-types. It could look something like this:

const currentContentState = editorState.getCurrentContent();
const targetRange = editorState.getSelection();

const metaData = { someId: 5 };

// just a dummy value, would be extracted from the selection/text or could
// be custom if we want to append text to the selection but decorate that specific piece of text
const rangeInfo = [[0,10], [11, 13]];

const entityKey = Entity.createComposite('MY_ENTITY', 'MUTABLE', rangeInfo, metaData);
const contentStateWithEntity = Modifier.applyEntity(
  currentContentState,
  targetRange,
  entityKey
);
const newEditorState = Draft.EditorState.push(
  editorState,
  contentStateWithCitation,
  'add-my-entity',
);

The createComposite method would create a special composite entity, which then can be used by draft to figure out how to split up the decorated text into multiple children, before passing them to the decorator.

Our decorator could then look like this:

const myEntityDecorator = ({ children, entityKey }) => {
  const [firstChild, secondChild] = children;
  const { someId } = Entity.get(entityKey).getData();
  return (
    <span>
      {firstChild}
      <a href={`#${someId}`}>
        {secondChild}
      </a>
    </span>
  );
});

I am aware that the above can be achieved in a slightly different way by adding two separate entities, but by doing that we lose the ability to make the them immutable (we could remove either one of them and the other one wouldn't be affected) and we would need two different entity types, even though it really only should be a single one.

Crazy?

johanneslumpe avatar Mar 29 '16 11:03 johanneslumpe

I've been having similar thoughts, around how to handle highlighted ranges as entities, and more generally about how to have nested entities at all. The use case that I find most compelling (and seemingly impossible to solve now?) is:

paragraph
  - text
paragraph
  - text
paragraph
  - image
paragraph
  - text
  - text (bold)
  - text

With that structure, I'd like to add a comment/highlight entity that spans multiple blocks, including the one with the image. Or similarly:

paragraph
  - text
  - hashtag
  - text

Even inside a single block, I'd like to be able to add a comment that encompasses the hashtag and other text. (Is this currently possible?)

I seems non-trivial, but it also extremely useful (and the highlighting case seems very valid) so I would love to have it considered more.


This makes me wonder whether it would instead make sense to try to find a simpler solution (in terms of number of moving parts) by changing entities to be nest-able, and then removing the concept of "inline style" altogether. If entities could be nestable, and apply to many ranges, they could effectively be the same as style ranges. What you might end up with instead is just the concept of InlineComponent and BlockComponent.

Which might be described as:

BlockComponent
  • As they work currently.
  • Has a type.
  • Has associated metadata.
  • Can contain text and ranges.
  • Can be nested inside other block components, like <li> currently can with depth.
InlineComponent
  • Are defined in a single definition map by key, at the top-level of ContentState.
  • Has a type.
  • Has associated metadata.
  • Are applied to text via a range, with range.key, range.offset and range.length.
  • Can be MUTABLE, in which case deleting works by removing the text and length--.
  • Can be IMMUTABLE, in which case deleting works by removing all instances of the inline component by key.
  • Can be SEGMENTED, in which case deleting is special-cased.

Note that this gives us a nice simple parallelism between all components:

*Component
  • Has a type.
  • Has associated metadata.

In that case, here's an example of how things might be defined:

Paragraph
kind: block
type: paragraph
key: ...
metadata: (none)
Bold
kind: inline
type: bold
key: ...
metadata: (none)
Inline Image
kind: inline
type: inline_image
key: ...
metadata:
  src: ...
  title: ...
  alt: ...
Inline Image
kind: block
type: block_image
key: ...
metadata:
  src: ...
  title: ...
  alt: ...
Link
kind: inline
type: link
key: ...
metadata:
  href: ...
  title: ...
  target: ...
Embed
kind: block
type: embed
key: ...
metadata:
  url: ...
  type: ...

Further, using the logic from the discussion around "atomicity" for blocks we could even streamline the concept of immutable, mutable, and segmented from InlineComponents with that of mutability from BlockComponents by instead treating them as nested component in the render function.

Imagine a BlockImage implemented as:

<ImmutableBlockComponent key={props.key}>
  <img src={props.src} alt={props.alt} title={props.title} />
</ImmutableBlockComponent>

The same way that you can currently nest pieces with the ContentBlock utility if you want to add additional functionality, but maintain all of the functionality that makes a block editable. For example a blockquote:

<blockquote>
  <MutableBlockComponent key={props.key}>
    {props.text}
  </MutableBlockComponent>
</blockquote>

Inlines might be handled the same way:

<a href={props.href} title={props.title} target={props.target}>
  <MutableInlineComponent key={props.key}>
    {props.text}
  </MutableInlineComponent>
</a>

And then segmented can either be special-cased separately, or be an entirely custom component, in which case you don't need to wrap with any built-in <*Component> elements.


There's a good chance I'm poorly understanding a few of the internals, so some of that might not make sense, if so let me know! Or if anything's unclear. Hopefully it's useful for discussion!

ianstormtaylor avatar Mar 29 '16 16:03 ianstormtaylor

@ianstormtaylor That sounds interesting, though I feel that this requires a rather drastic change. The beauty of only allowing a single entity per character lies in the simplicity of it. I do agree though that if somebody wants to highlight a line which also contains a mention or something else, then this falls apart quickly.

I'm wondering how this model would work if you have a mention in a text and you then want to add a comment which pans half of the mention and some text before it. Would you then have to next half a mention within the comment decorator? Or half a comment within the mention decorator?

johanneslumpe avatar Mar 29 '16 20:03 johanneslumpe

From the conversation with @hellendag from Slack, the takeaways were:

  • Nesting of decorators would get very complex, best to avoid this if possible. Worst case allow a single level of nesting, but still complex.
  • The highlighting use case is a valid one though.
  • We should be able to handle the highlighting use case with inline style ranges, if we can associate metadata with those ranges. This keeps the rendering and functionality simple and handled completely in core, so there's no complexity exposed to the developer.

After getting a better understanding, I do think there's value in separating the concepts of "blocks" from "decorators" from "inline styles", as three layers in the content view hierarchy. And highlighting having metadata associated with it seems very promising!

ianstormtaylor avatar Mar 29 '16 22:03 ianstormtaylor

Would love to have conditional inline styles, we are using decorator to do real time validation (regex check) of markers. (Correct markers in green, otherwise in red etc.) Currently we have to implement it using a decorator, but it has conflict with Link decorator, since decorator cannot be nested by default. I solved that by using a customized decorator class which allow decorator nesting, but it is very limited to our use case.

import { List } from 'immutable';

const DELIMITER = ':';
const DECORATOR_DELIMITER = ',';

function occupySlice(targetArr/* Array<?string>*/, start, end, componentKey) {
  for (let ii = start; ii < end; ii++) {
    if (targetArr[ii]) {
      targetArr[ii] = targetArr[ii] + DECORATOR_DELIMITER + componentKey; // eslint-disable-line
    } else {
      targetArr[ii] = componentKey; // eslint-disable-line no-param-reassign
    }
  }
}
export class NestableDecorator {
  constructor(decorators) {
    // Copy the decorator array, since we use this array order to determine
    // precedence of decoration matching. If the array is mutated externally,
    // we don't want to be affected here.
    this.decorators = decorators.slice();

    // We must cache the components, otherwise every time try get a component
    // using getComponentForKey would be a new object, that makes draft buggy
    this.componentCache = {};
  }

  getDecorations(block/* ContentBlock*/) { // return immutable List
    const decorations = Array(block.getText().length).fill(null);
    this.decorators.forEach(
      (/* object*/ decorator, /* number*/ ii) => {
        let counter = 0;
        const strategy = decorator.strategy;
        strategy(block, (/* number*/ start, /* number*/ end) => {
          occupySlice(decorations, start, end, ii + DELIMITER + counter);
          counter++;
        });
      }
    );
    return List(decorations); // eslint-disable-line new-cap
  }

  getComponentForKey(key) {
    if (this.componentCache[key]) return this.componentCache[key];
    const components = key.split(DECORATOR_DELIMITER)
      .map(k => parseInt(k.split(DELIMITER)[0], 10))
      .map(k => this.decorators[k].component);

    this.componentCache[key] = (props) => // composite component
      components.reduce((children, Outer) => 
        <Outer { ...props } children={children} />, props.children);
    return this.componentCache[key];
  }

  getPropsForKey(key) {
    const pps = key.split(DECORATOR_DELIMITER)
      .map(k => parseInt(k.split(DELIMITER)[0], 10))
      .map(k => this.decorators[k].props);
    return Object.assign({}, ...pps);
  }
}

ouchxp avatar May 23 '16 22:05 ouchxp

An example scenario: Let's say you've got a block with an inline TeX entity and some text, I want to apply a link to the complete selection (inline TeX and the text).

So this is basically not possible now?

bryanph avatar Aug 25 '16 19:08 bryanph

@ouchxp thanks very much for sharing your code. It is very helpfull, but i am little confused how it should work. In my project i use NestebleDecorator class instead of CompositeDecorator class right? And every single decorator stay same without any needed changes? So when specified text of block match multiple strategies, this text of block will be automatically nested with multiple components?

Thanks very much for any kind of advice or answer

Tommy10802 avatar Oct 06 '16 09:10 Tommy10802

@Tommy10802 Yes, to all your questions.

When draftjs trying to render decorator components it will call getDecorations first. This function will iterate through all decorators, matching the text range according given strategies, and then mark the range with a componentKey. What I did is making this function can handle overlapping ranges. Each character in the overlap will have multiple component keys, seperated by DECORATOR_DELIMITER.

The second part is creating component, draftjs will call getComponentForKey to create component by the componentKey it retrived from getDecorations function (which is a string consist of DECORATOR_DELIMITER seperated componentKeys now). This function split the key using DECORATOR_DELIMITER and create a nested component, then return to draftjs. And draftjs will apply it to the given range.

Because overlapping range will not have the same key as non-overlapping range. Draft js will think this should be a new component, which is the nested component of two decorator components.

The decorations list would look like: ['keyA', 'keyA', 'keyA', 'keyA,keyB', 'keyA,keyB', 'keyA,keyB', keyB, keyB ] And it will generate three components when render. ComponentA apply to range 0 to 2 NestedComponentAB apply to range 3 to 5 ComponentB apply to range 6 to 7

So with this NestebleDecorator, you just need to simply replace CompositeDecorator with it. like the code below.

const editorState = DraftHelper.createWithContent(
    contentState,
    new NestebleDecorator([
      { strategy: linkStrategy, component: Link },
      { strategy: markerStrategy, component: Marker, props: { markers: props.markers } },
    ]),
    false
  )

ouchxp avatar Oct 06 '16 10:10 ouchxp

@ouchxp thanks very much i tried your code and works perfectly. But i have one more problem. Strategy function of my first decorator have to return true for range 0 to 2 and 3 to 5 Strategy function of my second decorator have to return true for range 3 to 5 and 6 to 7. But i dont know how, because whole range from 0 to 7 can have only one entity, so i added to whole range only one common entity and information about single entities and their ranges passed in metadata of this one entity. But i dont know what now? Need to return true/false base on this information. Sorry for my bad english, i hope you understand what i mean Thanks very much for any response or advice

Tommy10802 avatar Oct 06 '16 19:10 Tommy10802

Hi, @Tommy10802 Since there is not way to attach multiple Entities to the same character. I think your one entity approach is a reasonable compromise.

If you already know what range you want apply decorator to. You can just call the callback function with the range as parameters. Here is an example I used to match Markers using regex.

const markerStrategy = (contentBlock, callback) => {
  // Match markers using regex
  const regex = /#[w]*#/g;
  const text = contentBlock.getText();
  let matchArr, start;
  while ((matchArr = regex.exec(text)) !== null) {
    start = matchArr.index;
    callback(start, start + matchArr[0].length); // apply marker decorator to the range
  }
};

Let me assume your entity data looks like the code below, we could iterate through all characters in content block. When you see a new entity, just invoke callback function to set the range of decorator.

// I didn't test this piece of code, so use it with cautious
const entityData = {
  decFooRanges: [[0, 2], [3, 5]],
  decBarRanges: [[3, 7]],
}
const fooStrategy = (contentBlock, callback) => {
  contentBlock
    .getCharacterList()
    .map(char => char.getEntity()) // get all entity keys
    .toSet() // remove duplication
    .map(key => Entity.get(key)) // get entity
    .forEach(entity => {
      // apply decorator for each range
      entity.getData().decFooRanges
        .forEach(([start, end]) => callback(start, end));
    });
};

ouchxp avatar Oct 06 '16 21:10 ouchxp

@ouchxp great it works for me, multiple components are now nicely nested.

But i have, hope last problem. When component are rendered for current strategy i can access to entity data in component. But i have here whole entity data. My entity data looks like const entityData = { comments: [ {id: "id10", position: [0,2] }, {id: "id23", position: [3,5] }], tasks: [ {id: "id13", position: [3,5] }], }

so in my component i need render the id of specified comment as id of span element

const Comment = (props) => { const {id} = Entity.get(props.entityKey).getData() return ( <span className="comment" id={idOfSpecifiedComment}> {props.children} </a> ); };

How can i know which comment from comments field in entityData is currently render?

thx very much for any advice or tips

Tommy10802 avatar Oct 10 '16 15:10 Tommy10802

@Tommy10802 For this last problem I really don't have good way to solve it. One hackish solution would be adjust the key format, bake the id into decorator key (in getDecorations). So you can pass it to individual component in getPropsForKey function.

However there is another problem for your requirement. When you having overlapping decorators, for example: const entityData = { comments: [ {id: "id10", position: [0,7] }], tasks: [ {id: "id13", position: [3,5] }], } Draftjs will create three decorator components, Comment form 0 to 2, Comment+Task from 3 to 5 and another Comment from 6 to 7. These three components will be generated as siblings, you can't apply the same id to all Comment component, unless you split the range properly (like your example form last comment) and give each piece an unique id.

I'm not sure what are you gonna do to the ids, but It may not be ideal to give it to decorators. Could you tell me how are you gonna use the id? Maybe there are some alternative solution we can go with.

ouchxp avatar Oct 11 '16 01:10 ouchxp

@ouchxp Thank you for your last reply. I didnt realize that the id of html element have to be unique. I know that the the components will be generated as siblings so in your example the first and last component of type comment will have the same id, right? So i will change it for class.

I just want do that. I have comments and tasks and one more entity which can be attached to the specific text of editor. When user add comment to the text in editor, comment is stored in database. All my comments are showed in another div, where user can filter and do more stuff with comments. And when user click on specific comment in this div i need highlight it (with highlight i mean, this specified comment will have a different color as rest of entities in editor} in draft editor.

So i thought about it, and maybe with jquery i change the css style of this specified filtered comment and the rest of comments. For this purpose i need some kind of identifier passed to the component. I hope you understand what i want to do.

Thanks very much

Tommy10802 avatar Oct 11 '16 05:10 Tommy10802

@Tommy10802 Sorry, I was a little bit busy recently. I'll try some approach when I have time and then get back to you.

ouchxp avatar Oct 12 '16 21:10 ouchxp

@ouchxp its ok i will wait, thanks for your time and willingness :)

Tommy10802 avatar Oct 13 '16 07:10 Tommy10802

@Tommy10802 With a second thought, I realise that it's not possible to achieve that in NestableDecorator, since it's props is tied to the type of the component, we are not able to set value for individual component. So the only solution to achieve your goal would be creating a fork and modify this segment https://github.com/facebook/draft-js/blob/master/src/component/contents/DraftEditorBlock.react.js#L187 Adding two attributes start=leafSet.get('start') end=leafSet.get('end') then you can match them against entity data in decorator components.

ouchxp avatar Oct 17 '16 21:10 ouchxp

@ouchxp thanks very much for your time :), a im got it, even without your last suggestion, you inspired me with idea in your before post, where you suggest me baked id into componetKey, and with a little edit of your code it nice works :). Thanks you a lot of again for your advices and tips

Tommy10802 avatar Oct 19 '16 14:10 Tommy10802

@ouchxp Hello, can i have one more question? :) I am trying now your nestebleDecorator class solution with draft-js-plugins because i need mentions plugin from it and decorators dont work, i think it is not possible to make your nestebleDecorator to work with Editor from draft-js-plugins

Tommy10802 avatar Nov 07 '16 07:11 Tommy10802

@Tommy10802 It does not work well with draft-js-plugins, since draft-js-plugins have it's own decorator implementation embedded, which cannot be replaced easily.

ouchxp avatar Nov 07 '16 23:11 ouchxp

Is this discussion dead? Is there anyway to be able to have 2 entities per block?

Our use case is a commenting system a la google docs. Someone might add a link, and someone else might want to make a comment on the link ie say the link is spelled wrong, etc.

In any case we aren't able to do this because adding a comment will remove the prior link.

Having a nested decorator with metadata also doesn't seem to solve the issue of what happens when someone edits the link. How does the nested decorator know the position and size of the new link?

Are there any suggested or recommended solutions to this?

terencechow avatar Sep 06 '17 14:09 terencechow

@terencechow where you able to come up with a solution for the described scenario? I'm very interested in the commenting feature of blocks as well.

roundrobin avatar Jan 07 '18 14:01 roundrobin

I would like to be able to have overlapping entities, such as it seems this topic would allow. Is this likely to be supported in the near future?

zachsa avatar Jan 17 '19 14:01 zachsa

I am also looking into solving this as I want to annotate certain text with multiple types of metadata and have nested styling for these.

yornaath avatar Mar 29 '20 11:03 yornaath

I too would like to see a solution to this issue

Sircrab avatar May 19 '20 04:05 Sircrab

This would really be helpful at the moment!

JosieGittingTheHub avatar Jun 22 '20 12:06 JosieGittingTheHub

I would like to be able to have overlapping entities, such as it seems this topic would allow. Is this likely to be supported in the near future?

yanhaijing avatar Dec 12 '22 03:12 yanhaijing

@yanhaijing THIS PROJECT IS CURRENTLY IN MAINTENANCE MODE. It will not receive any feature updates, only critical security bug patches. On 31st December 2022 the repo will be fully archived.

For users looking for an open source alternative, Meta have been working on migrating to a new framework, called Lexical. It's still experimental, and we're working on adding migration guides, but, we believe, it provides a more performant and accessible alternative

ouchxp avatar Dec 12 '22 04:12 ouchxp