From e8f3beae36cbe5c3dd1f02984be9e732aa7c832e Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sat, 28 Mar 2026 01:24:48 +0900 Subject: [PATCH 1/2] fix: prevent unwanted block split during IME composition on WKWebView On WebKit, committing IME text without conversion on an empty list item triggers a DOM-level block split that ProseMirror's observer converts into unwanted structural transactions. A layered defence blocks those transactions, cleans up orphaned DOM nodes, and forces ProseMirror to re-render from its authoritative state. Co-Authored-By: Claude Opus 4.6 --- src/features/editor/index.ts | 1 + .../editor/lib/imeCompositionGuard.ts | 197 ++++++++++++++++++ src/features/editor/ui/Editor.tsx | 2 + 3 files changed, 200 insertions(+) create mode 100644 src/features/editor/lib/imeCompositionGuard.ts diff --git a/src/features/editor/index.ts b/src/features/editor/index.ts index 8cf471d..7fbbf78 100644 --- a/src/features/editor/index.ts +++ b/src/features/editor/index.ts @@ -25,6 +25,7 @@ export { useSearchReplace } from './hooks/useSearchReplace' export { DEFAULT_BLOCKS, DEFAULT_CONTENT, extractTitle } from './lib/constants' export { cursorCenteringExtension } from './lib/cursorCentering' export { cursorVimKeysExtension } from './lib/cursorVimKeys' +export { imeCompositionGuard } from './lib/imeCompositionGuard' export { DEFAULT_FONT_SIZE, FONT_SIZE_STEP, diff --git a/src/features/editor/lib/imeCompositionGuard.ts b/src/features/editor/lib/imeCompositionGuard.ts new file mode 100644 index 0000000..15a175d --- /dev/null +++ b/src/features/editor/lib/imeCompositionGuard.ts @@ -0,0 +1,197 @@ +import { createExtension } from '@blocknote/core' +import { Plugin, PluginKey } from 'prosemirror-state' + +const PLUGIN_KEY = new PluginKey('imeCompositionGuard') + +/** Whether an IME composition session is currently active. */ +let composing = false + +/** Reference to the ProseMirror editor view, set during plugin initialization. */ +let pmView: any = null + +/** + * Flag indicating that a structural transaction was blocked during + * composition. When true, the immediately following non-structural + * transaction (the duplicate text insertion caused by position mismatch) + * is also blocked. + */ +let blockedStructural = false + +/** + * IDs of all `blockContainer` nodes that existed before composition + * started. Used in the `compositionend` handler to identify and remove + * orphaned DOM block elements created by WebKit's intermediate DOM + * mutations. + */ +let preCompositionBlockIds = new Set() + +/** + * BlockNote extension that prevents unwanted line breaks during IME + * composition on Tauri's WKWebView. + * + * **Root cause:** On WebKit, when the user commits IME text without + * conversion on an empty list item, the browser temporarily removes + * the composition text (`deleteCompositionText`), which empties the + * paragraph. WebKit then splits the list item (exiting an empty + * list item). When the committed text is re-inserted + * (`insertFromComposition`), it lands in the new block. + * + * ProseMirror's DOM observer turns these DOM mutations into + * structural transactions (`ReplaceStep` with `structure: true` and + * `ReplaceAroundStep`), causing an unwanted block split. + * + * **Fix (layered):** + * 1. `filterTransaction` blocks structural steps during composition + * and also blocks the duplicate text insertion that follows + * (caused by position mismatch from the blocked structural step). + * 2. On `compositionend`, orphaned DOM block elements are removed + * and ProseMirror re-renders the DOM from its state. + * 3. `contentEditable = 'plaintext-only'` during composition + * prevents block-level element creation (covers "with conversion"). + * 4. `keydown` capture and `handleKeyDown` prop block Enter during + * the composing window as additional safety. + */ +export const imeCompositionGuard = createExtension({ + key: 'imeCompositionGuard', + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + /** + * Filters ProseMirror transactions during IME composition. + * + * Blocks two categories of transactions: + * 1. Structural steps (`ReplaceStep` with `structure: true` or + * `ReplaceAroundStep`) that WebKit generates when it temporarily + * empties a list item during composition. + * 2. The non-structural transaction immediately following a blocked + * structural one, which is a duplicate text insertion caused by + * the position mismatch introduced by blocking step 1. + */ + filterTransaction(tr) { + if (!composing) { + blockedStructural = false + return true + } + + const hasStructural = tr.steps.some( + (step) => 'gapFrom' in step || (step as any).structure === true, + ) + if (hasStructural) { + blockedStructural = true + return false + } + + if (blockedStructural) { + blockedStructural = false + return false + } + + return true + }, + props: { + /** Suppresses Enter keydown events while an IME composition is active. */ + handleKeyDown(_view, event) { + return event.key === 'Enter' && composing + }, + }, + /** Stores the ProseMirror editor view reference for use in DOM event handlers. */ + view(editorView) { + pmView = editorView + return { update() {}, destroy() { pmView = null } } + }, + }), + ], + mount({ dom, signal }) { + /** + * Prepares the editor for an IME composition session. + * + * Records the set of existing block container IDs so that orphaned + * DOM nodes can be detected on `compositionend`, and switches the + * editor to `plaintext-only` contentEditable mode to prevent + * WebKit from creating block-level elements during composition. + */ + dom.addEventListener('compositionstart', () => { + composing = true + blockedStructural = false + if (!pmView) return + + preCompositionBlockIds.clear() + pmView.state.doc.descendants((node: any) => { + if (node.type.name === 'blockContainer' && node.attrs.id) { + preCompositionBlockIds.add(node.attrs.id) + } + }) + pmView.dom.contentEditable = 'plaintext-only' + }, { signal }) + + /** + * Cleans up after an IME composition session ends. + * + * Removes any DOM block container elements that were created by + * WebKit's intermediate DOM mutations (i.e., elements whose IDs + * were not present before composition started). Then forces + * ProseMirror to re-render the DOM from its authoritative state + * and restores `contentEditable` to `'true'`. + * + * A 500ms delay resets the `composing` flag to allow any late + * browser events from the composition to settle before normal + * transaction processing resumes. + */ + dom.addEventListener('compositionend', () => { + if (!pmView) return + + const domBlocks = Array.from( + pmView.dom.querySelectorAll('[data-node-type="blockContainer"]'), + ) + for (const el of domBlocks) { + const id = el.getAttribute('data-id') + if (!id || !preCompositionBlockIds.has(id)) { + el.remove() + } + } + pmView.updateState(pmView.state) + pmView.dom.contentEditable = 'true' + + setTimeout(() => { + composing = false + blockedStructural = false + }, 500) + }, { signal }) + + /** + * Prevents `insertLineBreak` input events during composition. + * + * This is captured in the bubble phase to intercept WebKit's + * programmatic line break insertion that occurs when the browser + * splits an empty list item during IME text commitment. + */ + dom.addEventListener( + 'beforeinput', + (e: InputEvent) => { + if (e.inputType === 'insertLineBreak' && composing) { + e.preventDefault() + } + }, + { capture: true, signal }, + ) + + /** + * Captures Enter keydown events during composition at the + * capture phase. + * + * Calls both `preventDefault()` and `stopImmediatePropagation()` + * to ensure the event never reaches ProseMirror's keydown + * handler, which would otherwise create a new block. + */ + dom.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if (e.key === 'Enter' && composing) { + e.preventDefault() + e.stopImmediatePropagation() + } + }, + { capture: true, signal }, + ) + }, +}) diff --git a/src/features/editor/ui/Editor.tsx b/src/features/editor/ui/Editor.tsx index 7c5fcf0..e346439 100644 --- a/src/features/editor/ui/Editor.tsx +++ b/src/features/editor/ui/Editor.tsx @@ -29,6 +29,7 @@ import type { SaveStatus } from '..' import { cursorCenteringExtension, cursorVimKeysExtension, + imeCompositionGuard, resolveImageUrl, searchExtension, uploadImage, @@ -263,6 +264,7 @@ export const Editor = forwardRef(function Editor( initialContent: DEFAULT_BLOCKS, pasteHandler, extensions: [ + imeCompositionGuard, cursorCenteringExtension, searchExtension, rangeCheckToggleExtension(), From 3a231db9f79454429096690cfd16369e64b1f87d Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sat, 28 Mar 2026 01:59:46 +0900 Subject: [PATCH 2/2] style: fix biome formatting for IME composition guard Apply biome auto-fixes: sort imports in index.ts and fix formatting (trailing commas, line wrapping) in imeCompositionGuard.ts. Co-Authored-By: Claude Opus 4.6 --- src/features/editor/index.ts | 4 +- .../editor/lib/imeCompositionGuard.ts | 81 +++++++++++-------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/features/editor/index.ts b/src/features/editor/index.ts index 45c86ac..ead3a5f 100644 --- a/src/features/editor/index.ts +++ b/src/features/editor/index.ts @@ -26,10 +26,10 @@ export { useCursorCentering } from './hooks/useCursorCentering' export { useEditorFontSize } from './hooks/useEditorFontSize' export type { UseSearchReplaceReturn } from './hooks/useSearchReplace' export { useSearchReplace } from './hooks/useSearchReplace' +export { checklistSplitFixExtension } from './lib/checklistSplitFix' export { DEFAULT_BLOCKS, DEFAULT_CONTENT, extractTitle } from './lib/constants' export { cursorCenteringExtension } from './lib/cursorCentering' export { cursorVimKeysExtension } from './lib/cursorVimKeys' -export { imeCompositionGuard } from './lib/imeCompositionGuard' export { DEFAULT_FONT_SIZE, FONT_SIZE_STEP, @@ -42,11 +42,11 @@ export { IMAGE_DIR, MAX_IMAGE_SIZE_BYTES, } from './lib/imageUploadConfig' +export { imeCompositionGuard } from './lib/imeCompositionGuard' export { exportToMarkdown, fixBlockNoteTableExport, } from './lib/markdown-export' -export { checklistSplitFixExtension } from './lib/checklistSplitFix' export { rangeCheckToggleExtension } from './lib/rangeCheckToggle' export { searchExtension } from './lib/searchExtension' export { slashMenuEmacsKeysExtension } from './lib/slashMenuEmacsKeys' diff --git a/src/features/editor/lib/imeCompositionGuard.ts b/src/features/editor/lib/imeCompositionGuard.ts index 15a175d..363bd4b 100644 --- a/src/features/editor/lib/imeCompositionGuard.ts +++ b/src/features/editor/lib/imeCompositionGuard.ts @@ -23,7 +23,7 @@ let blockedStructural = false * orphaned DOM block elements created by WebKit's intermediate DOM * mutations. */ -let preCompositionBlockIds = new Set() +const preCompositionBlockIds = new Set() /** * BlockNote extension that prevents unwanted line breaks during IME @@ -74,7 +74,7 @@ export const imeCompositionGuard = createExtension({ } const hasStructural = tr.steps.some( - (step) => 'gapFrom' in step || (step as any).structure === true, + (step) => 'gapFrom' in step || (step as any).structure === true ) if (hasStructural) { blockedStructural = true @@ -97,7 +97,12 @@ export const imeCompositionGuard = createExtension({ /** Stores the ProseMirror editor view reference for use in DOM event handlers. */ view(editorView) { pmView = editorView - return { update() {}, destroy() { pmView = null } } + return { + update() {}, + destroy() { + pmView = null + }, + } }, }), ], @@ -110,19 +115,23 @@ export const imeCompositionGuard = createExtension({ * editor to `plaintext-only` contentEditable mode to prevent * WebKit from creating block-level elements during composition. */ - dom.addEventListener('compositionstart', () => { - composing = true - blockedStructural = false - if (!pmView) return + dom.addEventListener( + 'compositionstart', + () => { + composing = true + blockedStructural = false + if (!pmView) return - preCompositionBlockIds.clear() - pmView.state.doc.descendants((node: any) => { - if (node.type.name === 'blockContainer' && node.attrs.id) { - preCompositionBlockIds.add(node.attrs.id) - } - }) - pmView.dom.contentEditable = 'plaintext-only' - }, { signal }) + preCompositionBlockIds.clear() + pmView.state.doc.descendants((node: any) => { + if (node.type.name === 'blockContainer' && node.attrs.id) { + preCompositionBlockIds.add(node.attrs.id) + } + }) + pmView.dom.contentEditable = 'plaintext-only' + }, + { signal } + ) /** * Cleans up after an IME composition session ends. @@ -137,26 +146,30 @@ export const imeCompositionGuard = createExtension({ * browser events from the composition to settle before normal * transaction processing resumes. */ - dom.addEventListener('compositionend', () => { - if (!pmView) return + dom.addEventListener( + 'compositionend', + () => { + if (!pmView) return - const domBlocks = Array.from( - pmView.dom.querySelectorAll('[data-node-type="blockContainer"]'), - ) - for (const el of domBlocks) { - const id = el.getAttribute('data-id') - if (!id || !preCompositionBlockIds.has(id)) { - el.remove() + const domBlocks = Array.from( + pmView.dom.querySelectorAll('[data-node-type="blockContainer"]') + ) + for (const el of domBlocks) { + const id = el.getAttribute('data-id') + if (!id || !preCompositionBlockIds.has(id)) { + el.remove() + } } - } - pmView.updateState(pmView.state) - pmView.dom.contentEditable = 'true' + pmView.updateState(pmView.state) + pmView.dom.contentEditable = 'true' - setTimeout(() => { - composing = false - blockedStructural = false - }, 500) - }, { signal }) + setTimeout(() => { + composing = false + blockedStructural = false + }, 500) + }, + { signal } + ) /** * Prevents `insertLineBreak` input events during composition. @@ -172,7 +185,7 @@ export const imeCompositionGuard = createExtension({ e.preventDefault() } }, - { capture: true, signal }, + { capture: true, signal } ) /** @@ -191,7 +204,7 @@ export const imeCompositionGuard = createExtension({ e.stopImmediatePropagation() } }, - { capture: true, signal }, + { capture: true, signal } ) }, })