facebookarchive / draft-js

A React framework for building text editors.
https://draftjs.org/
MIT License
22.56k stars 2.64k forks source link

Need ability to "not continue" an inline style or mutable entity #430

Open davidbyttow opened 8 years ago

davidbyttow commented 8 years ago

Right now, the editor simply looks to the left of the cursor to figure out what the style should be when inserting text (https://github.com/facebook/draft-js/blob/master/src/model/immutable/EditorState.js#L597). This works for "continuing" bold/italic/etc which is usually what you want. But sometimes you might not. It also uses the similar logic for mutable entities.

This might be complicated to describe, so I will explain with a use case: Imagine you want to implement inline code like this. So when you type a tick, some text, and another tick, it converts 'foo' into foo, by replacing the text and applying an inline style. However, as you continue typing, it will extend that code snippet because the cursor is picking up the inline style.

There are workarounds like: using immutable/segmented entities (mutable also exhibit this behavior) or adding an extra space when replacing the text and applying the inline code. But, really, what I want is the ability to edit "inside" of the snippet, but not have it continue at the boundaries.

I currently have a hacky workaround which handles onBeforeInput globally for certain inline styles/entities and manually inserts text when it borders on one of these types of inline styles.

Not sure if this is a widespread, known or otherwise specific use case, but worth logging.

sophiebits commented 8 years ago

I haven't looked at exactly how it's implemented, but if you go to the example on the homepage https://facebook.github.io/draft-js/, click Bold, type some text, click Bold again, then type more, the second piece of text is not bold. Is that what you want?

davidbyttow commented 8 years ago

Hm, that is a good point. I suppose applying a style to the collapsed, new range may solve it, though I am certain I tried that. Let me see. Thanks @spicyj

davidbyttow commented 8 years ago

Ah, yes. It uses toggleInlineStyle which uses setInlineStyleOverride (https://facebook.github.io/draft-js/docs/api-reference-editor-state.html#setinlinestyleoverride).

I've switched to using this as it's exposed. Thanks for the tip.

davidbyttow commented 8 years ago

Actually, re-opening because I think it's still valid.

In the above case, I actually never want it to continue from the edge. Otherwise, you end up with weird edge cases such as being unable to get out of it without some hotkey or UI affordance. I can solve the case for continuing to type after the transformation, but once I move the cursor left and right again (for example), it picks up the style and you can't get out of it.

davidbyttow commented 8 years ago

Here's an example: bugs

hellendag commented 8 years ago

Are you sure you never want to continue the style from the edge? If the user toggles a style override and begins typing, the cursor remains at the edge of the styled span -- shouldn't that override continue to be valid as the user continues to type?

Do you have examples of rich editors that support this behavior without a UI affordance or key command? I think your use case makes sense, I just want to evaluate whether this is something that should be fixed at a framework level, or whether the current APIs allow the appropriate behavior.

davidbyttow commented 8 years ago

To answer your question, yes, that's correct -- it should be valid. I handle this case, and it's a little hairy. The code hasn't been battle-tested yet, but the basic idea seems to work.

As for the punchline, I'm not sure whether or not it should be made available at the framework level or not. It seems fairly isolated, but this will require ongoing maintenance as the framework progresses. I don't have the historical knowledge to make a judgment call here, but happy to take a shot at integrating.

I don't have a specific example in mind. Medium (for example), does what I am doing here, which is that a new block is a "reset" and it doesn't look upward. But it doesn't have inline code snippets like this.

handleBeforeInput(chars, {getEditorState, setEditorState}) {
  const state = getEditorState();
  const selection = state.getSelection();

  if (!selection.isCollapsed()) {
    return false;
  }

  const startOffset = selection.getStartOffset();
  const block = getCurrentBlock(state);

  let style = state.getInlineStyleOverride();
  if (style === null) {
    style = block.getInlineStyleAt(startOffset);
  }

  let insertManually = false;

  if (startOffset == 0) {
    // We check the style here because Draft's inherent behavior is such that if there
    // is no inline style, it "carries" from the previous line or block. We want it to
    // "reset" for each block. However, if there is a style override, we should
    // let the default behavior occur.
    insertManually = style.isEmpty();
  } else if (style.intersect(rigidInlineTypes).isEmpty()) {
    // Some inline types should not be continuable at the far edge. That is,
    // if you put the cursor at the end of the style and start typing, it should
    // not carry that style. Draft does this be default, so we need to defeat it
    // just as we do above.
    const prevStyle = block.getInlineStyleAt(startOffset - 1);

    // Check if we're bordering on a rigid type.
    insertManually = !prevStyle.intersect(rigidInlineTypes).isEmpty();
  }

  if (insertManually) {
    const entity = block.getEntityAt(startOffset);

    // Insert the text manually so we can control the inline style behavior.
    let content = state.getCurrentContent();
    content = Modifier.insertText(content, selection, chars, style, entity);
    setEditorState(EditorState.push(state, content, 'insert-characters'));
    return true;
  }

  return false;
}

Of course, this has ramifications. For example, the default behavior of toggleInlineStyle will not work with this implementation, as I have to handle the isCollapsed case myself:

/**
 * This is our own custom version of toggle inline style. It has one special
 * case where we want to toggle a style at the start of the block. The default
 * behavior for Draft is to look upward to a previous block, but we don't
 * want that, so we manually set the override.
 */
export function toggleInlineStyle(state, inlineStyle) {
  const selection = state.getSelection();
  if (selection.isCollapsed()) {
    const currentStyle = (selection.getFocusOffset() == 0)
      ? getCurrentBlock(state).getInlineStyleAt(0)
      : state.getCurrentInlineStyle().subtract(rigidInlineTypes);

    return EditorState.setInlineStyleOverride(
      state,
      currentStyle.has(inlineStyle)
        ? currentStyle.remove(inlineStyle)
        : currentStyle.add(inlineStyle));
  }

  // Fallback to default beahvior.
  return RichUtils.toggleInlineStyle(state, inlineStyle);
}

Example: image

davidbyttow commented 8 years ago

And sorry, to be clear, there are a couple things going on in this code (sorry for conflating):

ouchxp commented 8 years ago

We have this need as well.

amireh commented 8 years ago

I'm not sure if our use-case warrants a different issue than this one because it seems to be very related so I'll just ask here.

We're applying a LINK entity to a range (with MUTABLE as a mutability strategy) like the link example shows but do not want the link to be applied to subsequent character insertions. If you look at Google Docs for example, once you insert a link then type anything (not necessarily a space character), the entity doesn't apply to those characters.

How can we achieve such an effect?

I thought of appending a whitespace character with no entity to the block's charlist but it seems hackish and has side-effects on the UI as the user shouldn't see a space since they didn't insert one... :confused: Another thought was to provide a custom strategy (or just tune the MUTABLE one) but could not find such an API that allows such customization.

Would love your input on this, kind of stuck on this now. Thank you very much.

iandoe commented 8 years ago

@hellendag @amireh I am facing the same issue, creating a link that i want to be editable inside but non contiguous. We should add some kind of contiguous: boolean option to the mutable entity or something. This is a pretty standard UX for WYSIWYG editors. Down below is an example with Redactor.js :

redactor_link_example

Attached PR #510

iandoe commented 8 years ago

Update, i made it work in #510

entity_link_example

Grunt work is there: /src/model/entity/getEntityKeyForSelection.js

    if (offset > 0) {
      entityKeyBeforeCaret = contentState.getBlockForKey(key).getEntityAt(offset - 1);
      entityKeyAfterCaret = contentState.getBlockForKey(key).getEntityAt(offset);
      caretOutsideEntity = (entityKeyBeforeCaret !== entityKeyAfterCaret);

      return filterKey(entityKeyBeforeCaret, caretOutsideEntity);
    }
function filterKey(
  entityKey: ?string,
  caretOutsideEntity: ?bool,
): ?string {
  if (entityKey) {
    var entity = DraftEntity.get(entityKey);

    // if entity is mutable and caret is inside it, return it
    if (entity.getMutability() === 'MUTABLE' && !caretOutsideEntity) {
      return entityKey;
    }

    // entity is mutable and the caret is outside of the entity
    // if it is contiguous, return it, else null
    if (entity.getMutability() === 'MUTABLE' && caretOutsideEntity) {
      return (entity.getContiguity()) ? entityKey : null;
    }
  }
  return null;
}

There's a few todo's left and i'm busy with work so if anybody can help out on what's left so we can get this merged in sooner rather than late i'd be grateful :)

Radivarig commented 8 years ago

I was thinking about adding a zero width space as first character in the desired entity type and right after outside of it so that moving the cursor can "enter/leave" the entity. So when pressing left/right at entity edges the cursor wouldn't visually move but its position would change and allow typing before/after outside and at start/end inside of entity. Is this an acceptable hack?

Raincle commented 7 years ago

@davidbyttow Hi, davidbyttow.

I have the same need with you. Thank you for your code above and now I'm using it . Just find that "getCurrentBlock" and "rigidInlineTypes" is not mentioned in documentation.

I think that "getCurrentBlock" is implemented by yourself, but I really have no idea about how to get current block and what "rigidInlineTypes" is.

Could you help me?

Raincle commented 7 years ago

Now I find "getBlockForKey" in documentation and implement my "getCurrentBlock". So, I still don't know what "rigidInlineTypes" is. Is it a string or something?

Uncaught ReferenceError: rigidInlineTypes is not defined

tomconroy commented 7 years ago

Here is my workaround (used like a plugin in draft-js-plugins), which overrides this behavior only for entities:

handleBeforeInput: (chars, { getEditorState, setEditorState }) => {
  const state = getEditorState();
  const selection = state.getSelection();

  if (!selection.isCollapsed()) {
    return false;
  }

  const startOffset = selection.getStartOffset();
  const content = state.getCurrentContent();
  const block = content.getBlockForKey(selection.getStartKey());

  const entity = block.getEntityAt(startOffset);
  if (entity === null) {
    const style = state.getCurrentInlineStyle();
    const newContent = Modifier.insertText(content, selection, chars, style, null);
    setEditorState(EditorState.push(state, newContent, 'insert-characters'));
    return 'handled';
  }

  return 'not-handled';
}
sudkumar commented 7 years ago

Here is how I have implemented the logic for MUTABLE entity which are not contiguous.

Entity creation

// create the entity which is mutable but not contiguous
const data = { href: "https://www.domain.com" }
contentState.createEntity(
    "LINK",
    "MUTABLE",
    { ...data, url: data.href, target: "_blank", contiguous: false }
)

Utility to handle the contiguous entity

import {
    EditorState,
    Modifier
} from "draft-js"
// method to handle the contiguous entity for the newly inserted chars in the editor state
const handleContiguousEntity = (chars, editorState) => {
    const selectionState = editorState.getSelection()
    let contentState = editorState.getCurrentContent()
    const startKey = selectionState.getStartKey()
    const block = contentState.getBlockForKey(startKey)
    // handle the extending of mutable entities
    if (selectionState.isCollapsed()) {
        const startOffset = selectionState.getStartOffset()
        if (startOffset > 0) {
            // we are not at the start of the block
            const entityKeyBeforeCaret = block.getEntityAt(startOffset - 1)
            const entityKeyAfterCaret = block.getEntityAt(startOffset)
            const isCaretOutsideEntity = (entityKeyBeforeCaret !== entityKeyAfterCaret)
            if (entityKeyBeforeCaret && isCaretOutsideEntity) {
                const entity = contentState.getEntity(entityKeyBeforeCaret)
                const isMutable = entity.getMutability() === "MUTABLE"
                const { contiguous = true } = entity.getData()
                // if entity is mutable, and caret is outside, and contiguous is set the false
                // remove the entity from the current char
                if (isMutable && !contiguous) {
                    // insert the text into the contentState
                    contentState = Modifier.insertText(
                        contentState,
                        selectionState,
                        char,
                        editorState.getCurrentInlineStyle(),
                        null
                    )
                    // push the new content into the editor state
                    const newEditorState = EditorState.push(
                        editorState,
                        contentState,
                        "insert-characters"
                    )
                    return newEditorState
                }
            }
        }
    }
    return false
}

The Editor Component's handleBeforeInput prop

// method inside the editor component
handleBeforeInput = (char) => {
    const { editorState } = this.state
    const newEditorState = handleContiguousEntity(char, editorState)
    if (newEditorState) {
        this.setState({ editorState: newEditorState })
        return "handled"
    }
    return "not-handled"
}
optimatex commented 7 years ago

@tomconroy Thank u so much! I'm very happy to get your solution

bultas commented 7 years ago

I use @sudkumar piece of code and implement it for my needs.. So if someone wants to look on that code and find inspiration how to handle some extra cases like convert HTML to State with extra Entity data etc... you will find it here:

https://gist.github.com/bultas/7f551efbc9054f8cf227f42db55eec07

prashantagarwal commented 6 years ago

@tomconroy I don't think setEditorState is available in latest version of Draft-js. Any work around for it ?

thibaudcolas commented 6 years ago

In my testing of other editors, lots/all have the behavior to continue inline styles, but none continue inline mutable entities. IMHO this issue should be restricted to this case and marked as a bug, unless there is a use case I'm overlooking where it's desirable to continue entities.

Note: tested Google Docs, Dropbox Paper, Gmail, Apple Pages.

busov commented 6 years ago

@prashantagarwal

  handleBeforeInput = (chars, editorState) => {
    const selection = editorState.getSelection()
    const startOffset = selection.getStartOffset();
    const content = editorState.getCurrentContent();
    const block = content.getBlockForKey(selection.getStartKey());
    const entity = block.getEntityAt(startOffset);

    if (entity === null) {
      const style = editorState.getCurrentInlineStyle();
      const newContent = Modifier.insertText(content, selection, chars, style, null);
      const newEditorState = EditorState.push(editorState, newContent, 'insert-characters');

      this.setState({ editorState: newEditorState });

      return 'handled';
    }

    return 'not-handled';
  }
mxstbr commented 6 years ago

I just implemented this in the draft-js-markdown-plugin based on the code above by @tomconroy, but heavily adapted to do as little manual changes as possible. You can see the full diff here: https://github.com/withspectrum/draft-js-markdown-plugin/pull/83

thibaudcolas commented 6 years ago

I'm surprised the v0.10.5 changelog doesn't mention this issue, this got fixed for entities:

Fix issue where typing at the end of a link caused the link to continue. (Ian Jones in d16833b3)

For inline styles, still works the same as before 🙂.

haikyuu commented 6 years ago

@thibaudcolas thank you so much 👍 it works after updating to v10.0.5. I think this issue can be closed now

mxstbr commented 6 years ago

@haikyuu not really since you still have to use the workaround I posted to get it working for inline styles?

haikyuu commented 6 years ago

@mxstbr i think it's the expected behaviour for inline styles. Check notes app, email app ... in mac

mxstbr commented 6 years ago

i think it's the expected behaviour for inline styles.

The point is that it depends on how users can type inline styles. If you have an inline toolbar, sure the current behavior makes sense, but if you have markdown shortcuts it's very annoying since you can't exit an inline style at the end of the editor.

That's why we need this behavior to be configurable, so you can set it to work the way it makes sense in your application! Nobody's saying to change it to only be non-sticky, but it should be configurable out of the box.

skinandbones commented 6 years ago

Is there a recommendation for the best way to get the previous contiguous entity behavior? For my entities use case, that was the preferred behavior so the 0.10.5 upgrade is posing some issues.

thibaudcolas commented 6 years ago

@skinandbones you should be able to implement this in handleBeforeInput, the opposite way from what was discussed in this thread before the 0.10.5 change (e.g. https://github.com/facebook/draft-js/issues/430#issuecomment-269815367).

Something like:

handleBeforeInput(char) {
  const { blockTypes } = this.props;
  const { editorState } = this.state;
  const selection = editorState.getSelection();
    if (selection.isCollapsed()) {
      const block = getSelectedBlock(editorState);

      if (hasSelectionStartEntity(selection, block)) {
          this.onChange(
              insertTextWithEntity(editorState, char),
          );
            return HANDLED;
      }
    }

    return NOT_HANDLED;
}

  hasSelectionStartEntity(selection, block) {
      const startOffset = selection.getStartOffset();
      return block.getEntityAt(startOffset) === ! null;
  },

  insertTextWithEntity(editorState, text) {
    const content = editorState.getCurrentContent();
    const selection = editorState.getSelection();
    const style = editorState.getCurrentInlineStyle();
    const entity = block.getEntityAt(selection.getStartOffset());

    const newContent = Modifier.insertText(
      content,
      selection,
      text,
      style,
      entity,
    );

    return EditorState.push(editorState, newContent, 'insert-characters');
},

Taken from the implementation of the inverse behavior over at https://github.com/springload/draftail/pull/106.