fix: prevent cursor jump during IME composition in colored text#7626
fix: prevent cursor jump during IME composition in colored text#7626semimikoh wants to merge 7 commits intoueberdosis:mainfrom
Conversation
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
✅ Deploy Preview for tiptap-embed ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
🦋 Changeset detectedLatest commit: 3a2f34d The changes in this PR will be included in the next version bump. This PR includes changesets to release 72 packages
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 |
packages/core/src/helpers/injectExtensionAttributesToParseRule.ts
Outdated
Show resolved
Hide resolved
bdbch
left a comment
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…osition cursor jump
bdbch
left a comment
There was a problem hiding this comment.
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>
|
@bdbch Thanks for the feedback! I've addressed all three points:
|
| * | ||
| * Falls back to the original value when `document` is unavailable (SSR). | ||
| */ | ||
| export function normalizeColor(color: string): string { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
onTransaction fires very frequently, and the state.doc.descendants method is quite expensive. For the current implementation, this solution is too heavy-handed.
There was a problem hiding this comment.
Removed the onTransaction hook. Colors are normalized at parseHTML, renderHTML, and command boundaries. Initial content is
handled once in onCreate.
|
Following the current approach, I believe the minimal complete solution consists of two parts:
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. |
…e onTransaction hook
bdbch
left a comment
There was a problem hiding this comment.
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:
- Check if the document actually changed (
transaction.docChanged). - Use the transaction's step map (
transaction.mapping) to iterate only over the specific ranges of the document that were modified. - 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.
|
@bdbch --- What changed:
This ensures Yjs syncs and programmatic mark updates are intercepted without scanning the entire document on every |
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.
addMarkViewto TextStyle extension that usessetAttributeinstead ofstyle.cssTextparseHTMLfor color, backgroundColor, fontFamily, fontSize, lineHeightinjectExtensionAttributesToParseRuleImplementation Approach
Root cause: ProseMirror's
DOMSerializersets styles viadom.style.cssText, which causes Chrome to normalize hex colors (#FF0000) torgb (
rgb(255, 0, 0)). During IME composition,MarkViewDescbecomesCONTENT_DIRTYand falls back to re-parsing the DOM — reading thenormalized rgb value instead of the original hex. This mismatch makes
findDiffdetect the entire mark region as changed, triggering afull-range replacement where Chrome's composition guard blocks correct cursor positioning.
Fix: A custom
addMarkViewcreates the span usingspan.setAttribute('style', ...)which preserves the original color format, preventingthe hex→rgb normalization.
Alternatives considered:
inclusive: falseon textStyle mark — causes side effects (new text won't inherit style at mark boundary)Testing Done
Verification Steps
Additional Notes
Debug logging confirmed the issue — during IME composition,
findDiffreturns{start: 410, endA: 429, endB: 429}(endA==endB meansmark-only change), replacing the entire colored range instead of just the inserted text. This only happens in Chrome due to
cssTextnormalization.
Open to feedback if there's a better approach!
Checklist
Related Issues
Closes #7621