facebookarchive / draft-js

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

Restricting user input aka controlled input #1058

Open andpor opened 7 years ago

andpor commented 7 years ago

I have a question.

Is it at all possible to enforce a structure of the document/block to only allow lists for example? I would like to start with say one empty bullet in UL and allow user to add more bullets and delete bullets but always keep the bullet format - no other form of input is allowed.

Are there any examples how one could achieve this if at all possible with DraftJS?

Thanks.

colinjeanne commented 7 years ago

It's very possible.

You can do this by creating your own key binding and key command handler and handling the various keys or bindings as needed. You'll need to override some other handlers, such as for paste, to ensure non-list content doesn't make its way in other ways.

andpor commented 7 years ago

Are there any good examples on how to go about creating custom key binding and key command handlers?

tobiasandersen commented 7 years ago

https://draftjs.org/docs/advanced-topics-key-bindings.html#content

andpor commented 7 years ago

This seems like a very shallow example. One page doc that really does not show off what needs to be done. I see some issues.

For example... DraftEditor runs function editOnKeyDown(editor, e) on keyDOwn and eventually calls onKeyCommand(command, editorState)

var newState = onKeyCommand(command, editorState);
  if (newState !== editorState) {
    editor.update(newState);
  }

This is not good. I have no way to do anything after onKeyCommand runs, I cannot overwrite it. What if I would like to do something different than default inside onKeyCommand or do some postprocessing? Or I am just not seeing how to do it and there is a way..

tobiasandersen commented 7 years ago

Well sure, the example could be more thorough, but it does show you the basics to get it working. I'm not sure I'm following you here, what are you unable to do?

andpor commented 7 years ago

I have a UL in the editor and a bunch of LI.

Basically a UL or OL with LIs that have placeholder text capability.

sophistifunk commented 7 years ago

I didn't want to open a new issue after seeing this one, but I also am having similar issues. The documents state that Draft.js is supposed to behave like a controlled input, but if I ignore change events completely, or if I allow only changes to selection like so:

editorChanged = (eventState: EditorState) => {
    let selection = eventState.getSelection();
    let newState = EditorState.acceptSelection(this.state.editorState, selection);
    this.setState({ editorState: newState });
}

The user can still type away and the on-screen contents are being updated. Is there something I need to do in order to let Draft.js know that certain blocks need to have their DOM nodes invalidated / reset to match the unchanged ContentState?

colinjeanne commented 7 years ago

@sophistifunk: If you want to block text then provide a handler for Editor.handleBeforeInput to reject the input you want.

Draft plays a game with the browser: in many cases the browser is allowed to pass the character through and Draft reacts to its presence rather than controlling it entirely. The relevant code is starts at https://github.com/facebook/draft-js/blob/6f2bf66bacff3ffac234fc21ff3720344ff92776/src/component/handlers/edit/editOnBeforeInput.js#L128.

sophistifunk commented 7 years ago

@colinjeanne thanks for that, it seems to work. Do you guys maintain the TypeScript defs, or do I need to talk to a third party about them? Some types for example DraftHandleValue are required as part of exported types, but not actually exported so you can't mark your handlers as returning it, or cast to it by name, you need to redefine it or cast as one of the consts.

andpor commented 7 years ago

I am trying various approaches and at the moment need to change the behavior of keyCommandPlainBackspace.js. Basically I want majority of the code there to execute except is some key scenarios. How do I do this in a clean way? If I take the code from keyCommandPlainBackspace.js and make it part of my app, it has a bunch of internal dependecies such as

var EditorState = require('./EditorState');
var UnicodeUtils = require('fbjs/lib/UnicodeUtils');

var moveSelectionBackward = require('./moveSelectionBackward');
var moveSelectionForward = require('./moveSelectionForward');
var removeTextWithStrategy = require('./removeTextWithStrategy');

and since these are not expose in draft, I would have to points these to explicit locations within node_modules/draft-js/lib which is not ideal e.g.:

var {EditorState} = require('draft-js');
var UnicodeUtils = require('fbjs/lib/UnicodeUtils');

var moveSelectionBackward = require('draft-js/lib/moveSelectionBackward');
var moveSelectionForward = require('draft-js/lib//moveSelectionForward');
var removeTextWithStrategy = require('draft-js/lib//removeTextWithStrategy');

What is the recommended strategy / approach for making something like this work cleanly?

colinjeanne commented 7 years ago

None of that code is particularly difficult to replicate using things like Modifier.removeRange and setting the selection. What are you trying to do?

andpor commented 7 years ago

My editor has UL and it needs to hold on to UL despite various combinations of backspace. The default behavior with backspace varies between single backspace, backspace word, backspace to the start of the line but the common theme is the logic does not preserve UL structure. With single backspace, when empty LI is backspaced, an unformatted block is inserted at the end and the next backspace jumps to the next LI. Also, default behaviour deletes UL completely if it is the the only LI in UL and inserts a unformatted block. I have added some logic in three handlers:

keyCommandBackspaceToStarOfLine.js keyCommandBackspaceWord.js keyCommandPlainBackspace.js

that basically ignore the backspace when certain conditions potentially destroying the UL structure are detected.

Here is the example from keyCommandBackspaceToStarOfLine.js


var {EditorState} = require('draft-js');

var expandRangeToStartOfLine = require('draft-js/lib/expandRangeToStartOfLine');
var getDraftEditorSelectionWithNodes = require('draft-js/lib/getDraftEditorSelectionWithNodes');
var moveSelectionBackward = require('draft-js/lib/moveSelectionBackward');
var moveSelectionForward = require('draft-js/lib/moveSelectionForward');
var removeTextWithStrategy = require('draft-js/lib/removeTextWithStrategy');

