Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Bad performence when composition with ime #172

Closed

Description

@fantasticit

When using IME for input in large amounts of text, the processes of composionstart, composionupdate, and composionend can cause frequent redrawing of the DOM, affecting the user experience. Is it possible to intercept the input of the compositionstart and compositionupdate processes, and then submit them uniformly to the prosemirror state in compositionsend? The commercial product Kingsoft Document should have used a similar solution.

Activity

  1. marijnh commented on Jun 27, 2024

    @marijnh
    Member

    We want the editor state to track the actual composition state. Freezing the editor until composition finishes has lots of disadvantages (extensions will appear unresponsive, on Android composition often only finishes when the user moves the cursor away from a composed word, etc).

    What kind of expensive redrawing are you seeing? The view update should generally be a no-op during composition.

  2. fantasticit commented on Jun 27, 2024

    @fantasticit
    Author

    image

    image

    A document of 300000 words, then input four or five words at the end.

  3. marijnh commented on Jun 28, 2024

    @marijnh
    Member

    I cannot really make anything out in those scaled-down screenshots. Does this document perform well if you edit it as a plain contenteditable element? Where do the dev tools say the time is spent?

  4. fantasticit commented on Jun 28, 2024

    @fantasticit
    Author

    When a document contains a large amount of text, the input during the composition event process can cause the browser to layout, paint, and other processes. This process is slow for the browser itself, resulting in a poorer input experience. Therefore, I believe that changing the editor state after the composition event is completed can improve the user experience (at least on the PC side).

    If I want to update the editor after the composition event is completed, could you give me a suggestion on how to modify the prosemirror-view input.ts code?

    For example, I tryied to hung a virtual node on top of the editor node to receive composite events.

    import { Editor, posToDOMRect } from "@tiptap/core";
    
    export class VirtualInput {
      private editor: Editor;
      private isComposing: boolean = false;
      private element!: HTMLElement;
    
      constructor(editor: Editor) {
        this.editor = editor;
      }
    
      mount() {
        const dom = document.createElement("div");
        dom.setAttribute("id", "virtual-input");
        dom.setAttribute("role", "text-box");
        dom.setAttribute("spellcheck", "false");
        dom.setAttribute("tabindex", "0");
        dom.setAttribute("contenteditable", "plaintext-only");
        dom.style.cssText = `
    position: fixed;
    left: 50px;
    top: 50px;
    opacity: 0;
    z-index: -1;
    pointer-events: none;
    outline: none;
    user-select: text;
    width: fit-content;
        `;
        this.element = dom;
        this.editor.options.element.appendChild(this.element);
    
        this.bindEvent();
      }
    
      bindEvent() {
        this.element.addEventListener("compositionstart", this.handleCompositionStart);
        this.element.addEventListener("compositionupdate", this.handleCompositionStart);
        this.element.addEventListener("compositionend", this.handleCompositionEnd);
        this.editor.view.dom.addEventListener("blur", this.handleEditorViewBlur);
        this.editor.view.dom.addEventListener("focus", this.handleEditorViewFocus);
      }
    
      unbindEvent() {
        this.element.removeEventListener("compositionstart", this.handleCompositionStart);
        this.element.removeEventListener("compositionupdate", this.handleCompositionStart);
        this.element.removeEventListener("compositionend", this.handleCompositionEnd);
        this.editor.view.dom.removeEventListener("blur", this.handleEditorViewBlur);
        this.editor.view.dom.removeEventListener("focus", this.handleEditorViewFocus);
      }
    
      handleMouseDown() {}
    
      handleMouseUp() {}
    
      handleVisibleChange() {}
    
      handeKeyUp() {}
    
      handleKeyDown(e: KeyboardEvent) {
        if (this.isComposing) return;
        this.editor.view.dispatchEvent(e);
      }
    
      handleChange() {}
    
      handleFocus() {
        this.updatePosition();
      }
    
      handleBlur() {}
    
      handleCompositionStart = () => {
        this.editor.view.dom?.blur();
        this.focusElement({ preventScroll: true });
        this.isComposing = true;
      };
    
      handleCompositionEnd = () => {
        if (!this.isComposing) return;
    
        this.element.blur();
        this.element.style.zIndex = "-1";
        this.element.style.opacity = "0";
    
        if (document.activeElement === this.element && this.element.textContent) {
          try {
            const range = document.getSelection()?.getRangeAt(0);
            range?.selectNodeContents(this.element);
            range?.collapse(true);
          } catch (e) {
            console.error(e);
          }
        }
    
        this.isComposing = false;
        this.editor.commands.insertContent(this.element.textContent);
        this.element.innerHTML = "";
      };
    
      handleEditorViewFocus = (e: FocusEvent) => {
        (e.target as HTMLElement)?.blur();
        this.focusElement({ preventScroll: true });
      };
    
      handleEditorViewBlur = (e: Event) => {
        e.preventDefault();
        e.stopPropagation();
      };
    
      focusElement = (options: FocusOptions | undefined) => {
        this.element.blur();
        this.element.focus(options);
        try {
          const range = document.getSelection()?.getRangeAt(0);
          range?.selectNodeContents(this.element);
          range?.collapse(false);
        } catch (e) {
          console.error(e);
        }
        this.updatePosition();
      };
    
      updatePosition = () => {
        const rect = posToDOMRect(
          this.editor.view,
          this.editor.state.selection.from,
          this.editor.state.selection.to,
        );
        this.element.style.zIndex = "1";
        this.element.style.opacity = "1";
        this.element.style.top = rect.top + "px";
        this.element.style.left = rect.left + "px";
      };
    
      destroy() {
        this.unbindEvent();
        this.element.remove();
      }
    }
  5. marijnh commented on Jun 28, 2024

    @marijnh
    Member

    When a document contains a large amount of text, the input during the composition event process can cause the browser to layout, paint, and other processes. This process is slow for the browser itself, resulting in a poorer input experience.

    This is something that the browser will do by itself during composition. It has to, since composition is changing the document.

    ProseMirror will not try to delay editor state updates during composition. Trying to interfere with composition events will definitely not work—the browser needs to handle these normally for composition to work at all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions