diff --git a/src/test/system/level_2_input_test.js b/src/test/system/level_2_input_test.js index 150419b74..bf6884b2c 100644 --- a/src/test/system/level_2_input_test.js +++ b/src/test/system/level_2_input_test.js @@ -100,7 +100,11 @@ testGroup("Level 2 Input", testOptions, () => { test("insertParagraph", async () => { await clickToolbarButton({ attribute: "quote" }) insertString("abc") + + // Insert Enter keydown events to deal with workaround for iOS 18 dictation issues. See `shouldInsertParagraph`. + triggerEvent(document.activeElement, "keydown", { key: "Enter", code: "Enter" }) await performInputTypeUsingExecCommand("insertParagraph", { inputType: "insertParagraph" }) + triggerEvent(document.activeElement, "keydown", { key: "Enter", code: "Enter" }) await performInputTypeUsingExecCommand("insertParagraph", { inputType: "insertParagraph" }) assert.blockAttributes([ 0, 4 ], [ "quote" ]) diff --git a/src/trix/controllers/level_2_input_controller.js b/src/trix/controllers/level_2_input_controller.js index 65b302157..d05f05d70 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, shouldInsertParagraph, squishBreakableWhitespace } from "trix/core/helpers" import InputController from "trix/controllers/input_controller" import * as config from "trix/config" @@ -14,6 +14,8 @@ export default class Level2InputController extends InputController { static events = { keydown(event) { + this.saveLastEvent("lastKeydownEvent", event) + if (keyEventIsKeyboardCommand(event)) { const command = keyboardCommandFromKeyEvent(event) if (this.delegate?.inputControllerDidReceiveKeyboardCommand(command)) { @@ -71,6 +73,8 @@ export default class Level2InputController extends InputController { }, beforeinput(event) { + this.saveLastEvent("lastBeforeInputEvent", event) + const handler = this.constructor.inputTypes[event.inputType] // Handles bug with Siri dictation on iOS 18+. @@ -441,8 +445,10 @@ export default class Level2InputController extends InputController { insertParagraph() { this.delegate?.inputControllerWillPerformTyping() - return this.withTargetDOMRange(function() { - return this.responder?.insertLineBreak() + return this.withTargetDOMRange(() => { + if (shouldInsertParagraph(this.lastKeydownEvent, this.lastBeforeInputEvent)) { + return this.responder?.insertLineBreak() + } }) }, @@ -468,6 +474,12 @@ export default class Level2InputController extends InputController { }, } + saveLastEvent(name, event) { + // Native timestamp is not reliable as some browsers do not update it on keydown. + event._timeStamp = Date.now() + this[name] = event + } + elementDidMutate() { if (this.scheduledRender) { if (this.composing) { diff --git a/src/trix/core/helpers/events.js b/src/trix/core/helpers/events.js index 4248830d3..1f525db3c 100644 --- a/src/trix/core/helpers/events.js +++ b/src/trix/core/helpers/events.js @@ -1,6 +1,6 @@ const testTransferData = { "application/x-trix-feature-detection": "test" } -export const dataTransferIsPlainText = function(dataTransfer) { +export const dataTransferIsPlainText = function (dataTransfer) { const text = dataTransfer.getData("text/plain") const html = dataTransfer.getData("text/html") @@ -20,7 +20,7 @@ export const dataTransferIsMsOfficePaste = ({ dataTransfer }) => { dataTransfer.getData("text/html").includes("urn:schemas-microsoft-com:office:office") } -export const dataTransferIsWritable = function(dataTransfer) { +export const dataTransferIsWritable = function (dataTransfer) { if (!dataTransfer?.setData) return false for (const key in testTransferData) { @@ -36,10 +36,26 @@ export const dataTransferIsWritable = function(dataTransfer) { return true } -export const keyEventIsKeyboardCommand = (function() { +export const keyEventIsKeyboardCommand = (function () { if (/Mac|^iP/.test(navigator.platform)) { return (event) => event.metaKey } else { return (event) => event.ctrlKey } })() + +export function shouldInsertParagraph(keydownEvent, beforeInputEvent) { + if (/iPhone|iPad/i.test(navigator.userAgent)) { + // Handle duplicated newlines when using dictation on iOS 18+. Upon dictation completion, iOS sends `insertParagraph` events + // and shifts the range offset to the next line, resulting in duplicated newlines. This patch ignores these events unless triggered + // by an Enter keypress. 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). + if (keydownEvent && beforeInputEvent && Math.abs(keydownEvent._timeStamp - beforeInputEvent._timeStamp) < 1000) { + return keydownEvent.key === "Enter" || keydownEvent.key === "Return" + } else { + return false + } + } else { + return true + } +}