function keyCommandBackspaceToStartOfLine(editorState) {
    var afterRemoval = removeTextWithStrategy(editorState, function (strategyState) {
        var selection = strategyState.getSelection();
        var content = editorState.getCurrentContent();
        var key = selection.getAnchorKey();
        var block = content.getBlockForKey(key);
        var blockBefore = content.getBlockBefore(key);
        var blockAfter = content.getBlockAfter(key);
        var blockText = block.getText();
        if (selection.isCollapsed() && selection.getAnchorOffset() === 0) {
            if (blockBefore && blockBefore.getType() === "unordered-list-item") {
                return moveSelectionBackward(strategyState, 1);
            } else if (blockText.length || (blockAfter && blockAfter.getType() === "unordered-list-item")) {
                return moveSelectionForward(strategyState, 1);
            } else {
                return selection;
            }
        }

        var domSelection = global.getSelection();
        var range = domSelection.getRangeAt(0);
        range = expandRangeToStartOfLine(range);

        return getDraftEditorSelectionWithNodes(strategyState, null, range.endContainer, range.endOffset, range.startContainer, range.startOffset).selectionState;
    }, 'backward');

    if (afterRemoval === editorState.getCurrentContent()) {
        return editorState;
    }

    return EditorState.push(editorState, afterRemoval, 'remove-range');
}

My logic tests a few contexual conditions and basically changes the default behaviour in draft - take a look at the nested if statement. I had to import several other modules to allow me to accomplish this though.

So the logic is complex but mostly duplicated from original handlers because there is no way to tap into what they do and inject your own custom logic at fine grained level.

Makes sense ?

colinjeanne commented 7 years ago

Here's a potential way to work around this: in your component take a ref on the DraftEditor and maintain a last known good EditorState. In your onChange handler if the incoming EditorState is empty except for an unstyled block then update your last known good EditorState to have a single empty list item block. Then call update on your ref to the DraftEditor and pass in your updated last known good editor state.

If, the incoming editor state was valid then just set your last known editor state to this new editor state.

Basically: let Draft do what it was going to do but once it does validate that it meets your criteria and fix things up if it doesn't.

andpor commented 7 years ago

What you are suggestion is not a reliable and deterministic approach. Besides unformatted block I am inspecting several other conditions and take different actions based on them: moveselectionforward, moveselectionbackward, accept backspace if there is text in the UL etc. I cannot let draft "do its thing" because I would like to interfere with it in a handful of circumstances which can only be derived inside these key handlers unfortunately. By the time draft is done making changes I cannot recover or "fix things up" as you say...fixing things up implies re-implementing the same logic that is inside handler plus bunch of other logic for comparison.

also note that each of the key handler functions actually modifies EditorState and redo stack which further proves my point that AFTER key handler is done it is too late to do anything.

colinjeanne commented 7 years ago

I don't know the specifics of what' you're trying to do or what experience you are trying to build. The issue with the undo stack is taken care of by maintaining the last known good editor state: you throw away the editor state that's passed to your onChange handler by updating the last known good one directly and passing that to update. That allows you to control the undo stack.

Depending on the conditions you are trying to detect in the various backspace handlers, you may be able to detect them in a custom key command handler and save some state about what's happening. Hopefully you'll sufficient data to then reconstruct whatever you were trying to detect in Draft's own logic.

Of course, barring that, you can fork the project.

andpor commented 7 years ago

@colinjeanne - what you are suggestion i.e taking action after standard draft key handlers run cannot work. Modifications to redo stack is one reason, another reason is that editOnKeyDown.js actually modified EditorState.

var newState = onKeyCommand(command, editorState);
  if (newState !== editorState) {
    editor.update(newState);
  }

This is why it is so critical to be able to tap into handlers invoked by onKeyCommand and execute your own logic before all of this happens.

What I am trying to do is not really rocket science. My editor has to enforce bullet layout (unordered list) and maintain at least one bullet or LI in the editor regardless of key presses or paste actions. So far, the only way to accomplish this in a deterministic fashion was to add my own logic into those three key handlers I mentioned earlier. While it is working like a charm, the way it is done is not proper i.e. forcing me to copy/paste entire handler and import a bunch of other internal Draft functions ....If Draft codebase is updated, I am kind of in a pickle...

That is why I said there is a strong need to open up key handlers so that custom code can be executed within them without forcing to copy/paste anything...

AdriatikDushica commented 7 years ago

@andpor have you found any solution?

andpor commented 7 years ago

@AdriatikDushica - I had no choice but to take the whole keyhandler copy from draftjs and put it in my local. I wish there were more hooks into draftjs that would allow me to modify behavior of already written code with simple config...

The most annoying change was backspace. I wanted to ensure that once in UL block LI are deleted one by one with backspace in orderly fashion. Unfortunately the default behaviour out of draftjs is that when empty LI is backspaced, the block is converted from LI to unstyled, then deleted on subsequent backspace..small things some much copy/paste...

andpor commented 7 years ago

Actually this approach did not work in a long run. It worked fine for our own code howver various other modules add-ons such as draft-js-to-html and draft-js-plugins refer to root level (node_modules) draftjs installation. Consequently entities fail to work because entities are maintained in a module var singleton so our code would be changing local draftjs registry while other code would be hitting node_modules one - which is naturally quite bad.

Still struggling how to overwrite default keystroke behaviour without replicating tons of code...