From 38ecc5a3bb998683ead873840ab6a0abd0bed275 Mon Sep 17 00:00:00 2001 From: MarsLuay <70299537+MarsLuay@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:23:23 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B9=20Refactor=20attachDocxImeTransfor?= =?UTF-8?q?mNeutralizer=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract diagnostic options creation, IME event handling, and mutation observer creation into modular helper functions and module constants. --- src/docxImeTransformNeutralizer.ts | 152 +++++++++++------- tests/docx-ime-transform-neutralizer.test.mjs | 60 +++++++ 2 files changed, 154 insertions(+), 58 deletions(-) diff --git a/src/docxImeTransformNeutralizer.ts b/src/docxImeTransformNeutralizer.ts index 612ba6e..c96c93a 100644 --- a/src/docxImeTransformNeutralizer.ts +++ b/src/docxImeTransformNeutralizer.ts @@ -457,19 +457,15 @@ export function syncDocxImeHiddenProseMirrorAnchor( return true; } -export function attachDocxImeTransformNeutralizer( - editorRoot: HTMLElement, - options: DocxImeTransformNeutralizerOptions = {}, -): () => void { - const view = getNeutralizerWindow(editorRoot, options.ownerDocument); - const ownerDocument = options.ownerDocument ?? editorRoot.ownerDocument; - let frameId: number | null = null; - let pollIntervalId: number | null = null; - let suppressObserver = false; - const retryTimeouts: number[] = []; - let lastAnchorState = ''; +const IMMEDIATE_IME_EVENTS = ['keydown', 'beforeinput', 'compositionstart', 'compositionupdate', 'compositionend', 'input'] as const; +const SCHEDULED_IME_EVENTS = ['keyup', 'mouseup', 'focusin', 'focusout', 'selectionchange'] as const; +const RETRY_DELAYS_MS = [0, 100, 500, 1500] as const; - const diagnosticOptions: DocxImeTransformNeutralizerOptions = { +function createDeduplicatedDiagnosticOptions( + options: DocxImeTransformNeutralizerOptions, +): DocxImeTransformNeutralizerOptions { + let lastAnchorState = ''; + return { ...options, onDiagnostic: (event) => { if (event.event === 'anchor-state') { @@ -484,6 +480,88 @@ export function attachDocxImeTransformNeutralizer( options.onDiagnostic?.(event); }, }; +} + +function attachImeEventListeners( + ownerDocument: Document, + runImmediately: (event: Event) => void, + schedule: () => void, +): void { + const eventOptions = { capture: true }; + for (const eventName of IMMEDIATE_IME_EVENTS) { + ownerDocument.addEventListener(eventName, runImmediately, eventOptions); + } + for (const eventName of SCHEDULED_IME_EVENTS) { + ownerDocument.addEventListener(eventName, schedule, eventOptions); + } +} + +function detachImeEventListeners( + ownerDocument: Document, + runImmediately: (event: Event) => void, + schedule: () => void, +): void { + const eventOptions = { capture: true }; + for (const eventName of IMMEDIATE_IME_EVENTS) { + ownerDocument.removeEventListener(eventName, runImmediately, eventOptions); + } + for (const eventName of SCHEDULED_IME_EVENTS) { + ownerDocument.removeEventListener(eventName, schedule, eventOptions); + } +} + +function createImeMutationObserver( + editorRoot: HTMLElement, + isSuppressed: () => boolean, + schedule: () => void, +): MutationObserver { + const observer = new MutationObserver((mutations) => { + if (isSuppressed()) { + return; + } + + for (const mutation of mutations) { + if (mutation.type === 'childList') { + schedule(); + return; + } + + if ( + mutation.type === 'attributes' + && mutation.attributeName === 'style' + && isHTMLElement(mutation.target) + ) { + const wrapper = findDocxEditorZoomWrapper(editorRoot); + if (wrapper && (mutation.target === wrapper || wrapper.contains(mutation.target))) { + schedule(); + return; + } + } + } + }); + + observer.observe(editorRoot, { + attributes: true, + attributeFilter: ['style'], + childList: true, + subtree: true, + }); + + return observer; +} + +export function attachDocxImeTransformNeutralizer( + editorRoot: HTMLElement, + options: DocxImeTransformNeutralizerOptions = {}, +): () => void { + const view = getNeutralizerWindow(editorRoot, options.ownerDocument); + const ownerDocument = options.ownerDocument ?? editorRoot.ownerDocument; + let frameId: number | null = null; + let pollIntervalId: number | null = null; + let suppressObserver = false; + const retryTimeouts: number[] = []; + + const diagnosticOptions = createDeduplicatedDiagnosticOptions(options); const run = () => { frameId = null; @@ -550,49 +628,12 @@ export function attachDocxImeTransformNeutralizer( schedule(); }; - const observer = new MutationObserver((mutations) => { - if (suppressObserver) { - return; - } + const observer = createImeMutationObserver(editorRoot, () => suppressObserver, schedule); - for (const mutation of mutations) { - if (mutation.type === 'childList') { - schedule(); - return; - } - - if ( - mutation.type === 'attributes' - && mutation.attributeName === 'style' - && isHTMLElement(mutation.target) - ) { - const wrapper = findDocxEditorZoomWrapper(editorRoot); - if (wrapper && (mutation.target === wrapper || wrapper.contains(mutation.target))) { - schedule(); - return; - } - } - } - }); - - observer.observe(editorRoot, { - attributes: true, - attributeFilter: ['style'], - childList: true, - subtree: true, - }); - - const immediateEventOptions = { capture: true }; - const scheduledEventOptions = { capture: true }; - for (const eventName of ['keydown', 'beforeinput', 'compositionstart', 'compositionupdate', 'compositionend', 'input']) { - ownerDocument.addEventListener(eventName, runImmediately, immediateEventOptions); - } - for (const eventName of ['keyup', 'mouseup', 'focusin', 'focusout', 'selectionchange']) { - ownerDocument.addEventListener(eventName, schedule, scheduledEventOptions); - } + attachImeEventListeners(ownerDocument, runImmediately, schedule); schedule(); - for (const delay of [0, 100, 500, 1500]) { + for (const delay of RETRY_DELAYS_MS) { retryTimeouts.push(view.setTimeout(schedule, delay)); } pollIntervalId = view.setInterval(schedule, 1000); @@ -614,12 +655,7 @@ export function attachDocxImeTransformNeutralizer( view.clearTimeout(timeoutId); } } - for (const eventName of ['keydown', 'beforeinput', 'compositionstart', 'compositionupdate', 'compositionend', 'input']) { - ownerDocument.removeEventListener(eventName, runImmediately, immediateEventOptions); - } - for (const eventName of ['keyup', 'mouseup', 'focusin', 'focusout', 'selectionchange']) { - ownerDocument.removeEventListener(eventName, schedule, scheduledEventOptions); - } + detachImeEventListeners(ownerDocument, runImmediately, schedule); const editorView = options.getEditorView?.(); if (editorView) { const hiddenRoot = findHiddenProseMirrorRoot(editorView); diff --git a/tests/docx-ime-transform-neutralizer.test.mjs b/tests/docx-ime-transform-neutralizer.test.mjs index 6ec35f3..387e896 100644 --- a/tests/docx-ime-transform-neutralizer.test.mjs +++ b/tests/docx-ime-transform-neutralizer.test.mjs @@ -71,3 +71,63 @@ test("calculateHiddenImeAnchorPosition aligns hidden IME caret to visible caret" assert.equal(hiddenCaretLeftAfterMove, 300); assert.equal(hiddenCaretBottomAfterMove, 424); }); + +test("attachDocxImeTransformNeutralizer registers listeners, emits diagnostics, and cleans up", async () => { + const { attachDocxImeTransformNeutralizer } = await loadNeutralizerModule(); + + const originalMutationObserver = globalThis.MutationObserver; + globalThis.MutationObserver = class MockMutationObserver { + observe() {} + disconnect() {} + }; + + try { + const addedListeners = []; + const removedListeners = []; + const diagnostics = []; + + const mockWindow = { + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + setTimeout: () => 2, + clearTimeout: () => {}, + setInterval: () => 3, + clearInterval: () => {}, + }; + + const mockDocument = { + defaultView: mockWindow, + addEventListener: (type, handler, options) => { + addedListeners.push({ type, handler, options }); + }, + removeEventListener: (type, handler, options) => { + removedListeners.push({ type, handler, options }); + }, + }; + + const mockElement = { + ownerDocument: mockDocument, + querySelector: () => null, + contains: () => false, + }; + + const detach = attachDocxImeTransformNeutralizer(mockElement, { + ownerDocument: mockDocument, + onDiagnostic: (diag) => diagnostics.push(diag), + }); + + assert.ok(diagnostics.some((d) => d.event === "attached")); + assert.ok(addedListeners.length > 0); + + detach(); + + assert.ok(diagnostics.some((d) => d.event === "detached")); + assert.equal(removedListeners.length, addedListeners.length); + } finally { + if (originalMutationObserver) { + globalThis.MutationObserver = originalMutationObserver; + } else { + delete globalThis.MutationObserver; + } + } +});