Add dev design, update explainer and demo app
This commit is contained in:
Родитель
7d5c740c12
Коммит
74e47ad361
|
@ -2,7 +2,11 @@
|
|||
|
||||
![shared text](shared_text_basic.png)
|
||||
|
||||
The typical flow of text input comes from the user pressing keys on the keyboard. These are delivered to the browser, which opted-in to using the system's text services framework in order to integrate with the IMEs installed on the system. This will cause input to be forwarded to the active IME. The IME is then able to query the text services to read contextual information related to the underlying editable text in order to provide suggestions, and potentially modify which character(s) should be written to the shared buffer. These modifications are typically performed based on the current selection, which is also communicated through the text services framework. When the shared buffer is updated, the web application will be notified of this via the ```textupdate``` event.
|
||||
1. The typical flow of text input comes from the user pressing keys on the keyboard.
|
||||
2. These are delivered to the browser, which opted-in to using the system's text services framework in order to integrate with the IMEs installed on the system. This will cause input to be forwarded to the active IME.
|
||||
3. The IME is then able to query the text services to read contextual information related to the underlying editable text in order to provide suggestions, and potentially modify which character(s) should be written to the shared buffer.
|
||||
4. These modifications are typically performed based on the current selection, which is also communicated through the text services framework.
|
||||
5. When the shared buffer is updated, the web application will be notified of this via the ```textupdate``` event.
|
||||
|
||||
When an EditContext has focus, this sequence of events is fired when a key is pressed and an IME is not active:
|
||||
|
||||
|
@ -12,7 +16,6 @@ When an EditContext has focus, this sequence of events is fired when a key is pr
|
|||
| textupdate | active EditContext |
|
||||
| keyup | focused element |
|
||||
|
||||
Because the web page has opted in to the EditContext having focus, keypress is not delivered, as it is redundant with the `textupdate` event for editing scenarios.
|
||||
|
||||
Now consider the scenario where an IME is active, the user types in two characters, then commits to the first IME candidate by hitting 'Space'.
|
||||
|
||||
|
@ -28,13 +31,13 @@ Now consider the scenario where an IME is active, the user types in two characte
|
|||
| keydown | focused element | Space
|
||||
| textupdate | active EditContext | (committed IME characters available in event.updateText)
|
||||
| keyup | focused element | ...
|
||||
| compositioncomplete | active EditContext |
|
||||
| compositionend | active EditContext |
|
||||
|
||||
Note that the composition events are also not fired on the focused element as the composition is operating on the shared buffer that is represented by the EditContext.
|
||||
|
||||
### Externally triggered changes
|
||||
|
||||
Changes to the editable contents can also come from external events, such as collaboration scenarios. In this case, the web editing framework may get some XHR completion that notifies it of some pending collaboartive change that another user has committed. The framework is then responsible for writing to the shared buffer, via the ```textChanged()``` method.
|
||||
Changes to the editable contents can also come from external events, such as collaboration scenarios. In this case, the web editing framework may get some XHR completion that notifies it of some pending collaboartive change that another user has committed. The framework is then responsible for writing to the shared buffer, via the ```updateText()``` method.
|
||||
|
||||
![external input](external_input.png)
|
||||
|
||||
|
@ -42,47 +45,57 @@ Changes to the editable contents can also come from external events, such as col
|
|||
|
||||
The ```textupdate``` event will be fired on the EditContext when user input has resulted in characters being applied to the editable region. The event signals the fact that the software keyboard or IME updated the text (and as such that state is reflected in the shared buffer at the time the event is fired). This can be a single character update, in the case of typical typing scenarios, or multiple-character insertion based on the user changing composition candidates. Even though text updates are the results of the software keyboard modifying the buffer, the creator of the EditContext is ultimately responsible for keeping its underlying model up-to-date with the content that is being edited as well as telling the EditContext about such changes. These could get out of sync, for example, when updates to the editable content come in through other means (the backspace key is a canonical example — no ```textupdate``` is fired in this case, and the consumer of the EditContext should detect the keydown event and remove characters as appropriate).
|
||||
|
||||
Updates to the shared buffer driven by the webpage/javascript are performed by calling the ```textChanged()``` method on the EditContext. ```textChanged()``` accepts a range (start and end offsets over the underlying buffer) and the characters to insert at that range. ```textChanged()``` should be called anytime the editable contents have been updated. However, in general this should be avoided during the firing of ```textupdate``` as it will result in a canceled composition.
|
||||
Updates to the shared buffer driven by the webpage/javascript are performed by calling the ```updateText()``` method on the EditContext. ```updateText()``` accepts a range (start and end offsets over the underlying buffer) and the characters to insert at that range. ```updateText()``` should be called anytime the editable contents have been updated. However, in general this should be avoided during the firing of ```textupdate``` as it will result in a canceled composition.
|
||||
|
||||
The ```selectionupdate``` event may be fired by the browser when the IME wants a specific region selected, generally in response to an operation like IME reconversion.
|
||||
```updateSelection()``` should be called by the web page in order to communicate whenever the selection has changed. It takes as parameters a start and end character offsets, which are based on the underlying flat text buffer held by the EditContext. This would need to be called in the event that a combination of control keys (e.g. Shift + Arrow) or mouse events result in a change to the selection on the edited document.
|
||||
|
||||
```selectionChanged()``` should be called by the web page in order to communicated whenever the selection has changed. It takes as parameters a start and end character offsets, which are based on the underlying flat text buffer held by the EditContext. This would need to be called in the event that a combination of control keys (e.g. Shift + Arrow) or mouse events result in a change to the selection on the edited document.
|
||||
|
||||
The ```layoutChanged()``` method must be called whenever the [client coordinates](https://drafts.csswg.org/cssom-view/#dom-mouseevent-clientx) (i.e. relative to the origin of the viewport) of the view of the EditContext have changed. This includes if the viewport is scrolled or the position of the editable contents changes in response to other updates to the view. The arguments to this method describe a bounding box in client coordinates for both the editable region and also the current selection.
|
||||
The ```updateLayout()``` method must be called whenever the [client coordinates](https://drafts.csswg.org/cssom-view/#dom-mouseevent-clientx) (i.e. relative to the origin of the viewport) of the view of the EditContext have changed. This includes if the viewport is scrolled or the position of the editable contents changes in response to other updates to the view. The arguments to this method describe a bounding box in client coordinates for both the editable region and also the current selection. The rectangles communicated through this API are used to scroll the EditContext into view when the software input panel gets raised by text input service or for IMEs to position the candidate window at the location where the composition is taking place.
|
||||
|
||||
The ```textformatupdate``` event is fired when the input method desires a specific region to be styled in a certain fashion, limited to the style properties that correspond with the properties that are exposed on TextFormatUpdateEvent (e.g. backgroundColor, textDecoration, etc.). The consumer of the EditContext should update their view accordingly to provide the user with visual feedback as prescribed by the software keyboard. Note that this may have accessibility implications, as the IME may not be aware of the color scheme of the editable contents (i.e. may be requesting blue highlight on text that was already blue).
|
||||
|
||||
```compositionstart``` and ```compositioncompleted``` fire when IME composition begins and ends. It does not provide any other contextual information, as the ```textupdate``` events will let the application know the text that the user chose to insert.
|
||||
```compositionstart``` and ```compositionend``` fire when IME composition begins and ends. It does not provide any other contextual information, as the ```textupdate``` events will let the application know the text that the user chose to insert.
|
||||
|
||||
There can be multiple EditContext's per document, and they each have a notion of focused state. Because there is no implicit representation of the EditContext in the HTML view, focus must be managed by the web developer, most likely by forwarding focus calls from the DOM element that contains the editable view. ```focus``` and ```blur``` events are fired on the EditContext in reponse to changes in the focused state. EditContext focus is bound to the element that was focused when the EditContext became active, that is, if the focused element changes, the EditContext will also lose focus.
|
||||
There can be multiple EditContexts per document, and they each have a notion of focused state. Because there is no implicit representation of the EditContext in the HTML view, focus must be managed by the web developer, most likely by forwarding focus calls from the DOM element that contains the editable view. ```focus()``` and ```blur()``` APIs are used to set focus and blur on the EditContext respectively.
|
||||
|
||||
The ```mode``` property on the EditContext (also can be passed in a dictionary to the constructor) denotes what type of input the EditContext is associated with. This information is typically provided to the underlying system as a hint for which software keyboard to load (e.g. keyboard for phone numbers may be a numpad instead of the default keyboard). This defaults to 'text'.
|
||||
The ```inputMode``` property on the EditContext (also can be passed in a dictionary to the constructor) denotes what type of input the EditContext is associated with. This information is typically provided to the underlying system as a hint for which software keyboard to load (e.g. keyboard for phone numbers may be a numpad instead of the default keyboard). This defaults to 'text'.
|
||||
|
||||
## Implementation notes
|
||||
```javascript
|
||||
enum EditContextInputMode { "text", "decimal", "password", "search", "email", "numeric", "tel", "url" }
|
||||
```
|
||||
|
||||
In a browser where the document thread is separate from the input thread, there is some synchronization that needs to take place so that the web developer can provide a consistent and reliable editing experience to the user. Because the threads are separate, there must be a copy of the shared buffer to avoid synchronous communication between the two threads. With a single buffer, synchronous commuincation would be necessary to provide synchronous responses as required by operating system queries about the contents of the document. The copies of the shared buffer are then managed by a component that lives on the input thread, and a component that lives in the web platform component. The copies can then be synchronized by converting updates to asynchronous notifications with ACKs, where the updates are not committed until it has been confirmed as received by the other thread.
|
||||
The ```action``` property on the EditContext (also can be passed in a dictionary to the constructor) denotes what type of Enter key action the EditContext is associated with. This information indicates to the text input services to display different glyphs for the enter key on the software input panel which also changes the functionality of the enter key such as enter to search, enter to send etc.
|
||||
|
||||
As in the previous section the basic flow of input in this model could look like this:
|
||||
![threaded buffer flow](thread_basic.png)
|
||||
![threaded buffer flow external](thread_external.png)
|
||||
```javascript
|
||||
enum EditContextInputAction { "enter", "done", "go", "next", "previous", "search", "send" }
|
||||
```
|
||||
|
||||
### Resolving conflicts
|
||||
The ```inputPolicy``` property on the EditContext (also can be passed in a dictionary to the constructor) denotes whether the virtual keyboard should be raised automatically or not when an EditContext is focused. It enables web authors to control the visibility of the VKs.
|
||||
|
||||
It is possible for conflicts to occur between the input thread and script thread updating the shared buffer. These can be resolved in such a way that the users input is not dropped and is consistently applied in the expected manner.
|
||||
```javascript
|
||||
enum EditContextInputPolicy { "auto", "manual" }
|
||||
```
|
||||
|
||||
Let's say there is an EditContext that starts with a shared buffer of ```"abc|"``` with the selection/caret being at the end of the buffer. The user types ```d``` and approximately the same time, there is a collaborative update (perhaps triggered/detected by a completed XHR) to the document that prepends ```x``` — these are delivered independently to each thread.
|
||||
1. The input thread sees the insertion of ```d``` at position 3, the shared buffer is updated to ```"abcd|```, and the input thread component keeps a record of this pending action. It then sends a textupdate notification to the document thread.
|
||||
2. Meanwhile, prior to receiving that notification, the document thread processes the prepending of ```x``` and sends a notification to the input thread of this text change, keeping track of the fact that it too has a pending operation.
|
||||
3. The input thread receives the text change notification prior to the ACK for its pending textupdate. To resolve this conflict, it undoes the pending insertion of ```d``` and applies the text change. It is then determined that the previous insertion location of ```d``` was not modified* by the text change, so it replays the insertion of ```d```, but at position 4 instead and keeps this as a pending update. This leaves the shared buffer as ```"xabcd|"```. The ACK of the text change is sent to the document thread.
|
||||
4. The document thread then yields and receives the text update of ```d``` at position 3. It determines that it has a pending operation outstanding, so runs through the same algorithm as the input thread — the ```x``` is already prepended but the text update is determined to not have been modified by the pending operations. The text update is then adjusted and applied as ```d``` at position 4. The text update is then ACK'd back to the input thread.
|
||||
5. The ACK of the text change is received on the document thread and the pending operation is removed (committed)
|
||||
6. The ACK of the text update is received on the input thread and its pending operation is also removed (committed)
|
||||
### Renderer process IME components:
|
||||
![Renderer process communication](renderer_process_comm.png)
|
||||
|
||||
\* An operation is only affected by a change if the range on which it was originally intended to apply to has been modified.
|
||||
1. WidgetInputHandlerImpl: Receives the IME messages in the IO thread and posts it to the main thread of the renderer process.
|
||||
2. It is then received by the RenderWidget that sends it to the WebInputMethodControllerImpl to decide which component should handle the IME event and fire the corresponding JS event.
|
||||
3. WebInputMethodControllerImpl routes the IME events to the EditContext if there is an EditContext in focus, else it calls the InputMethodController APIs if the focused node is editable.
|
||||
4. InputMethodController: A final class that is created using LocalFrame. This class has APIs to interact with DOM, selection controllers, “visible” range in the plain text view of the DOM, Editor etc. It also facilitates composition that is platform agnostic. It uses generic structure to represent the range of the selection, composed text (ImeTextSpan) etc.
|
||||
5. If EditContext is in focus, then it updates the internal states and fires the corresponding events to JS.
|
||||
|
||||
![thread conflict](thread_conflict.png)
|
||||
### EditContext:
|
||||
![Class diagram](edit_context_class_design.png)
|
||||
|
||||
The layout position of the EditContext is also reported to the input thread component, which caches the values and lets the text services know that the position has changed. In turn, it uses the cached values to respond to any read requests from the text services.
|
||||
This class implements the WebInputMethodController interface and is also the event target for various JS events that get fired based on the IME and English typing events. The lifetime of the EditContext is managed by the Document. There can be multiple EditContext for an active document but only one can be focused at a time. The EditContext JS events are fired whenever there is an active composition. EditContext also maintains internal states that get updated during these input events. These internal states are used to communicate changes to the text input services that might affect their text view of the edit control.
|
||||
|
||||
### Synchronization mechanism
|
||||
![Synchronization mechanism](edit_context_state_sync.png)
|
||||
|
||||
1. EditContext's state can be manipulated by either text input services or JS authors. This state is kept in sync with the text input services via TextInputState data object. This TextInputState object contains the data that is required by the text input services to synchronize their text view and provide meaningful suggestions and other text input intelligence operation.
|
||||
2. The TextInputState object is updated on every lifecycle update(BeginMainFrame) which gets invoked right before the paint happens. This TextInputState object is updated from EditContext if it has focus, else, it is updated from InputMethodController and then sent by RenderWidget to the browser process through the IPC mechanism.
|
||||
3. RenderWidgetHostImpl receives this IPC message in the browser process and forwards it to the TextInputManager via RenderWidgetHostViewBase which then notifies all the TextInputState observers.
|
||||
4. The observers of the TextInputState object communicate with the text input services and synchronize the state.
|
||||
|
||||
#### Links to Relevant operating systems input APIs
|
||||
| Operating System | |
|
||||
|
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 117 KiB |
|
@ -0,0 +1,578 @@
|
|||
<html>
|
||||
<head>
|
||||
<style>
|
||||
#realinputcontext {
|
||||
position:absolute;
|
||||
top:20px;
|
||||
right:20px;
|
||||
}
|
||||
#editview {
|
||||
white-space:pre
|
||||
}
|
||||
|
||||
.selection {
|
||||
background:blue;
|
||||
color:white;
|
||||
}
|
||||
.caret {
|
||||
outline: 1px solid black;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw message;
|
||||
}
|
||||
}
|
||||
|
||||
class EditableView {
|
||||
constructor(editContext, editModel) {
|
||||
this.editContext = editContext;
|
||||
this.editModel = editModel;
|
||||
|
||||
this.caret = document.createElement('span');
|
||||
this.caretOn = true;
|
||||
this.caretInterval = -1;
|
||||
this.caret.style = "outline:1px solid black";
|
||||
|
||||
this.viewElement = document.createElement('code');
|
||||
this.viewElement.appendChild(this.caret);
|
||||
this.viewElement.style = "position:relative; white-space:pre; width:300px; height:300px";
|
||||
|
||||
// TODO: Belong on controller?
|
||||
editableviewholder.addEventListener("mouseup", (function () {
|
||||
console.log("view got mouseup");
|
||||
this.focus();
|
||||
this.updateView();
|
||||
}).bind(this));
|
||||
|
||||
editableviewholder.appendChild(this.viewElement);
|
||||
|
||||
this.updateQueued = false;
|
||||
|
||||
this.caretRange = document.createRange();
|
||||
}
|
||||
|
||||
queueUpdate() {
|
||||
if (!this.updateQueued) {
|
||||
window.requestAnimationFrame((() => {
|
||||
this.updateView();
|
||||
// Turn caret on while the view is actively being updated
|
||||
this.caretOn = true;
|
||||
this.updateQueued = false;
|
||||
}).bind(this));
|
||||
this.updateQueued = true;
|
||||
}
|
||||
}
|
||||
|
||||
updateView() {
|
||||
let html = this.editModel.asHtml();
|
||||
if (html !== "") {
|
||||
this.viewElement.innerHTML = html;
|
||||
} else {
|
||||
this.viewElement.innerHTML = "";
|
||||
this.viewElement.appendChild(this.caret);
|
||||
}
|
||||
var viewRect = this.viewElement.getBoundingClientRect();
|
||||
viewRect.x = viewRect.left;
|
||||
viewRect.y = viewRect.top;
|
||||
var caretRect = this.viewElement.getBoundingClientRect();
|
||||
// TODO: get the actual caret's range
|
||||
caretRect.x = caretRect.left + this.editModel.desiredCaretX;
|
||||
caretRect.y = 2.2 * caretRect.top;
|
||||
caretRect.width = 1;
|
||||
this.editContext.updateLayout(viewRect, caretRect);
|
||||
}
|
||||
|
||||
updateCaret() {
|
||||
if (this.editContext !== undefined) {
|
||||
this.caret.style.visibility = this.caretOn ? "visible" : "hidden";
|
||||
this.caretOn = !this.caretOn;
|
||||
if (this.caretInterval === -1) {
|
||||
const CARET_UPDATE_MS = 500;
|
||||
this.caretInterval = window.setInterval((() => this.updateCaret()).bind(this), CARET_UPDATE_MS);
|
||||
}
|
||||
} else if (this.caretInterval !== -1) {
|
||||
this.caret.style.visibility = "hidden";
|
||||
window.clearInterval(this.caretInterval);
|
||||
this.caretInterval = -1;
|
||||
}
|
||||
}
|
||||
focus() {
|
||||
this.editContext.focus();
|
||||
this.caretOn = true;
|
||||
this.updateCaret();
|
||||
}
|
||||
|
||||
blur() {
|
||||
//this.editContext.blur();
|
||||
this.updateCaret();
|
||||
}
|
||||
}
|
||||
|
||||
class Position {
|
||||
constructor(x, y) {
|
||||
x = (x !== undefined) ? x : -1;
|
||||
y = (y !== undefined) ? y : -1;
|
||||
this.set(x, y);
|
||||
}
|
||||
|
||||
unposition() {
|
||||
this.set(-1, -1);
|
||||
}
|
||||
|
||||
isPositioned() {
|
||||
assert((this.y === -1) === (this.x === -1), "x and y positioning must be in sync");
|
||||
return (this.y !== -1 && this.x !== -1);
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
assert(other.__proto__ === Position.prototype, "Can only test equality against another Position object");
|
||||
return (this.y === other.y && this.x === other.x);
|
||||
}
|
||||
|
||||
assign(other) {
|
||||
assert(other.__proto__ === Position.prototype, "Can only assign positions to another Position object");
|
||||
this.x = other.x;
|
||||
this.y = other.y;
|
||||
}
|
||||
|
||||
set(x, y) {
|
||||
assert(typeof x === "number", "Position.x must be a number");
|
||||
assert(typeof y === "number", "Position.y must be a number");
|
||||
assert(x !== NaN, "Position.x must not be NaN");
|
||||
assert(y !== NaN, "Position.y must not be NaN");
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
class Selection {
|
||||
constructor() {
|
||||
this.anchor = new Position();
|
||||
this.focus = new Position();
|
||||
}
|
||||
|
||||
unposition() {
|
||||
this.anchor.unposition();
|
||||
this.focus.unposition();
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.anchor.equals(this.focus);
|
||||
}
|
||||
|
||||
isPositioned() {
|
||||
return this.anchor.isPositioned();
|
||||
}
|
||||
|
||||
ensureSelection(x, y) {
|
||||
if (!this.anchor.isPositioned()) {
|
||||
this.anchor.set(x, y);
|
||||
this.focus.set(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// start() and end() are based on document position, and doesn't provide
|
||||
// information about the anchor or focus.
|
||||
start() {
|
||||
if (this.anchor.isPositioned()) {
|
||||
if (this.focus.y < this.anchor.y) {
|
||||
return this.focus;
|
||||
} else if (this.focus.y > this.anchor.y) {
|
||||
return this.anchor;
|
||||
} else {
|
||||
return (this.focus.x < this.anchor.x) ? this.focus : this.anchor;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
end() {
|
||||
let startPosition = this.start();
|
||||
if (startPosition === null) {
|
||||
return null;
|
||||
}
|
||||
return startPosition === this.anchor ? this.focus : this.anchor;
|
||||
}
|
||||
}
|
||||
|
||||
class EditModel {
|
||||
constructor(editContext) {
|
||||
this.textRows = [];
|
||||
this.caretPosition = new Position(0, 0);
|
||||
this.textRows[this.caretPosition.y] = [];
|
||||
this.desiredCaretX = 0;
|
||||
this.selection = new Selection();
|
||||
this.CaretMovement = { LEFT:0, RIGHT:1, WORDLEFT:2, WORDRIGHT:3, HOME:4, END:5, UP: 6, DOWN: 7};
|
||||
}
|
||||
|
||||
asHtml() {
|
||||
let html = "";
|
||||
let selectionStart = this.selection.start();
|
||||
let selectionEnd = this.selection.end();
|
||||
this.textRows.forEach((row, index) => {
|
||||
|
||||
let selectionStartX = (selectionStart && selectionStart.y === index) ? selectionStart.x : -1;
|
||||
let selectionEndX = (selectionEnd && selectionEnd.y === index) ? selectionEnd.x : -1;
|
||||
let offsetRealized = 0;
|
||||
if (selectionStart) {
|
||||
if (selectionStartX !== -1) {
|
||||
// Lay down start selection element
|
||||
html += row.slice(0, selectionStartX).join('');
|
||||
if (selectionStart === this.selection.focus) {
|
||||
html += "<span class='caret'></span>";
|
||||
}
|
||||
html += "<span class='selection'>";
|
||||
offsetRealized = selectionStartX;
|
||||
}
|
||||
if (selectionEndX !== -1) {
|
||||
html += row.slice(offsetRealized, selectionEndX).join('');
|
||||
html += "</span>";
|
||||
if (selectionEnd === this.selection.focus) {
|
||||
html += "<span class='caret'></span>";
|
||||
}
|
||||
offsetRealized = selectionEndX;
|
||||
}
|
||||
} else {
|
||||
if (this.caretPosition.y === index) {
|
||||
html += row.slice(0, this.caretPosition.x).join('');
|
||||
html += "<span class='caret'></span>";
|
||||
offsetRealized = this.caretPosition.x;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (offsetRealized != row.length) {
|
||||
html += row.slice(offsetRealized, row.length).join('');
|
||||
}
|
||||
|
||||
html += "<br>";
|
||||
})
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
updateText(text, start, end) {
|
||||
console.log(`text:${text}, start:${start}, end:${end}`)
|
||||
console.log(`line: ${this.textRows[this.caretPosition.y].join("")}`)
|
||||
this.textRows[this.caretPosition.y].splice(start, end - start, ...text.split(""));
|
||||
this.caretPosition.set(this.caretPosition.x - (end - start) + text.length, this.caretPosition.y);
|
||||
this.desiredCaretX = this.caretPosition.x;
|
||||
}
|
||||
|
||||
getCaretPosition() {
|
||||
return this.caretPosition;
|
||||
}
|
||||
|
||||
currentRow() {
|
||||
return this.textRows[this.caretPosition.y];
|
||||
}
|
||||
|
||||
movePosition(position, movement) {
|
||||
switch (movement) {
|
||||
case this.CaretMovement.LEFT:
|
||||
if (position.x !== 0) {
|
||||
position.set(position.x - 1, position.y);
|
||||
} else if (position.y !== 0) {
|
||||
let previousRow = position.y - 1;
|
||||
position.set(this.textRows[previousRow].length, previousRow);
|
||||
}
|
||||
break;
|
||||
case this.CaretMovement.RIGHT:
|
||||
if (position.x !== this.currentRow().length) {
|
||||
position.set(position.x + 1, position.y);
|
||||
} else if (position.y !== this.textRows.length - 1) {
|
||||
position.set(0, position.y + 1);
|
||||
}
|
||||
break;
|
||||
case this.CaretMovement.UP:
|
||||
if (position.y !== 0) {
|
||||
let previousRow = position.y - 1;
|
||||
position.set(Math.min(this.textRows[previousRow].length, this.desiredCaretX), previousRow);
|
||||
}
|
||||
break;
|
||||
case this.CaretMovement.DOWN:
|
||||
if (position.y !== this.textRows.length - 1) {
|
||||
let nextRow = position.y + 1;
|
||||
position.set(Math.min(this.textRows[nextRow].length, this.desiredCaretX), nextRow);
|
||||
}
|
||||
break;
|
||||
case this.CaretMovement.WORDLEFT:
|
||||
break;
|
||||
case this.CaretMovement.WORDRIGHT:
|
||||
break;
|
||||
case this.CaretMovement.HOME:
|
||||
position.set(0, position.y);
|
||||
break;
|
||||
case this.CaretMovement.END:
|
||||
let selectionRow = position.y;
|
||||
position.set(this.textRows[selectionRow].length, selectionRow);
|
||||
break;
|
||||
|
||||
default:
|
||||
assert(false, "Invalid position movement");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
isHorizontalMovement(movement) {
|
||||
return movement === this.CaretMovement.LEFT ||
|
||||
movement === this.CaretMovement.RIGHT ||
|
||||
movement === this.CaretMovement.HOME ||
|
||||
movement === this.CaretMovement.END;
|
||||
}
|
||||
|
||||
updateSelection(movement) {
|
||||
this.selection.ensureSelection(this.caretPosition.x, this.caretPosition.y);
|
||||
this.movePosition(this.selection.focus, movement);
|
||||
|
||||
if (this.selection.isEmpty()) {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selection.unposition();
|
||||
}
|
||||
|
||||
moveCaret(movement, shift) {
|
||||
if (shift) {
|
||||
this.updateSelection(movement);
|
||||
this.movePosition(this.caretPosition, movement);
|
||||
} else {
|
||||
if (this.selection.anchor.isPositioned()) {
|
||||
this.clearSelection();
|
||||
}
|
||||
this.movePosition(this.caretPosition, movement);
|
||||
}
|
||||
|
||||
if (this.isHorizontalMovement(movement)) {
|
||||
this.desiredCaretX = this.caretPosition.x;
|
||||
}
|
||||
}
|
||||
|
||||
backspace(shift, control) {
|
||||
if (!this.selection.isPositioned()) {
|
||||
if (this.caretPosition.x !== 0) {
|
||||
this.currentRow().splice(this.caretPosition.x - 1, 1);
|
||||
this.caretPosition.x--;
|
||||
this.desiredCaretX = this.caretPosition.x;
|
||||
} else if (this.caretPosition.y !== 0) {
|
||||
let currentRow = this.currentRow();
|
||||
let endOfPrevRow = this.textRows[this.caretPosition.y - 1].length;
|
||||
this.textRows.splice(this.caretPosition.y, 1);
|
||||
this.caretPosition.y--;
|
||||
this.textRows[this.caretPosition.y] = this.currentRow().concat(currentRow);
|
||||
|
||||
this.caretPosition.x = endOfPrevRow;
|
||||
this.desiredCaretX = this.caretPosition.x;
|
||||
}
|
||||
} else {
|
||||
this.caretPosition.assign(this.selection.start());
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
delete(shift, control) {
|
||||
if (!this.selection.isPositioned()) {
|
||||
if (shift) {
|
||||
this.textRows.splice(this.caretPosition.y, 1);
|
||||
if (this.caretPosition.y === this.textRows.length) {
|
||||
this.textRows.push([]);
|
||||
}
|
||||
this.caretPosition.set(0, this.caretPosition.y);
|
||||
} else {
|
||||
if (this.caretPosition.x !== this.currentRow().length) {
|
||||
this.currentRow().splice(this.caretPosition.x, 1);
|
||||
} else if (this.caretPosition.y < this.textRows.length - 1) {
|
||||
let nextRow = this.textRows[this.caretPosition.y + 1];
|
||||
this.textRows.splice(this.caretPosition.y + 1, 1);
|
||||
this.textRows[this.caretPosition.y] = this.currentRow().concat(nextRow);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.replaceSelection("");
|
||||
this.caretPosition.assign(this.selection.start());
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
replaceSelection(replacementText) {
|
||||
let start = this.selection.start();
|
||||
let end = this.selection.end();
|
||||
let endRow = this.textRows[end.y];
|
||||
let sequenceAfterEnd = endRow.splice(end.x, endRow.length - end.x);
|
||||
let startRow = this.textRows[start.y];
|
||||
startRow.splice(start.x, startRow.length - start.x);
|
||||
this.textRows[start.y] = startRow.concat(sequenceAfterEnd);
|
||||
this.textRows.splice(start.y + 1, end.y);
|
||||
}
|
||||
|
||||
addRow() {
|
||||
// Add a new row at caret y + 1
|
||||
let currentRow = this.currentRow();
|
||||
let tailCurrentRow = currentRow.splice(this.caretPosition.x, currentRow.length);
|
||||
|
||||
this.caretPosition.set(0, this.caretPosition.y + 1);
|
||||
this.desiredCaretX = this.caretPosition.x;
|
||||
this.textRows.splice(this.caretPosition.y, 0, tailCurrentRow);
|
||||
}
|
||||
}
|
||||
|
||||
class EditController {
|
||||
constructor(editContext, model, view) {
|
||||
this.editContext = editContext;
|
||||
this.model = model;
|
||||
this.view = view;
|
||||
this.controlPressed = false;
|
||||
|
||||
editableviewholder.addEventListener("focus", (e => {
|
||||
console.log("focus");
|
||||
this.view.focus();
|
||||
}).bind(this));
|
||||
|
||||
editableviewholder.addEventListener("blur", (e => {
|
||||
console.log("blur");
|
||||
this.view.blur();
|
||||
}).bind(this));
|
||||
|
||||
editableviewholder.addEventListener("keypress", e => {
|
||||
console.log("keypress "+e.key);
|
||||
if (e.key === "Enter"){
|
||||
this.editContext.updateText(this.editContext.selection.start, this.editContext.selection.end, "\n");
|
||||
this.editContext.updateSelection(this.editContext.selection.start + 1, this.editContext.selection.start + 1);
|
||||
} else {
|
||||
this.model.updateText(e.key, this.editContext.selection.start, this.editContext.selection.end);
|
||||
this.editContext.updateText(this.editContext.selection.start, this.editContext.selection.end, e.key);
|
||||
this.editContext.updateSelection(this.editContext.selection.start + e.key.length, this.editContext.selection.start + e.key.length);
|
||||
this.view.queueUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
editableviewholder.addEventListener("keydown", e => {
|
||||
console.log(`keydown: ${e.key}`)
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
this.model.moveCaret(this.model.CaretMovement.LEFT, this.shiftPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
this.model.moveCaret(this.model.CaretMovement.RIGHT, this.shiftPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
this.model.moveCaret(this.model.CaretMovement.UP, this.shiftPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
this.model.moveCaret(this.model.CaretMovement.DOWN, this.shiftPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "Home":
|
||||
this.model.moveCaret(this.model.CaretMovement.HOME, this.shiftPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "End":
|
||||
this.model.moveCaret(this.model.CaretMovement.END, this.shiftPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "Enter":
|
||||
this.model.addRow();
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case " ":
|
||||
this.editContext.updateText(this.editContext.selection.start, this.editContext.selection.end, " ");
|
||||
this.editContext.updateSelection(this.editContext.selection.start + 1, this.editContext.selection.start + 1);
|
||||
this.model.updateText(" ", this.editContext.selection.start, this.editContext.selection.end);
|
||||
this.view.queueUpdate();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "Backspace":
|
||||
this.editContext.updateText(this.editContext.selection.start - 1, this.editContext.selection.end, "");
|
||||
this.editContext.updateSelection(this.editContext.selection.start - 1, this.editContext.selection.start - 1);
|
||||
this.model.backspace(this.shiftPressed, this.controlPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "Delete":
|
||||
this.model.delete(this.shiftPressed, this.controlPressed);
|
||||
this.view.queueUpdate();
|
||||
break;
|
||||
case "Control":
|
||||
this.controlPressed = true;
|
||||
break;
|
||||
case "Shift":
|
||||
this.shiftPressed = true;
|
||||
break;
|
||||
}
|
||||
});
|
||||
editableviewholder.addEventListener("keyup", e => {
|
||||
console.log(`keyup: ${e.key}`)
|
||||
switch (e.key) {
|
||||
case "Control":
|
||||
this.controlPressed = false;
|
||||
break;
|
||||
case "Shift":
|
||||
this.shiftPressed = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.editContext.addEventListener("textupdate", (e => {
|
||||
console.log("update text " + "Selection offsets are " + "start " + e.newSelection.start + "end " + e.newSelection.end);
|
||||
this.model.updateText(e.updateText, e.updateRange.start, e.updateRange.end);
|
||||
this.view.queueUpdate();
|
||||
}).bind(this));
|
||||
editContext.addEventListener("textformatupdate", e => { console.log("formatupdating") });
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
let editContext = new EditContext();
|
||||
let model = new EditModel(editContext);
|
||||
let view = new EditableView(editContext, model);
|
||||
let controller = new EditController(editContext, model, view);
|
||||
// Render the initial view, so that you see the first blinking caret
|
||||
view.queueUpdate();
|
||||
|
||||
let select = document.querySelector("#input-type")
|
||||
let contextTypes = {
|
||||
text: new EditContext({editContextType: "text" }),
|
||||
password: new EditContext({editContextType: "password" }),
|
||||
search: new EditContext({editContextType: "search" }),
|
||||
email: new EditContext({editContextType: "email" }),
|
||||
number: new EditContext({editContextType: "number" }),
|
||||
telephone: new EditContext({editContextType: "telephone" }),
|
||||
url: new EditContext({editContextType: "url" }),
|
||||
date: new EditContext({editContextType: "date" }),
|
||||
datetime: new EditContext({editContextType: "datetime" })
|
||||
}
|
||||
select.addEventListener("focus", () => {
|
||||
let type = select.options[select.selectedIndex].value
|
||||
contextTypes[type].focus()
|
||||
})
|
||||
select.addEventListener("change", () => {
|
||||
let type = select.options[select.selectedIndex].value
|
||||
contextTypes[type].focus()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<body>
|
||||
<select style="position:fixed; right: 50px; top: 8px; width: 100px;" id="input-type">
|
||||
<option>text</option>
|
||||
<option>password</option>
|
||||
<option>search</option>
|
||||
<option>email</option>
|
||||
<option>number</option>
|
||||
<option>telephone</option>
|
||||
<option>url</option>
|
||||
<option>date</option>
|
||||
<option>datetime</option>
|
||||
</select>
|
||||
<p> This is an editable region, based on EditContext:<br>
|
||||
<b>usage:</b> new EditContext()
|
||||
</p>
|
||||
<div id="editableviewholder" style="width:300px; height:300px; border:blue 1px dashed" tabindex="-1"></div>
|
||||
<p> Some footer where other content might live </p>
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 661 KiB |
|
@ -21,16 +21,15 @@ interface TextFormatUpdateEvent : Event {
|
|||
readonly attribute DOMString textUnderlineStyle;
|
||||
};
|
||||
|
||||
enum EditContextInputType {
|
||||
enum EditContextInputMode {
|
||||
"text",
|
||||
"decimal",
|
||||
"password",
|
||||
"search",
|
||||
"email",
|
||||
"number",
|
||||
"telephone",
|
||||
"url",
|
||||
"date",
|
||||
"datetime"
|
||||
"numeric",
|
||||
"tel",
|
||||
"url"
|
||||
};
|
||||
|
||||
enum EditContextInputAction {
|
||||
|
@ -43,10 +42,16 @@ enum EditContextInputAction {
|
|||
"send"
|
||||
};
|
||||
|
||||
enum EditContextInputPolicy {
|
||||
"auto",
|
||||
"manual"
|
||||
};
|
||||
|
||||
dictionary EditContextInit {
|
||||
EditContextInputType type;
|
||||
DOMString text;
|
||||
EditContextTextRange selection;
|
||||
EditContextInputMode inputMode;
|
||||
EditContextInputPolicy inputPolicy;
|
||||
EditContextInputAction action;
|
||||
};
|
||||
|
||||
|
@ -67,7 +72,8 @@ interface EditContext : EventTarget {
|
|||
|
||||
readonly attribute DOMString text;
|
||||
readonly attribute EditContextTextRange selection;
|
||||
readonly attribute EditContextInputType type;
|
||||
readonly attribute EditContextInputMode inputMode;
|
||||
readonly attribute EditContextInputPolicy inputPolicy
|
||||
readonly attribute EditContextInputAction action;
|
||||
|
||||
// Event handler attributes
|
||||
|
|
|
@ -1,84 +1,390 @@
|
|||
# EditContext API Explained
|
||||
This document proposes a new API for integrating web applications with the input services of the operating system to allow clean separation of document object model and data model, as well as richer functionality that web apps can take advantage of much like desktop apps.
|
||||
# EditContext API Explainer
|
||||
## Introduction
|
||||
The EditContext is a new API that simplifies the process of integrating a web app with advanced text input methods, improves accessibility and performance, and unlocks new capabilities for web-based editors.
|
||||
|
||||
## Motivation:
|
||||
The built-in edit controls of the browser are no longer sufficient as the editing experience has evolved from filling in form data to rich editing experiences in web applications like CKEditor, GSuite, TinyMCE, Office 365, Visual Studio Online and many others.
|
||||
Contenteditable, the most recent editing innovation developed as part of HTML5, was meant to provide a new editing primitive suitable for building rich editing experiences, but came with a design flaw in that it couples the document model and view. As a result, contenteditable is only suitable for editing HTML in a WYSIWYG fashion, and doesn't meet the needs of many editing applications.
|
||||
Despite their shortcomings, contenteditable and the good old textarea element are used by many web-based editors today, as **without a focused, editable element in the DOM, there is no way for an author to leverage the advanced input features of modern operating systems including composition, handwriting recognition, shape-writing, and more**.
|
||||
## Motivation
|
||||
The web platform provides out-of-the-box editing experiences for single lines of plain-text (input), small amounts of multi-line plain-text (textarea) and a starting point for building an HTML document editing experience (contenteditable elements).
|
||||
|
||||
Each approach comes with its own set of drawbacks that include
|
||||
Each of the editable elements provided by the web platform comes with built-in editing behaviors that are often inadequate to power the desired editing experience. As a result, web-based editors don't incorporate the web platform's editable elements into their view. Unfortunately, the only API provided by the web platform today to enable advanced text input experiences is to place an editable element in the DOM and focus it.
|
||||
|
||||
1. Contenteditable approach limits the app's ability to enhance the view, as the view (i.e. the DOM) is also the authoritative source on the contents of the document being edited.
|
||||
1. Additional issue with using contenteditable is that the editing operations built-in to the browser are designed to edit HTML, which produces results that are unrelated to the change in the actual editable document. For example, shown in "Use Cases" section below, typing an 'x' after keyword `public` in the document when using a contenteditable element would continue with the preceding blue color making "publicx" look like a keyword. To avoid the issue, authors may prevent the default handling of input (e.g. on keydown). This can be done, but only for regular keyboard input and when a composition is not in progress, specifically, **there is no way to prevent modification of the DOM during composition without disabling composition**.
|
||||
1. In textarea approach, native selection cannot be used as part of the view (because its being used in the hidden textarea instead), which adds complexity (since the editing app must now build its own representation of selection and the caret), and (unless rebuilt by the editing app) eliminates specialized experiences for touch where selection handles and other affordances can be supplied for a better editing experience.
|
||||
1. When the location of selection in the textarea doesn't perfectly match the location of selection in the view, it creates problems when software keyboards attempt to reposition the viewport to where the system thinks editing is occurring. Input method-specific UI meant to be positioned nearby the selection, for example the UI presenting candidates for phonetically composed text, can also be negatively impacted (in that they will be placed not nearby the composed text in the view).
|
||||
1. Accessibility is negatively impacted. Assistive technologies may highlight the textarea to visually indicate what content the assisted experience applies to. Given that the textarea is likely hidden and not part of the view, these visual indicators will likely appear in the wrong location. Beyond highlighting, the model for accessibility should often match the view and not the portion of the document copied into a textarea. For assistive technology that reads the text of the document, the wrong content may be read as a result.
|
||||
This contradiction of needing an editable element, but not wanting it to be visible, leads web-based editors to create hidden editable elements to facilitate text input. This approach negatively impacts accessibility and increases complexity, leading to buggy behavior.
|
||||
|
||||
To summarize: EditContext API will help to alleviate problems and provide new functionalities, such as:
|
||||
* Code complexity which in turn would increase developer productivity.
|
||||
* A number of composition scenarios. e.g., long running composition in collaboration scenarios.
|
||||
* Native-like functionality, allowing Web Apps to handle OS input in a more efficient fashion.
|
||||
An alternative is to incorporate a contenteditable element into the view of the editor, regardless of whether the editor is editing an HTML document. This approach limits the editor's flexibilty in modifying the view, since the view is also powering the text input experience.
|
||||
|
||||
## Goals:
|
||||
The goal of the EditContext is to expose the lower-level APIs provided by modern operating systems to facilitate various input modalities to unlock advanced editing scenarios.
|
||||
## Real-world Examples of Text Input Issues in Top Sites and Frameworks
|
||||
### Accessibility Issues in the Monaco Editor
|
||||
[This video](https://www.youtube.com/watch?v=xzC86EG9lPo) demos Windows Narrator reading from a hidden textarea element in the Monaco editor and compares it with the intended experience by showing Narrator reading text from CKEditor, which uses a contenteditable element as part of its view.
|
||||
|
||||
## Non-Goals:
|
||||
* EditContext API does not intend to replace existing edit controls that can still be used for simple editing scenarios on the web.
|
||||
* It does not attempt to solve any of the accessibility issues that are observed in editors, today. [AOM](https://wicg.github.io/aom/explainer.html) may be the answer to that.
|
||||
Monaco edits plain text - it's a code editor. The plain text document is presented using a rich view created from HTML, but a hidden textarea is used to integrate with the text input services of the OS. This approach makes the hidden textarea the accessibile surface for the editable content being edited.
|
||||
|
||||
### Use cases
|
||||
#### Editing in online code editor:
|
||||
Visual Studio editing experience. This would be difficult to replicate on the web using a contenteditable element as the view contains more information that the plain-text document being edited. Specifically, the grey text shows commit history and dependency information that is not part of the plain-text C# document being edited. Because input methods query for the text of the document nearby the selection for context, e.g. to provide suggestions, the divergence in document content and presentation can negatively impact the editing experience.
|
||||
Two aspects of accessibility suffer as a result:
|
||||
1. The focused element is off screen so narrator doesn't place a blue outline around the words as they are read aloud.
|
||||
2. Unless Monaco duplicates the whole document into the textarea element, only a fraction of the content can be read before Narrator moves prematurely out of the document content and starts reading elsewhere on the page.
|
||||
|
||||
![Visual Studio's rich view of a plain-text document](visual_studio_editing_experience.png)
|
||||
### Trouble Collaborating in Word Online while Composing Text
|
||||
[This video](https://www.youtube.com/watch?v=s7Ga2VYFiGo) shows a Word Online collaboration feature where two users can see each other's edits and caret positions. Collaboration is suspended though while composition is in progress. When composition is active, updates to the view (especially nearby the composition) may cancel the composition and prevent text input.
|
||||
|
||||
#### Native selection gripper support
|
||||
Below the two animated gifs contrast the experience touching the screen to place a caret for Visual Studio Online (uses a hidden textarea for input and recreates its own selection and caret), versus placing a caret in a contenteditable div in Chrome (grippers shown).
|
||||
To work around this problem, Word Online waits until the composition finishes before updating the view. Some Chinese IMEs don't auto commit their composition; it just keeps going until the user types Enter. As a result, collaboration may be blocked for some time.
|
||||
|
||||
| No grippers | Native Grippers |
|
||||
| ------------- | ------------------ |
|
||||
| ![Missing Grippers in Visual Studio Online](NOGrippersGif.gif) | ![With Grippers in Visual Studio Online](withGrippers.gif) ||
|
||||
### Can't Use the Windows Emoji Picker in Google Docs
|
||||
[In this video](https://www.youtube.com/watch?v=iVclyPE55Js) Google Docs is using an off screen contenteditable element to enable text input. This approach gives Google Docs access to text input features like an IME for composition, as well as enabling the emoji picker and other advanced text input options.
|
||||
|
||||
## Proposal:
|
||||
To avoid the side-effects that come from using editable elements to integrate with input services, we propose using a new object, EditContext, that when created provides a connection to the operating system's input services.
|
||||
Google Docs is listening for events to ensure the contenteditable element is focused and positioned appropriately near the insertion point before composition starts. It isn't aware of all events, or in some cases doesn't receive any events, when other text input UI like the emoji picker is displayed. As a result, the emoji window is positioned near the top of the app (not near the insertion point) and input isn't received since focus isn't currently in an editable element.
|
||||
|
||||
The EditContext is an abstraction over a shared, plain-text input buffer that provides the underlying platform with a view of the content being edited. Creating an EditContext conceptually tells the browser to instantiate the appropriate machinery to create a target for text input operations. In addition to maintaining a shared buffer, the EditContext also has the notion of selection, expressed as offsets into the buffer, state to describe the layout bounds of the view of the editable region, as well as the bounds of the selection. These values are provided in JavaScript to the EditContext in terms of client coordinates and communicated by the browser to the underlying platform to enable rich input experiences.
|
||||
### Trouble Composing Across Page Boundaries
|
||||
[In this video](https://www.youtube.com/watch?v=iXgttLgJY_I) Native Word on Windows is shown updating its view while in an active composition. The scenario demonstrated requires Word to relocate the active composition into a different page based on layout constraints.
|
||||
|
||||
Having a shared buffer and selection for the underlying platform allows it to provide input methods with context regarding the contents being edited, for example, to enable better suggestions while typing. Because the buffer and selection are stateful, updating the contents of the buffer is a cooperative process between the characters coming from user input and changes to the content that are driven by other events. Cooperation takes place through a series of events dispatched on the EditContext to the web application — these events are requests from the underlying platform to read or update the text of the web application. The web application can also proactively communicate changes in its text to the underlying platform by using methods on the EditContext.
|
||||
Because the web platform integrates with the OS text input services through its HTML DOM view, updating the view while composition is in progress may cancel the composition and prevent text input. Using the EditContext, however, the view can be updated and new locations for where composition is occuring can be reported without canceling the composition.
|
||||
|
||||
A web application is free to create multiple EditContexts if there are multiple distinct editable areas in the application. Only the focused EditContext (designated by calling the focus method on the EditContext object) receives updates from the system's input services. Note that the concept of the EditContext being focused is separate from that of the document's activeElement, which will continue to determine the target for dispatching keyboard events.
|
||||
### No Support for Type-to-search in Custom Controls with Chinese Characters
|
||||
[This video](https://www.youtube.com/watch?v=rHEPdi1Rw34) demonstrates an IE feature that automatically selected an option in a select element based on the text typed by the user - even when that text is being composed.
|
||||
|
||||
[API usage examples](examples.md)
|
||||
Custom components have no ability to achieve similar behavior, but with the EditContext API type-to-search can be a reality for arbitrary custom elements. Non-editing scenarios will also benefit from the EditContext.
|
||||
|
||||
While an EditContext is active, the text services framework may read the following state:
|
||||
* Text content
|
||||
* Selection offsets into the text content
|
||||
* The location (on the screen) of selection
|
||||
* The location (on the screen) of the content this EditContext represents
|
||||
## Proposal: EditContext API
|
||||
The EditContext addresses the problems above by decoupling text input from the HTML DOM view. Rather than having the web platform infer the data required to enable sophisticated text input mechanisms from the HTML DOM, the author will provide that data explicitly through the API surface of the EditContext.
|
||||
|
||||
The text services framework can also request that the buffer or view of the application be modified by requesting that:
|
||||
Specifically, the EditContext allows the author to provide:
|
||||
* The coordinates of the selection and of a logically editable element so that UI relating to text input can be appropriately positioned.
|
||||
* Contextual text nearby the selection enabling suggestions for input methods that support generating them.
|
||||
* The location (expressed as offsets into the contextual text) of selection to enable text input to be inserted at the right location.
|
||||
* The inputMode to specialize software keyboard layouts.
|
||||
* The inputAction to specialize the display of the Enter key on software keyboards.
|
||||
* The inputPolicy to control whether a software keyboard should automatically appear or needs to be requested explicitly by the user.
|
||||
* More than one EditContext to convey the information listed above for multiple editable regions of a web application.
|
||||
* An ability to specify which of those multiple EditContexts is currently the target of text input.
|
||||
|
||||
* The text contents be updated
|
||||
* The selection of be relocated
|
||||
* The text contents be marked over a particular range, for example to indicate visually where composition is occurring
|
||||
Additionally, the EditContext communicates events driven from text input UI to JavaScript:
|
||||
* Text and selection update events; these represent requests for the web app to update their text and selection model given some text input from the user.
|
||||
* Composition start and end events.
|
||||
* Text formatting requests that indicate where activity relating to text input, e.g. composition, is taking place.
|
||||
|
||||
The web application is free to communicate before, after or during a request from the underlying platform that its:
|
||||
### EditContext WebIDL
|
||||
```javascript
|
||||
[Exposed=Window]
|
||||
interface EditContextTextRange {
|
||||
attribute long start;
|
||||
attribute long end;
|
||||
};
|
||||
|
||||
* Text content has changed
|
||||
* Selection offsets have changed
|
||||
* The location (on the screen) of selection or content has changed
|
||||
* The preferred mode of input has changed, for example, to provide software keyboard specialization
|
||||
[Exposed=Window]
|
||||
interface TextUpdateEvent : Event {
|
||||
readonly attribute EditContextTextRange updateRange;
|
||||
readonly attribute DOMString updateText;
|
||||
readonly attribute EditContextTextRange newSelection;
|
||||
};
|
||||
|
||||
[Exposed=Window]
|
||||
interface TextFormatUpdateEvent : Event {
|
||||
readonly attribute EditContextTextRange formatRange;
|
||||
readonly attribute DOMString underlineColor;
|
||||
readonly attribute DOMString backgroundColor;
|
||||
readonly attribute DOMString textDecorationColor;
|
||||
readonly attribute DOMString textUnderlineStyle;
|
||||
};
|
||||
|
||||
enum EditContextInputMode {
|
||||
"text",
|
||||
"decimal",
|
||||
"password",
|
||||
"search",
|
||||
"email",
|
||||
"numeric",
|
||||
"tel",
|
||||
"url"
|
||||
};
|
||||
|
||||
enum EditContextInputAction {
|
||||
"enter",
|
||||
"done",
|
||||
"go",
|
||||
"next",
|
||||
"previous",
|
||||
"search",
|
||||
"send"
|
||||
};
|
||||
|
||||
enum EditContextInputPolicy {
|
||||
"auto",
|
||||
"manual"
|
||||
};
|
||||
|
||||
dictionary EditContextInit {
|
||||
DOMString text;
|
||||
EditContextTextRange selection;
|
||||
EditContextInputMode inputMode;
|
||||
EditContextInputPolicy inputPolicy;
|
||||
EditContextInputAction action;
|
||||
};
|
||||
|
||||
/// @event name="textupdate", type="TextUpdateEvent"
|
||||
/// @event name="textformatupdate", type="TextFormatUpdateEvent"
|
||||
/// @event name="focus", type="FocusEvent"
|
||||
/// @event name="blur", type="FocusEvent"
|
||||
/// @event name="compositionstart", type="CompositionEvent"
|
||||
/// @event name="compositionend", type="CompositionEvent"
|
||||
[Exposed=Window]
|
||||
[Constructor(optional EditContextInit options)]
|
||||
interface EditContext : EventTarget {
|
||||
void focus();
|
||||
void blur();
|
||||
void updateSelection(unsigned long start, unsigned long end);
|
||||
void updateLayout(DOMRect controlBounds, DOMRect selectionBounds);
|
||||
void updateText(unsigned long start, unsigned long end, DOMString updateText);
|
||||
|
||||
readonly attribute DOMString text;
|
||||
readonly attribute EditContextTextRange selection;
|
||||
readonly attribute EditContextInputMode inputMode;
|
||||
readonly attribute EditContextInputPolicy inputPolicy
|
||||
readonly attribute EditContextInputAction action;
|
||||
|
||||
// Event handler attributes
|
||||
attribute EventHandler ontextupdate;
|
||||
attribute EventHandler ontextformatupdate;
|
||||
attribute EventHandler oncompositionstart;
|
||||
attribute EventHandler oncompositionend;
|
||||
};
|
||||
```
|
||||
## EditContext Usage
|
||||
### Example 1
|
||||
Create an EditContext and have it start receiving events when its associated container gets focus. After creating an EditContext, the web application should initialize the text and selection (unless the state of the web application is correctly represented by the empty defaults) via a dictionary passed to the constructor. Additionally, the layout bounds of selection and conceptual location of the EditContext in the view should be provided by calling `updateLayout`.
|
||||
|
||||
```javascript
|
||||
let editContainer = document.querySelector("#editContainer");
|
||||
|
||||
let editContextInit = {
|
||||
text: "Hello world",
|
||||
selection: new EditContextTextRange(11, 11),
|
||||
inputMode: "text",
|
||||
inputPolicy: "auto",
|
||||
action: "enter"
|
||||
};
|
||||
let editContext = new EditContext(editContextInit);
|
||||
|
||||
// EditModel and EditView are author supplied code omitted from this example for brevity.
|
||||
let model = new EditModel(editContext, editContextInit.text, editContextInit.selection);
|
||||
let view = new EditView(editContext, model, editContainer);
|
||||
|
||||
// Delegate focus to an EditContext when an "editable" part of the view is focused in the web app.
|
||||
editContainer.addEventListener("focus", () => editContext.focus());
|
||||
window.requestAnimationFrame(() => {
|
||||
editContext.updateLayout(editContainer.getBoundingClientRect(), computeSelectionBoundingRect());
|
||||
});
|
||||
|
||||
editContainer.focus();
|
||||
```
|
||||
|
||||
The following code registers for `textupdate` and keyboard related events (note that keydown/keyup are still delivered to the edit container, i.e. the activeElement). Note that `model` represents the document model for the editable content, and `view` represents an object that produces an HTML view of that document.
|
||||
|
||||
```javascript
|
||||
editContainer.addEventListener("keydown", e => {
|
||||
// Handle control keys that don't result in characters being inserted
|
||||
switch (e.key) {
|
||||
case "Home":
|
||||
model.updateSelection(...);
|
||||
view.queueUpdate();
|
||||
break;
|
||||
case "Backspace":
|
||||
model.deleteCharacters(Direction.BACK);
|
||||
view.queueUpdate();
|
||||
break;
|
||||
...
|
||||
}
|
||||
});
|
||||
|
||||
editContext.addEventListener("textupdate", e => {
|
||||
model.updateText(e.newText, e.updateRange);
|
||||
|
||||
// Do not call updateText on editContext, as we're accepting
|
||||
// the incoming input.
|
||||
|
||||
view.queueUpdate();
|
||||
});
|
||||
|
||||
editContext.addEventListener("textformatupdate", e => {
|
||||
view.addFormattedRange(e.formatRange)
|
||||
});
|
||||
```
|
||||
|
||||
### Example 2
|
||||
|
||||
Example of a user-defined EditModel class that contains the underlying model for the editable content
|
||||
```javascript
|
||||
// User defined class
|
||||
class EditModel {
|
||||
constructor(editContext, text, selection) {
|
||||
// This specific model uses the underlying buffer of the editContext directly
|
||||
// and so doesn't have a backing text store of its own.
|
||||
this.editContext = editContext;
|
||||
this.text = text;
|
||||
this.selection = new Selection();
|
||||
this.setSelection(selection.start, selection.end);
|
||||
}
|
||||
|
||||
updateText(text, start, end) {
|
||||
this.textRows[this.caretPosition.y].splice(start, end - start, ...text.split(""));
|
||||
this.caretPosition.set(this.caretPosition.x - (end - start) + text.length, this.caretPosition.y);
|
||||
this.desiredCaretX = this.caretPosition.x;
|
||||
}
|
||||
|
||||
setSelection(start, end) {
|
||||
this.selection.start = start;
|
||||
this.selection.end = end;
|
||||
}
|
||||
|
||||
updateSelection(...) {
|
||||
// Compute new selection, based on shift/ctrl state
|
||||
let newSelection = computeSelection(this.editContext.currentSelection, ...);
|
||||
this.setSelection(newSelection.start, newSelection.end);
|
||||
this.editContext.updateSelection(newSelection.start, newSelection.end);
|
||||
}
|
||||
|
||||
deleteCharacters(direction) {
|
||||
if (this.selection.start !== this.selection.end) {
|
||||
// Notify EditContext that things are changing.
|
||||
this.editContext.updateText(this.selection.start, this.selection.end, "");
|
||||
this.editContext.updateSelection(this.selection.start, this.selection.start);
|
||||
|
||||
// Update internal model state
|
||||
this.text = text.slice(0, this.selection.start) +
|
||||
text.slice(this.selection.end, this.text.length)
|
||||
this.setSelection(this.selection.start, this.selection.start);
|
||||
} else {
|
||||
// Delete a single character, based on direction (forward or back).
|
||||
// Notify editContext of changes
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3
|
||||
Example of a user defined class that can compute an HTML view, based on the text model
|
||||
```javascript
|
||||
class EditableView {
|
||||
constructor(editContext, editModel, editRegionElement) {
|
||||
this.editContext = editContext;
|
||||
this.editModel = editModel;
|
||||
this.editRegionElement = editRegionElement;
|
||||
|
||||
// When the webpage scrolls, the layout position of the editable view
|
||||
// may change - we must tell the EditContext about this.
|
||||
window.addEventListener("scroll", this.notifyLayoutChanged.bind(this));
|
||||
|
||||
// Same response is needed when the window is resized.
|
||||
window.addEventListener("resize", this.notifyLayoutChanged.bind(this));
|
||||
}
|
||||
|
||||
queueUpdate() {
|
||||
if (!this.updateQueued) {
|
||||
requestAnimationFrame(this.renderView.bind(this));
|
||||
this.updateQueued = true;
|
||||
}
|
||||
}
|
||||
|
||||
addFormattedRange(formatRange) {
|
||||
// Replace any previous formatted range by overwriting - there
|
||||
// should only ever be one (specific to the current composition).
|
||||
this.formattedRange = formatRange;
|
||||
this.queueUpdate();
|
||||
}
|
||||
|
||||
renderView() {
|
||||
this.editRegionElement.innerHTML = this.convertTextToHTML(
|
||||
this.editModel.text, this.editModel.selection);
|
||||
|
||||
notifyLayoutChanged();
|
||||
|
||||
this.updateQueued = false;
|
||||
}
|
||||
|
||||
notifyLayoutChanged() {
|
||||
this.editContext.updateLayout(this.computeBoundingBox(), this.computeSelectionBoundingBox());
|
||||
}
|
||||
|
||||
convertTextToHTML(text, selection) {
|
||||
// compute the view (code omitted for brevity):
|
||||
// - if there is no selection, return a string with the text contents
|
||||
// - surround the selection by a <span> that has the
|
||||
// appropriate background/foreground colors.
|
||||
// - surround the characters represented by this.formatRange
|
||||
// with a <span> whose style has properties as specified by
|
||||
// the properties on 'this.formattedRange': color
|
||||
// backgroundColor, textDecorationColor, textUnderlineStyle
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Application
|
||||
This [example application](edit_context_demo.html) shows how an author might build a simple editor that leverages the EditContext in a more holistic way.
|
||||
|
||||
## Interaction with Other Browser Editing Features
|
||||
By decoupling the view from text input, the EditContext gives up other DOM features that relate to editing. An inventory of features related to editing in the web platform and their interaction with the EditContext follows:
|
||||
|
||||
* Spellcheck
|
||||
* Undo
|
||||
* Focus
|
||||
* Built-in Editing Commands that Manipulate the DOM in Response to User Input
|
||||
* Default Key Event Behavior Adaptations for Editing
|
||||
* Touch-specific Editing Behaviors
|
||||
* Native Selection and Caret
|
||||
* Highlighting
|
||||
|
||||
### Spellchecking
|
||||
Web apps have no way today to integrate with spellcheck from the browser except through editable elements. Using the EditContext will make the native spellchecking capabilities of the browser unreachable. There is demand for an independent spellchecking API.
|
||||
|
||||
For web apps or editing frameworks relying on editable elements to provide this behavior, it may be a barrier to adoption of the EditContext. Note, however, there are heavily used web editing experiences (Office Online apps, Google docs) that have replaced spell checking with a custom solution who will not be blocked from adopting a better text input integration story, even in the absence of a separate spellcheck API. Similarly, there are also editing experiences, e.g. Monaco, that don't use spell checking from the browser because an element like a contenteditable won't understand what's a string and what's a class name leading to a lot of extra innappropriate squiggles in the code editing experience.
|
||||
|
||||
### Undo
|
||||
Web-based editors rarely want the DOM undo stack. Undo reverses the affect of DOM operations in an editable element that were initiated in response to user input. Since many editors use the editable element to capture text input from the user, but use JavaScript operations to update the view in response to that input, undoing only the DOM changes from user input rarely makes sense.
|
||||
|
||||
It is expected that web-based editors using the EditContext will provide their own undo operations. Some performance benefit should be realized as DOM operations will no longer incur the overhead of maintaining a valid undo stack as DOM mutations mix with user-initiated (undoable) actions.
|
||||
|
||||
### Focus
|
||||
The notion of focus in the DOM, which determines the target for KeyboardEvents, is unaffected by the EditContext. DOM elements can remain focused while the EditContext serves as the recipient of composition and textupdate events.
|
||||
|
||||
### Built-in Editing Commands that Manipulate the DOM in Response to User Input
|
||||
Web-based editors which use the EditContext are expected to provide their own editing command implementations. For example, typing Enter on the keyboard will not automatically insert a newline into the HTML view. An editor must handle the KeyboardEvent and perform updates to their own document model and render those changes into the HTML DOM for users to see the impact of the Enter key press.
|
||||
|
||||
As an alternative, basic editing command implementations could be implemented and expressed as textupdate events to the EditContext's cached text view. Such a feature may make it easier for web-based editors to adopt since the EditContext will behave more like the hidden text area without the side effects.
|
||||
|
||||
However, if the EditContext did provide more editing behavior, it may not be used by editors since a key press like Enter or Backspace is often associated with editing heuristics such as ending or outdenting a list, turning a heading into a normal paragraph style, inserting a new table row, removing a hyperlink without removing any characters from the URL, etc.
|
||||
|
||||
The current thinking is that a more minimal approach is a better place to start.
|
||||
|
||||
### Default Key Event Behavior Adaptations for Editing
|
||||
Some KeyboardEvents are associated with different default behaviors when an editable element is focused than when a read-only element is focused. As an example, the spacebar inserts a space in editable elements, but scrolls when a read-only element is focused.
|
||||
|
||||
When an EditContext is active, the web platform will treat the set of KeyboardEvents with special editing behaviors as though the default behavior has been prevented, i.e. there will be no need for the author to call preventDefault to prevent scrolling when a Space key is pressed.
|
||||
|
||||
### Touch-specific Editing Behaviors
|
||||
Some browsers may support double-tap to zoom. When double tap occurs on editable text, however, it is commonly used to select the word under the double tap. Editors using read-only elements in conjunction with an EditContext can employ the touch-action CSS property to eliminate unwanted touch behavior.
|
||||
|
||||
### Native Selection and Caret
|
||||
Web-based editors using the EditContext that also want to use native selection and the caret don't currently have a great solution. There are two problems in particular that must be overcome:
|
||||
|
||||
1. A native caret currently can only be rendered in an editable region, so using an EditContext in combination with a read-only element in the DOM doesn't support a native caret.
|
||||
2. Native selection is constrained to stay within the bounds of an editable element. This is likely expected behavior, but no such restriction is placed on read-only elements which could lead to over selection without an editable element that establishes a selection limit.
|
||||
|
||||
#### Option 1
|
||||
New DOM content attributes could be proposed to constrain selection to a subtree of the DOM and allow display of the native caret.
|
||||
|
||||
#### Option 2
|
||||
Editors implement their own selection and caret using DOM elements or the proposed [Highlight API](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/master/highlight/explainer.md).
|
||||
|
||||
Option 2 is the default and may be the best starting point. It is currently employed by multiple editors as those editors offer specialized behavior related to selection: e.g. multiple insertion point support or rectangular selection or table selection.
|
||||
|
||||
#### Option 3
|
||||
An editor could combine a contenteditable element with an EditContext. This has the advantage of overcoming both selection related challenges: constraining selection and displaying the native caret. It, however, has the disadvantage that editing behaviors not disabled by having an EditContext, for example clipboard paste and drag and drop, may result in DOM mutations which could break editors.
|
||||
|
||||
### Highlighting
|
||||
Editable elements can apply paint-time effects to mark an active composition and spellchecking results. These features won't happen automatically for web-based editors using the EditContext. Instead, additional elements can be added to the DOM to render these effects, or, when available, the proposed [Highlight API](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/master/highlight/explainer.md) can be used.
|
||||
|
||||
## Alternatives:
|
||||
Multiple approaches have been discussed during F2F editing meetings and through online discussions.
|
||||
* New `ContentEditable` attributes. The group has [considered](https://w3c.github.io/editing/contentEditable.html) adding new attribute values to contenteditable (events, caret, typing) that in would allow web authors to prevent certain input types or to modify some input before it has made it into the markup. This approach hasn’t gotten much traction since browsers would still be building these behaviors on top of content editable thus, inheriting existing limitations.
|
||||
|
||||
* `beforeInput` event. It eventually diverged into two different specs, [Level 1](https://www.w3.org/TR/input-events-1/) (Blink implementation) and [Level 2](https://www.w3.org/TR/input-events-2/) (Webkit implementation). The idea behind this event was to allow developer to preventDefault user input (except for IME cases) and provide information about the type of the input. Due to Android IME constraints, Blink made most of the `beforeInput` event types non-cancelable except for a few formatting input types. This divergence would only get worse over time and since it only solves a small subset of problems for the web, it can’t be considered as a long-term solution.
|
||||
* New `contenteditable` attributes: The group has [considered](https://w3c.github.io/editing/contentEditable.html) adding new attribute values to contenteditable (events, caret, typing) that in would allow web authors to prevent certain input types or to modify some input before it has made it into the markup. These proposals continue to couple text input with the view which has limitations discussed above that the EditContext aims to overcome.
|
||||
|
||||
* As an alternative to `beforeInput` Google has proposed a roadmap in [Google Chrome Roadmap Proposal](https://docs.google.com/document/d/10qltJUVg1-Rlnbjc6RH8WnngpJptMEj-tyrvIZBPSfY/edit) where it was proposed to use existing browser primitives solving CE problems with textarea buffer approach, similar to what developers have already been doing. While we agree with it in concept, we don't think there is a clean way to solve this with existing primitives. Hence, we are proposing EditContext API.
|
||||
* `beforeInput` event: [Level 1](https://www.w3.org/TR/input-events-1/) (Blink implementation) and [Level 2](https://www.w3.org/TR/input-events-2/) (Webkit implementation). The idea behind this event was to allow authors greater insight into the user's intent, and to allow editors to handle that intent without needing to intercept all the arcs through which that input could have been initiated, e.g. context menus, keyboard shortcuts, shaking the phone to undo, etc. This approach makes it easier to handle various events but still leaves text input coupled with the view.
|
||||
|
||||
## Additional Material:
|
||||
|
||||
[Dev Design Draft](dev-design.md)
|
||||
|
||||
[Open Issues](open-issues.md)
|
||||
* As an alternative to `beforeInput` Google has proposed a roadmap in [Google Chrome Roadmap Proposal](https://docs.google.com/document/d/10qltJUVg1-Rlnbjc6RH8WnngpJptMEj-tyrvIZBPSfY/edit) that suggests some potential subprojects to improve editing and textinput in the browser. One concept in particular was described as a something like a hidden textarea that is decoupled from the view. This proposal aligns well with that thinking.
|
||||
|
|
Двоичные данные
EditContext/external_input.png
Двоичные данные
EditContext/external_input.png
Двоичный файл не отображается.
До Ширина: | Высота: | Размер: 97 KiB После Ширина: | Высота: | Размер: 96 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 265 KiB |
Загрузка…
Ссылка в новой задаче