draft-js
draft-js copied to clipboard
RFC: Composite entities
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?
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
andranges
. - Can be nested inside other block components, like
<li>
currently can withdepth
.
InlineComponent
- Are defined in a single definition map by
key
, at the top-level ofContentState
. - Has a
type
. - Has associated
metadata
. - Are applied to
text
via arange
, withrange.key
,range.offset
andrange.length
. - Can be
MUTABLE
, in which case deleting works by removing thetext
andlength--
. - Can be
IMMUTABLE
, in which case deleting works by removing all instances of the inline component bykey
. - 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 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?
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!
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);
}
}
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?
@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 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 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
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 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 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 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 Sorry, I was a little bit busy recently. I'll try some approach when I have time and then get back to you.
@ouchxp its ok i will wait, thanks for your time and willingness :)
@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 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
@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 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.
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 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.
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?
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.
I too would like to see a solution to this issue
This would really be helpful at the moment!
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 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