keyboard events on [non-] contenteditable HTML5 el

2020-02-06 23:42发布

问题:

I'm coding the MELT monitor (free software, alpha stage, related to the GCC MELT domain specific language to customize GCC). It is using libonion to behave as a specialized web server, and I want it to become a syntax directed editor of some DSL I am designing. I'm speaking of commit 97d60053 if that matters. You could run it as ./monimelt -Dweb,run -W localhost.localdomain:8086 then open http://localhost.localdomain:8086/microedit.html in your browser.

I am emitting (thru file webroot/microedit.html)

<h1>Micro Editing Monimelt</h1>
<div id='microedit_id' contenteditable='true'>*</div>
<hr/>

then some AJAX trickery is filling that #micredit_id element with something containing stuff similar to:

    <dd class='statval_cl' data-forattr='notice'> &#9653;
    <span class='momnode_cl'>*<span class='momconn_cl'>
    <span class='momitemref_cl'>comment</span></span>
    (&#8220;<span class='momstring_cl'>some simple notice</span>&#8221;
     <span class='momnode_cl'>*<span class='momconn_cl'>
     <span class='momitemref_cl'>web_state</span></span>
     (<span class='momnumber_cl'>2</span>)</span>
     <span class='momitemval_cl'>hashset</span>
     <span class='momset_cl'>{<span class='momitemref_cl'>microedit</span>
     <span class='momitemref_cl'>the_agenda</span>}</span>
     <span class='momtuple_cl'>[<span class='momitemref_cl'>web_session</span>
     <span class='momitemref_cl empty_cl'>~</span>
     <span class='momitemref_cl'>the_system</span>]</span>)</span> ;</dd> 

