Skip to content

fix: prevent cursor jump during IME composition in colored text#7626

Open
semimikoh wants to merge 7 commits intoueberdosis:mainfrom
semimikoh:fix/ime-cursor-jump-text-style
Open

fix: prevent cursor jump during IME composition in colored text#7626
semimikoh wants to merge 7 commits intoueberdosis:mainfrom
semimikoh:fix/ime-cursor-jump-text-style

Conversation

@semimikoh
Copy link
Copy Markdown

@semimikoh semimikoh commented Mar 19, 2026

When typing with IME (Korean/Chinese/Japanese) before the last character
of colored text, the cursor would jump to the end of the paragraph.

Root cause: ProseMirror's DOMSerializer sets styles via style.cssText,
which causes Chrome to normalize hex colors (#FF0000) to rgb format
(rgb(255, 0, 0)). During IME composition, this mismatch between the
stored mark attributes and re-parsed DOM values causes findDiff to
detect the entire mark region as changed, triggering a full replacement
that misplaces the cursor.

Fix: Add a custom markView to TextStyle that uses setAttribute instead
of cssText, preserving the original color format. Also filter empty
strings in parseHTML and injectExtensionAttributesToParseRule to prevent
additional attribute mismatches.

Closes #7621

Changes Overview

Fix cursor jumping to end of paragraph when typing with IME (Korean/Chinese/Japanese) before the last character of text styled with
color/textStyle marks.

  • Added addMarkView to TextStyle extension that uses setAttribute instead of style.cssText
  • Added empty string filtering in parseHTML for color, backgroundColor, fontFamily, fontSize, lineHeight
  • Added empty string check in injectExtensionAttributesToParseRule

Implementation Approach

Root cause: ProseMirror's DOMSerializer sets styles via dom.style.cssText, which causes Chrome to normalize hex colors (#FF0000) to
rgb (rgb(255, 0, 0)). During IME composition, MarkViewDesc becomes CONTENT_DIRTY and falls back to re-parsing the DOM — reading the
normalized rgb value instead of the original hex. This mismatch makes findDiff detect the entire mark region as changed, triggering a
full-range replacement where Chrome's composition guard blocks correct cursor positioning.

Fix: A custom addMarkView creates the span using span.setAttribute('style', ...) which preserves the original color format, preventing
the hex→rgb normalization.

Alternatives considered:

  • Normalize colors to rgb at storage time — loses user's original format, wider change scope
  • Patch ProseMirror's DOMSerializer upstream — maintainer is conservative about changes
  • Set inclusive: false on textStyle mark — causes side effects (new text won't inherit style at mark boundary)

Testing Done

  • Manually tested IME input (Korean) before last character of colored text — cursor stays in place
  • Manually tested IME input in middle of colored text — works normally
  • Manually tested non-IME input with color/font-family/font-size — works as before
  • All 684 unit tests pass

Verification Steps

  1. Open demo with TextStyle/Color extensions
  2. Select text and apply a color
  3. Place cursor before the last character of the colored text
  4. Type with IME (Korean/Chinese/Japanese input method)
  5. Before fix: cursor jumps to end of paragraph after composition
  6. After fix: cursor stays at the correct position

Additional Notes

Debug logging confirmed the issue — during IME composition, findDiff returns {start: 410, endA: 429, endB: 429} (endA==endB means
mark-only change), replacing the entire colored range instead of just the inserted text. This only happens in Chrome due to cssText
normalization.

Open to feedback if there's a better approach!

Checklist

  • I have created a changeset for this PR if necessary.
  • My changes do not break the library.
  • I have added tests where applicable.
  • I have followed the project guidelines.
  • I have fixed any lint issues.

Related Issues

Closes #7621

  When typing with IME (Korean/Chinese/Japanese) before the last character
  of colored text, the cursor would jump to the end of the paragraph.

  Root cause: ProseMirror's DOMSerializer sets styles via style.cssText,
  which causes Chrome to normalize hex colors (#FF0000) to rgb format
  (rgb(255, 0, 0)). During IME composition, this mismatch between the
  stored mark attributes and re-parsed DOM values causes findDiff to
  detect the entire mark region as changed, triggering a full replacement
  that misplaces the cursor.

  Fix: Add a custom markView to TextStyle that uses setAttribute instead
  of cssText, preserving the original color format. Also filter empty
  strings in parseHTML and injectExtensionAttributesToParseRule to prevent
  additional attribute mismatches.

  Closes ueberdosis#7621
@netlify
Copy link
Copy Markdown

netlify bot commented Mar 19, 2026

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit 3a2f34d
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/69ce348f69d89500087ad124
😎 Deploy Preview https://deploy-preview-7626--tiptap-embed.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: 3a2f34d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 72 packages
Name Type
@tiptap/extension-text-style Patch
@tiptap/extension-color Patch
@tiptap/extension-details Patch
@tiptap/extension-file-handler Patch
@tiptap/extension-font-family Patch
@tiptap/extension-invisible-characters Patch
@tiptap/core Patch
@tiptap/extension-audio Patch
@tiptap/extension-blockquote Patch
@tiptap/extension-bold Patch
@tiptap/extension-bubble-menu Patch
@tiptap/extension-bullet-list Patch
@tiptap/extension-code-block-lowlight Patch
@tiptap/extension-code-block Patch
@tiptap/extension-code Patch
@tiptap/extension-collaboration-caret Patch
@tiptap/extension-collaboration Patch
@tiptap/extension-document Patch
@tiptap/extension-drag-handle-react Patch
@tiptap/extension-drag-handle-vue-2 Patch
@tiptap/extension-drag-handle-vue-3 Patch
@tiptap/extension-drag-handle Patch
@tiptap/extension-emoji Patch
@tiptap/extension-floating-menu Patch
@tiptap/extension-hard-break Patch
@tiptap/extension-heading Patch
@tiptap/extension-highlight Patch
@tiptap/extension-horizontal-rule Patch
@tiptap/extension-image Patch
@tiptap/extension-italic Patch
@tiptap/extension-link Patch
@tiptap/extension-list Patch
@tiptap/extension-mathematics Patch
@tiptap/extension-mention Patch
@tiptap/extension-node-range Patch
@tiptap/extension-ordered-list Patch
@tiptap/extension-paragraph Patch
@tiptap/extension-strike Patch
@tiptap/extension-subscript Patch
@tiptap/extension-superscript Patch
@tiptap/extension-table-of-contents Patch
@tiptap/extension-table Patch
@tiptap/extension-text-align Patch
@tiptap/extension-text Patch
@tiptap/extension-twitch Patch
@tiptap/extension-typography Patch
@tiptap/extension-underline Patch
@tiptap/extension-unique-id Patch
@tiptap/extension-youtube Patch
@tiptap/extensions Patch
@tiptap/html Patch
@tiptap/markdown Patch
@tiptap/pm Patch
@tiptap/react Patch
@tiptap/starter-kit Patch
@tiptap/static-renderer Patch
@tiptap/suggestion Patch
@tiptap/vue-2 Patch
@tiptap/vue-3 Patch
@tiptap/extension-character-count Patch
@tiptap/extension-dropcursor Patch
@tiptap/extension-focus Patch
@tiptap/extension-gapcursor Patch
@tiptap/extension-history Patch
@tiptap/extension-list-item Patch
@tiptap/extension-list-keymap Patch
@tiptap/extension-placeholder Patch
@tiptap/extension-table-cell Patch
@tiptap/extension-table-header Patch
@tiptap/extension-table-row Patch
@tiptap/extension-task-item Patch
@tiptap/extension-task-list Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Member

@bdbch bdbch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into the IME cursor jump. The diagnosis is useful, but I don't think we should merge this approach as-is. The new textStyle mark view changes the rendering path for every textStyle mark, and the implementation relies on setAttribute('style', ...), which reintroduces the CSP/security compatibility issue ProseMirror explicitly fixed upstream. I left line comments with the main concerns.

return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},

addMarkView() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safer direction here seems to be stabilizing color values instead of introducing a MarkView. In particular, we should canonicalize color values at the Color / BackgroundColor extension boundary so the stored mark attrs and the reparsed DOM use the same representation during IME composition. That keeps the normal rendering path intact, avoids reintroducing the CSP issue from setAttribute('style', ...), and narrows the fix to the actual source of the mismatch rather than changing how every textStyle mark is rendered.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bdbch

Thanks for the review! I've replaced the MarkView approach with color normalization at the Color/BackgroundColor extension boundary. Now
both parseHTML and setColor/setBackgroundColor run through a shared normalizeColor() utility that canonicalizes values to rgb() via the
browser's CSS parser, so stored attrs and reparsed DOM always match during IME composition.

Copy link
Copy Markdown
Member

@bdbch bdbch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reworking this away from the MarkView approach. Normalizing at the Color / BackgroundColor extension boundary is much closer to the right shape and addresses the CSP concern from the earlier version.

I still don’t think we can merge it as-is, though, because the fix currently only stabilizes color values when they enter through parseHTML() or setColor() / setBackgroundColor(). Tiptap content can also come from JSON / Yjs / persisted document state, and those paths can still contain existing hex values like #ff0000 in mark attrs. In that case we still render the mark from the stored hex value, Chrome reparses the DOM as rgb(...) during IME composition, and we end up with the same attr mismatch again.

I think the fix needs to make color attrs stable regardless of how the document was created. A better direction would be to canonicalize the textStyle color attrs before they are compared/rendered in the editor state as well, so JSON-loaded content and command-created content converge on the same representation. For example, that could mean normalizing these attrs when content is imported into the editor / parsed into the ProseMirror doc, or otherwise ensuring the mark attrs are canonical even when they originate from JSON rather than HTML.

Also, the current changeset still includes @tiptap/core, but this version of the PR doesn’t change anything in core anymore, so that release entry should be removed unless core changes come back.

Please also add a regression test that starts from JSON content with a hex color attr, since that’s the path the current implementation still misses.

… tests

Address reviewer feedback:
- Normalize color/backgroundColor attrs on editor creation (onCreate)
  and on every document change (onTransaction) to handle JSON, Yjs,
  and persisted content that may contain non-canonical hex values
- Normalize in renderHTML so DOM output is always canonical
- Remove @tiptap/core from changeset (no core changes in this PR)
- Add regression tests for JSON-loaded hex color normalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@semimikoh
Copy link
Copy Markdown
Author

@bdbch Thanks for the feedback! I've addressed all three points:

  1. JSON / Yjs / persisted content normalization — Added onCreate and onTransaction hooks to both Color and BackgroundColor extensions. onCreate normalizes
    hex values in the initial document (e.g. JSON-loaded content), and onTransaction catches any subsequent external updates (e.g. Yjs). Also added
    normalizeColor() in renderHTML as a safety net so the DOM output is always canonical regardless of stored attr format.
  2. Removed @tiptap/core from changeset — Only @tiptap/extension-text-style remains since core is no longer touched.
  3. Added regression tests — Integration tests that create an editor with JSON content containing hex color attrs and verify they get normalized to rgb().
    Also covers the external update (Yjs-like) path.

*
* Falls back to the original value when `document` is unavailable (SSR).
*/
export function normalizeColor(color: string): string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every call to the normalizeColor method creates a <span> tag. If we have to use this approach, would it be better to implement it as a singleton tied to the editor's lifecycle?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored to reuse a singleton element and added a Map cache so repeated lookups skip DOM access entirely.

let hasChanges = false
const tr = state.tr

state.doc.descendants((node, pos) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onTransaction fires very frequently, and the state.doc.descendants method is quite expensive. For the current implementation, this solution is too heavy-handed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the onTransaction hook. Colors are normalized at parseHTML, renderHTML, and command boundaries. Initial content is
handled once in onCreate.

@snovey
Copy link
Copy Markdown

snovey commented Apr 1, 2026

Following the current approach, I believe the minimal complete solution consists of two parts:

  1. Change all fallback return values of parseHTML under the extension-text-style directory to null (i.e., exclude the addMarkView related code in the initial commit).
  2. Maintain a singleton to retrieve the browser-computed color values (i.e., refactor the normalizeColor method into the singleton pattern).

However, I consider the second part of the solution rather tricky and dependent on the browser environment.

I haven’t come up with a better alternative either, which is why I only filed a bug report instead of submitting a PR.

Copy link
Copy Markdown
Member

@bdbch bdbch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for the updates! The approach of normalizing via parseHTML/renderHTML and handling the initial load in onCreate is definitely a step in the right direction.

However, we still can't merge this as-is because handling programmatic or collaborative (e.g. Yjs) updates is missing, and scanning the entire document on every transaction would cause severe performance issues on larger documents.

Instead, could you handle this by creating a ProseMirror plugin with an appendTransaction hook (using addProseMirrorPlugins)?

This is the standard ProseMirror way to enforce document constraints. With appendTransaction, you can:

  1. Check if the document actually changed (transaction.docChanged).
  2. Use the transaction's step map (transaction.mapping) to iterate only over the specific ranges of the document that were modified.
  3. If you find un-normalized hex colors in those specific ranges, you can return a new transaction that fixes them.

This ensures we don't tank performance by checking the whole document on every keystroke, and it will safely intercept inbound Yjs syncs.

Let me know if you need any pointers on how to utilize appendTransaction!

…rmalization

Use a ProseMirror plugin with appendTransaction to normalize color attrs
only in changed ranges (via findDiffStart/findDiffEnd), and handle initial
content via the plugin's view() hook. This safely intercepts Yjs syncs
and programmatic updates without scanning the entire document.
@semimikoh
Copy link
Copy Markdown
Author

semimikoh commented Apr 2, 2026

@bdbch ---
Thanks for the great suggestion! I've refactored the approach to use a ProseMirror plugin with appendTransaction via
addProseMirrorPlugins.

What changed:

  • Replaced the onCreate full-document scan with a plugin that has two hooks:
    • view() — normalizes initial JSON/HTML content on editor mount
    • appendTransaction — uses findDiffStart/findDiffEnd to compute the changed range and only walks those nodes
  • Removed the now-unused normalizeDocColorAttrs utility
  • Added a test case for programmatic/collaborative updates being normalized via appendTransaction

This ensures Yjs syncs and programmatic mark updates are intercepted without scanning the entire document on every
transaction.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The cursor will jump when typing text before the end of colored text using an input method

3 participants