From c6812580d8ed89f8b17ccd3ab1468d81e28cc3ff Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 28 Oct 2024 12:16:17 +0100 Subject: [PATCH] Fix: duplicated newlines when using dictation on iOS 18+. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes a bug that started to happen with iOS 18, where you would get duplicated newlines after completing the dictation. The bug happens because, upon dictation completion, iOS sends the sequence of `beforeinput` events very quickly. With the `insertParagraph` ones, it can happen that the internal document range fails to update, resulting in duplicated newlines and missed content. This workaround is necessary due to the inability to distinguish text entered in dictation mode, as iOS WebKit doesn’t trigger composition events during dictation (https://bugs.webkit.org/show_bug.cgi?id=261764). --- src/test/system/level_2_input_test.js | 13 ++++++++++--- src/test/test_helpers/input_helpers.js | 3 +-- src/trix/controllers/level_2_input_controller.js | 16 ++++++++++------ src/trix/core/helpers/events.js | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/test/system/level_2_input_test.js b/src/test/system/level_2_input_test.js index 150419b74..aae390263 100644 --- a/src/test/system/level_2_input_test.js +++ b/src/test/system/level_2_input_test.js @@ -52,11 +52,18 @@ const performInputTypeUsingExecCommand = async (command, { inputType, data }) => await nextFrame() + const isInsertParagraph = inputType === "insertParagraph" + triggerInputEvent(document.activeElement, "beforeinput", { inputType, data }) - document.execCommand(command, false, data) - assert.equal(inputEvents.length, 2) + + // See `shouldRenderInmmediatelyToDealWithIOSDictation` to deal with iOS 18+ dictation bug. + if (!isInsertParagraph) { + document.execCommand(command, false, data) + assert.equal(inputEvents[1].type, "input") + } + + assert.equal(inputEvents.length, isInsertParagraph ? 1 : 2) assert.equal(inputEvents[0].type, "beforeinput") - assert.equal(inputEvents[1].type, "input") assert.equal(inputEvents[0].inputType, inputType) assert.equal(inputEvents[0].data, data) diff --git a/src/test/test_helpers/input_helpers.js b/src/test/test_helpers/input_helpers.js index 8990adeaf..35c782bfb 100644 --- a/src/test/test_helpers/input_helpers.js +++ b/src/test/test_helpers/input_helpers.js @@ -276,9 +276,8 @@ const simulateKeypress = async (keyName) => { await deleteInDirection("right") break case "return": - await nextFrame() triggerInputEvent(document.activeElement, "beforeinput", { inputType: "insertParagraph" }) - await insertNode(document.createElement("br")) + break } } diff --git a/src/trix/controllers/level_2_input_controller.js b/src/trix/controllers/level_2_input_controller.js index 65b302157..121461d51 100644 --- a/src/trix/controllers/level_2_input_controller.js +++ b/src/trix/controllers/level_2_input_controller.js @@ -1,4 +1,4 @@ -import { getAllAttributeNames, squishBreakableWhitespace } from "trix/core/helpers" +import { getAllAttributeNames, shouldRenderInmmediatelyToDealWithIOSDictation, squishBreakableWhitespace } from "trix/core/helpers" import InputController from "trix/controllers/input_controller" import * as config from "trix/config" @@ -73,14 +73,18 @@ export default class Level2InputController extends InputController { beforeinput(event) { const handler = this.constructor.inputTypes[event.inputType] - // Handles bug with Siri dictation on iOS 18+. - if (!event.inputType) { - this.render() - } + const immmediateRender = shouldRenderInmmediatelyToDealWithIOSDictation(event) if (handler) { this.withEvent(event, handler) - this.scheduleRender() + + if (!immmediateRender) { + this.scheduleRender() + } + } + + if (immmediateRender) { + this.render() } }, diff --git a/src/trix/core/helpers/events.js b/src/trix/core/helpers/events.js index 4248830d3..f8411089e 100644 --- a/src/trix/core/helpers/events.js +++ b/src/trix/core/helpers/events.js @@ -43,3 +43,17 @@ export const keyEventIsKeyboardCommand = (function() { return (event) => event.ctrlKey } })() + +export function shouldRenderInmmediatelyToDealWithIOSDictation(inputEvent) { + if (/iPhone|iPad/.test(navigator.userAgent)) { + // Handle garbled content and duplicated newlines when using dictation on iOS 18+. Upon dictation completion, iOS sends + // the list of insertText / insertParagraph events in a quick sequence. If we don't render + // the editor synchronously, the internal range fails to update and results in garbled content or duplicated newlines. + // + // This workaround is necessary because iOS doesn't send composing events as expected while dictating: + // https://bugs.webkit.org/show_bug.cgi?id=261764 + return !inputEvent.inputType || inputEvent.inputType === "insertParagraph" + } else { + return false + } +}