Now, I want every <span> of class momitemref_cl to be sensitive to some keyboard (and perhaps mouse) events. However, the contenteditable elements can be edited by many user actions (I don't even understand what is the entire list of such user actions....) and I only want these span elements to be responsive to a defined and restricted set of key presses (alphanumerical & space) and not be able to be user-changed otherwise (e.g. no punctuation characters, no "cut", no "paste", no backspace, no tab, etc...).

Is there a complete list of events (or user actions) that a contenteditable='true' element can get and is reacting to?

How to disable most of these events or user actions (on keyboard & mouse) and react only to some (well defined) keyboard events?

Apparently, a <span> element in a non-contenteditable element cannot get any keyboard user action (because it cannot get the focus)...

I am targeting only recent HTML5 browsers, such as Firefox 38 or 42, or Chrome 47 etc... on Debian/Linux/x86-64 if that matters (so I really don't care about IE9)

PS. this is a related question, but not the same one.

PS2: Found the why contenteditable is terrible blog page. Makes me almost cry... Also read about faking an editable control in browser Javascript (for CodeMirror). See also W3C draft internal document on Editing Explainer and edit events draft. Both W3C things are work in progress. W3C TR on UI events is still (nov.2015) a working draft. See also http://jsfiddle.net/8j6jea6p/ (which behaves differently in Chrome 46 and in Firefox 42 or 43 beta)

PS3: perhaps a contenteditable is after all a bad idea. I am (sadly) considering using a canvas (à la carota) and doing all the editing & drawing by hand-written javascript...


addenda:

(November 26th 2015)

By discussing privately with some Mozilla persons, I understood that:

  • contenteditable is messy (so I rather avoid it), and is not anymore worked much in Firefox (for instance, even recent beta Firefox don't know about contenteditable='events', see nsGenericHTMLElement.h file)

  • event bubbling and capturing matters a big lot

  • a normal <div> (or <span>) can be made focusable by giving it a tabindex property

  • text selection API could be useful (but has some recent bugs)

So I probably don't need contenteditable

回答1:

You can do as such:

function validateInput(usrAct){
  swich(usrAct){
    case "paste":
    // do something when pasted
    break;
    case "keydown":
    // dosomething on keydown
    break;
    default:
    //do something on default
    break;
  }
}

document.querySelectorAll('.momitemref_cl').addEventListener('input', function(e){
  validateInput(e.type)
}, false);


回答2:

This snippet could be what you are looking for, making span.momitemref_cl elements focusable but not tabbable and setting has contenteditable. But as i'm testing it on chrome, contenteditable inside any container with attribute contenteditable set to true, don't fire any keyboard event. So the trick could be on focus to set any container to not editable (and switch back on blur).

See e.g: (keypress and keydown events are both binded to handle some specific cases where keypress or keydown wouldn't be fired on specifc keys)

NOTE: has you seem to populate DIV with content dynamically, you could delegate it or bind event (& set tabindex attribute if changing it in HTML markup not a solution) once ajax request has completed.

$('#microedit_id .momitemref_cl').attr('tabindex', -1).prop('contenteditable', true).on('focusin focusout', function(e) {
  $(this).parents('[contenteditable]').prop('contenteditable', e.type === "focusout");
}).on('keypress keydown paste cut', function(e) {
  if (/[a-zA-Z0-9 ]/.test(String.fromCharCode(e.which))) return;
  return false;
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<h1>Micro Editing Monimelt</h1>

<div id='microedit_id' contenteditable='true'>
  <dd class='statval_cl' data-forattr='notice'>&#9653; <span class='momnode_cl'>*<span class='momconn_cl'>
    <span class='momitemref_cl'>comment</span></span>(&#8220;<span class='momstring_cl'>some simple notice</span>&#8221; <span class='momnode_cl'>*<span class='momconn_cl'>
     <span class='momitemref_cl'>web_state</span></span>(<span class='momnumber_cl'>2</span>)</span> <span class='momitemval_cl'>hashset</span>
    <span class='momset_cl'>{<span class='momitemref_cl'>microedit</span>
    <span class='momitemref_cl'>the_agenda</span>}</span> <span class='momtuple_cl'>[<span class='momitemref_cl'>web_session</span>
    <span class='momitemref_cl empty_cl'>~</span>
    <span class='momitemref_cl'>the_system</span>]</span>)</span>;</dd>
</div>
<hr/>



回答3:

First, HTMLElements become contentEditableElements when you set their contentEditable attribute to true.

Now, the best way to do your parsing IMO is to listen to the inputEvent and check your element's textContent:

s.addEventListener('input', validate, false);

function validate(evt) {
  var badValues = ['bad', 'content'];
  var span = this;
  badValues.forEach(function(v) {
    if (span.textContent.indexOf(v) > -1) {
      // that's bad m..key
      span.textContent = span.textContent.split(v).join('');
    }
  });
};
<span id="s" contentEditable="true">Hello</span>

Unfortunately, the input event isn't widely supported so you may need to add onkeydown and onpasteand maybe onclick event handlers to catch non-supporting browsers (a.k.a IE).



回答4:

Edit:

(Handles only the spans with the said class. Also handles the case, where you could go back from another span into a previous one and could delete it. Incorporates the idea of @AWolff for switching the contenteditable attribute on focus)

The overall idea remains the same as that of the previous version.

Fiddle: http://jsfiddle.net/abhitalks/gb0mbwLu/

Snippet:

var div = document.getElementById('microedit_id'), 
    spans = document.querySelectorAll('#microedit_id .momitemref_cl'), 
    commands = ['paste', 'cut'], 
    // whitelist is the keycodes for keypress event
    whitelist = [{'range': true, 'start': '97', 'end': '122'}, // lower-case
                 {'range': true, 'start': '65', 'end': '90'}, // upper-case
                 {'range': true, 'start': '48', 'end': '57' } // numbers
	], 
    // specialkeys is the keycodes for keydown event
    specialKeys = [8, 9, 13, 46] // backspace, tab, enter, delete
;
div.addEventListener('keydown', handleFromOutside, false);

[].forEach.call(spans, function(span) {
    span.setAttribute('contenteditable', true);
    span.setAttribute('tabindex', '-1');
    span.addEventListener('focus', handleFocus, false);
    span.addEventListener('blur', handleBlur, false);
    commands.forEach(function(cmd) {
        span.addEventListener(cmd, function(e) {
            e.preventDefault(); return false;
        });
    });
    span.addEventListener('keypress', handlePress, false);
    span.addEventListener('keydown', handleDown, false);
});

function handleFocus(e) { div.setAttribute('contenteditable', false); }
function handleBlur(e) { div.setAttribute('contenteditable', true); }

function handlePress(e) {
    var allowed = false, key = e.keyCode;
    whitelist.forEach(function(range) {
        if (key && (key != '') && (range.start <= key) && (key <= range.end)) {
            allowed = true;
        }
    });
    if (! allowed) { e.preventDefault(); return false; }
}

function handleDown(e) {
    var allowed = false, key = e.keyCode;
    specialKeys.forEach(function(spl) {
        if (key && (spl == key)) { e.preventDefault(); return false; }
    });
}

function handleFromOutside(e) {
    var key = e.keyCode, node = window.getSelection().anchorNode, prev, next;
    node = (node.nodeType == 3 ? node.parentNode : node)
    prev = node.previousSibling; next = node.nextSibling; 
    if (prev || next) {
        if (node.className == 'momitemref_cl') {
            if (specialKeys.indexOf(key) >= 0) {
                e.preventDefault(); return false;
            }
        }
    }
}
<h1>Micro Editing Monimelt</h1>
<div id='microedit_id' contenteditable='true'>
    <dd class='statval_cl' data-forattr='notice'> &#9653;
    <span class='momnode_cl'>*<span class='momconn_cl'>
    <span class='momitemref_cl'>comment</span></span>
    (&#8220;<span class='momstring_cl'>some simple notice</span>&#8221;
     <span class='momnode_cl'>*<span class='momconn_cl'>
     <span class='momitemref_cl'>web_state</span></span>
     (<span class='momnumber_cl'>2</span>)</span>
     <span class='momitemval_cl'>hashset</span>
     <span class='momset_cl'>{<span class='momitemref_cl'>microedit</span>
     <span class='momitemref_cl'>the_agenda</span>}</span>
     <span class='momtuple_cl'>[<span class='momitemref_cl'>web_session</span>
     <span class='momitemref_cl empty_cl'>~</span>
     <span class='momitemref_cl'>the_system</span>]</span>)</span> ;</dd>     
</div>
<hr/>

Apart from the usual handling of events on the spans and preventing / allowing the keys and/or commands from the white-lists and balck-lists; what this code does is to also check if the cursor or editing is currently being done on other spans which are not constrained. When selecting or moving using arrow keys from there into the target spans, we dis-allow special keys to prevent deletion etc.

function handleFromOutside(e) {
    var key = e.keyCode, node = window.getSelection().anchorNode, prev, next;
    node = (node.nodeType == 3 ? node.parentNode : node)
    prev = node.previousSibling; next = node.nextSibling; 
    if (prev || next) {
        if (node.className == 'momitemref_cl') {
            if (specialKeys.indexOf(key) >= 0) {
                e.preventDefault(); return false;
            }
        }
    }
}

I could not get much time, and thus one problem still remains. And, that is to disallow commands as well like cut and paste while moving into the target spans from outside.


Older version for reference only:

You could maintain a white-list (or blacklist if number of commands allowed are higher) of all keystrokes that you want to allow. Similarly, also maintain a dictionary of all events that you want to block.

Then wire up the commands on your div and use event.preventDefault() to reject that action. Next up, wire up the keydown event and use the whitelist to allow all keystrokes that are in the permissible ranges as defined above:

In the example below only numbers and alphabets will be allowed as per the first range and arrow keys (along with pageup/down and space) will be allowed as per the second range. Rest all actions are blocked / rejected.

You can then extend it further to your use-case. Try it out in the demo below.

Fiddle: http://jsfiddle.net/abhitalks/re7ucgra/

Snippet:

var div = document.getElementById('microedit_id'), 
    spans = document.querySelectorAll('#microedit_id span'), 
    commands = ['paste'], 
    whitelist = [ {'start': 48, 'end': 90}, {'start': 32, 'end': 40 }, ]
;
commands.forEach(function(cmd) {
    div.addEventListener(cmd, function(e) {
        e.preventDefault(); return false;
    });
});

div.addEventListener('keydown', handleKeys, false);

function handleKeys(e) {
    var allowed = false;
    whitelist.forEach(function(range) {
        if ((range.start <= e.keyCode) && (e.keyCode <= range.end)) {
            allowed = true;
        }
    });
    if (! allowed) { e.preventDefault(); return false; }
};
<h1>Micro Editing Monimelt</h1>
<div id='microedit_id' contenteditable='true'>
    <dd class='statval_cl' data-forattr='notice'> &#9653;
    <span class='momnode_cl'>*<span class='momconn_cl'>
    <span class='momitemref_cl'>comment</span></span>
    (&#8220;<span class='momstring_cl'>some simple notice</span>&#8221;
     <span class='momnode_cl'>*<span class='momconn_cl'>
     <span class='momitemref_cl'>web_state</span></span>
     (<span class='momnumber_cl'>2</span>)</span>
     <span class='momitemval_cl'>hashset</span>
     <span class='momset_cl'>{<span class='momitemref_cl'>microedit</span>
     <span class='momitemref_cl'>the_agenda</span>}</span>
     <span class='momtuple_cl'>[<span class='momitemref_cl'>web_session</span>
     <span class='momitemref_cl empty_cl'>~</span>
     <span class='momitemref_cl'>the_system</span>]</span>)</span> ;</dd>     
</div>
<hr/>

Edited, to fix the problem of not capturing special keys especially when shift was pressed and the same keyCode is generated for keypress. Added, keydown for handling special keys.

Note: This is assuming that to happen on the entire div. As I see in the question, there are only spans and that too nested ones. There are no other elements. If there are other elements involved and you want to exempt those, then you will need to bind the event to those elements only. This is because, the events on children are captured by the parent when parent is contenteditable and not fired on the children.



回答5:

A straightforward solution to your problem would be to listen on the keydown event fired by the inner-most element and act accordingly. An exemplary code snippet can be found below:

HTML:

<div class="momitemref_cl" contenteditable="true">Foo Bar</div>
<input class="not-momitemref_cl"/>
<input class="momitemref_cl"/>

JS:

document.querySelectorAll('.momitemref_cl').forEach((el) => {
    el.addEventListener('keydown', validateInput);
    el.addEventListener('cut', e => e.preventDefault());
    el.addEventListener('copy', e => e.preventDefault());
    el.addEventListener('paste', e => e.preventDefault());
});

function validateInput(userAction) {
    console.log(userAction);
    if (userAction.ctrlKey) {
        userAction.preventDefault();
        return false;
    }
    let code = (userAction.keyCode ? userAction.keyCode : userAction.which);
    if ((48 <= code && code <= 57 && !userAction.shiftKey) || (65 <= code && code <= 90) || (97 <= code && code <= 122) || code === 32) {
        console.log(`Allowing keypress with code: ${code}`);
        return true;
    }
    console.log(`Preventing keypress with code: ${code}`);
    userAction.preventDefault();
    return false;
}

This works for both <input> elements as well as elements with the contenteditable attribute set to true.

JS Fiddle: https://jsfiddle.net/rsjw3c87/22/

EDIT: Also added additional checks to prevent right-click & copy/cut/paste. Disabling right-click directly via the contextmenu event will not work as certain browsers & OSes disallow you from disabling that specific event.