w3c / editing

Specs and explainers maintained by the editing task force
http://w3c.github.io/editing/
Other
192 stars 40 forks source link

Proposal: compositionborder attribute #414

Open Azmisov opened 1 year ago

Azmisov commented 1 year ago

The idea is to allow a compositionborder=true attribute to elements within a content editable container. This signals to the browser that composition should not cross the element's boundary. When IME composition begins, the initial composition text is bounded by compositionborder. Input event Ranges will necessarily be within those bounds. An input event Range outside the range would necessarily trigger a compositionend event first.

Purpose

Composition events cannot be handled manually (specifically preventDefault is a not allowed in the InputEvents spec). During composition editing, browsers may add/delete nodes, or move textContent to adjacent nodes. This can cause a "style jank" where a style is modified and we must wait until composition has ended to repair the styles. This is only a problem with styled text, as with plain text we can repair the DOM afterwards without any visible jank. This also only affects compositions, since regular events we can cancel and manually edit the DOM.

Here are some examples of unpredictable browser behavior around compositions. Each of these result in style jank, but could be solved by adding compositionborder=true to one or more elements.

(Vertical bar indicates caret position)

1)

the suffix -esque, as in pictur<u>esqu|</u>

Autocomplete "picturesque" in Chrome, or typing "e" in Firefox Android will result in:

the suffix -esque, as in <u>picturesque</u>

ruining our intended styling.

2)

In a code editor, the same might occur with:

<var>let</var> x = <var>shor</var>-<var>ter|</var>

The browser does not know that we're dealing with programming code, and so thinks shor-ter is a single word/phrase that should be composed together with the IME.

This particular example can be observed in CodeMirror when editing on Android; when typing this-some_var, syntax highlighting is not properly applied until after composition finishes.

3)

<b>some text </b><u>|</u>

Typing "here" will result in

<b>some text here|</b><u></u>

The browser treats the contents as one style-agnostic text phrase, ignoring that the cursor is inside <u> and retargeting it to <b> instead. While normal input events we can cancel and retarget it back to <u>, with composition events we can't edit the DOM until after composition has completed; and by that point, it is too late, since the text has already been styled bold.

4)

<template id="custom-element">
   <img src="avatar.png>
   <b>~ <slot></slot> ~</b>
</template>
<custom-element>John Doe</custom-element>is| a programmer from Orlando

(Assuming custom-element has a shadow root given by the template). Here, browsers interpret "Doeis" as the phrase for composition, even though the actual text being displayed is "~ John Doe ~ is...". A similar example can be crafted for before/after CSS pseudo elements.

Usage

Add compositionborder=true to an element to restrict IME composition. For example, given a caret given by vertical bar, the brackets indicate the composition bounds:

<div compositionborder=true>[
   <span>foo| bar<span>
   <div>]<span compositionborder=true>baz</span></div>
</div>

In this example, the browser would insert text inside <u> instead of crossing to another element:

<b>foo</b><u compositionborder=true>[|]</u>

Using the syntax of insertAdjacentElement, the composition boundary is the first position preceding/following the anchor whose element compositionborder is true or isContentEditable is false.

Using the node-boundary package, code for the range would look like:

import {Boundary, BoundaryRange} from "node-boundary";

/** Get the range for composition given an anchor point
 * @param {Boundary} anchor - the DOM position whose composition range we want to find
 * @param {Node} [root] a root element that is an additional bounds constraint (e.g. the root
 *  contenteditable); anchor should be inside root
 * @returns {BoundaryRange} a range giving the composition boundary start/end; can be collapsed,
 *  in which case composition would insert a new Text node at that point
 */
function composition_bounds(anchor, root){
    // to detect whether isContentEditable changes, we need the initial state at this anchor
    const is_boundary = n => {
        return n === root || (!(n instanceof CharacterData) && (
            n.compositionBorder === "true" ||
            !n.isContentEditable !== anchor_editable));
    };
    const range = new BoundaryRange();
    // traverse left/right to get bounds
    const lanchor = anchor.clone();
    for (const _ of lanchor.previousNodes()){
        if (is_boundary(lanchor.node))
            break;
    }
    range.start = lanchor;
    const ranchor = anchor.clone();
    for (const _ of ranchor.nextNodes()){
        if (is_boundary(ranchor.node))
            break;
    }
    range.end = ranchor;
    return range;
}

Implementation

The idea here is a simple change to encapsulate composition events so they are more controllable. While browser devs have made clear full composition handling cannot be supported easily, this should be a simple addition which I believe solves the majority of complaints surrounding composition events. Browsers already seem to implement some logic surrounding this, although it is not exposed; for example, Firefox appears to exhibit compositionborder=true behavior for <article>.

snianu commented 1 year ago

The problem that you are describing here can also be fixed by using the EditContext API, so not sure if we need to invent another approach to handle composition events.

Azmisov commented 1 year ago

Yeah, I was aware of that API. It has been 3+ years and EditContext hasn't been implemented in any browser still. So the intent here with compositionborder is to be a simple change that can be quickly added to browsers' existing Input Events implementations. It is sort of a halfway compromise, giving users a way to encapsulate/control IME text input, but without the full-fledged API overhaul and IME integration of EditContext. I also imagine Input Events will not be deprecated once (or if) EditContext is eventually widely supported. So this would still be useful for those who wish to use that API instead.

masayuki-nakano commented 1 year ago

1)

the suffix -esque, as in pictur<u>esqu|</u>

Autocomplete "picturesque" in Chrome, or typing "e" in Firefox Android will result in:

the suffix -esque, as in <u>picturesque</u>

ruining our intended styling.

Oh, really? This is terrible result... I guess that your IME selects the word and replace it. I mean that this is probably depends on IME. If the <u> has compositionborder="true" as your proposal, it's nightmare for web browser developers... (perhaps, hiding outside compositionborder contents from IME is the solution.)

2)

In a code editor, the same might occur with:

<var>let</var> x = <var>shor</var>-<var>ter|</var>

The browser does not know that we're dealing with programming code, and so thinks shor-ter is a single word/phrase that should be composed together with the IME.

It's a job of IME that which characters are treated as word separator. Browsers let IME know surrounding text as plaintext.

This particular example can be observed in CodeMirror when editing on Android; when typing this-some_var, syntax highlighting is not properly applied until after composition finishes.

I guess that CodeMirror just touches the DOM tree after committing composition. That's the safest way. Updating the DOM tree will be notified IME. Then, IME may stop the composition, i.e., the behavior becomes depending on IME. That must be a nightmare for web developers.

3)

<b>some text </b><u>|</u>

Typing "here" will result in

<b>some text here|</b><u></u>

The browser treats the contents as one style-agnostic text phrase, ignoring that the cursor is inside <u> and retargeting it to <b> instead. While normal input events we can cancel and retarget it back to <u>, with composition events we can't edit the DOM until after composition has completed; and by that point, it is too late, since the text has already been styled bold.

I think that this should be fixed in each browser without any spec changes. Could be avoided with inserting empty text node there and collapse Selection into it.

Azmisov commented 1 year ago

it's nightmare for web browser developers... (perhaps, hiding outside compositionborder contents from IME is the solution.)

In Firefox, using <article> as the tag for styling text will emulate compositionborder=true behavior (at least tested in Firefox mobile with Android IME). So I figured there must be some kind of logic already in there that compositionborder could expose. But I'm not a browser developer, so I'll defer to your judgement there.

For example:

<span class='u'>te</span><span class='b'>ex|</span>

Will suggest "text" for autocomplete, but switching to <article> will suggest "exactly" for autocomplete instead. (I only see this behavior in Firefox)

I guess that CodeMirror just touches the DOM tree after committing composition. That's the safest way. Updating the DOM tree will be notified IME. Then, IME may stop the composition, i.e., the behavior becomes depending on IME. That must be a nightmare for web developers.

In my tests, you cannot modify the DOM during composition. Chrome will allow it, but the IME (as tested in Android) does a janky refresh if you do a DOM modification. Firefox will silently proceed, but has some weird behavior where the remaining composition events are all fired incorrectly on orphaned nodes.

I suppose that was one of the stipulations of the Input Events Lvl 2 spec: all events could be prevented (e.g. you can manually handle DOM edits), except for composition events, which you must wait to complete before doing any DOM repairs. (If you could do preventDefault on composition events then that would solve everything, no need for compositionborder or other; though I've been told that Chrome has made it clear they cannot implement preventDefault for composition).

In any case, modifying the DOM during composition without preventDefault would be problematic, since the browser might restyle/add/delete nodes based on whatever internal DOM editing logic it has. You'd be constantly fighting to repair the DOM. Which is sort of the idea behind compositionborder: setting a border for the text editing range to tell the browser "hey, don't modify anything outside this range when you do the composition edit"

I think that this should be fixed in each browser without any spec changes. Could be avoided with inserting empty text node there and collapse Selection into it.

Tested now and using a text node seems to work for Firefox, but Chrome still retargets to the <b> element. I'd support Firefox's behavior being standardized (and that would solve this case without needing compositionborder).

johanneswilm commented 1 year ago

2023-02-09 call:

johanneswilm: JS editors can't do anything about IME composition UI until composition is already done johanneswilm: proposal: specify composition border around element, composition stays around until element is gone (?) johanneswilm: if you try to change the DOM around the composition, it can interrupt the composition johanneswilm: if you can mark an element as composition border then you can move elements adjacent to the composition johanneswilm: is this a problem worth solving? is this proposal the way we should solve it? snianu (Microsoft): preferred approach is to use EditContext