From 43f04dcd867176d5f692809bc4e7197aadbb5cb4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 2 Jul 2026 14:52:24 +0200 Subject: [PATCH] fix: finally fix the cursor key bug in monaco --- assets/js/hooks/monaco_editor.js | 56 ++++++++++++++++++++++++++++++++ priv/static/assets/app.js | 41 +++++++++++++++++++++++ test/bds/ui/shell_test.exs | 42 ++++++++++++++++++++++++ 3 files changed, 139 insertions(+) diff --git a/assets/js/hooks/monaco_editor.js b/assets/js/hooks/monaco_editor.js index d92acd4..e70b0bb 100644 --- a/assets/js/hooks/monaco_editor.js +++ b/assets/js/hooks/monaco_editor.js @@ -5,6 +5,52 @@ import { unregisterMonacoEditor } from "../monaco/services.js"; +const WEBKIT_TEXTAREA_ARROW_COMMANDS = { + ArrowLeft: "cursorLeft", + ArrowRight: "cursorRight", + ArrowUp: "cursorUp", + ArrowDown: "cursorDown" +}; + +const isWebKitEngine = () => { + const userAgent = globalThis.navigator?.userAgent || ""; + return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium"); +}; + +const isMonacoInputArea = (target) => + target instanceof HTMLTextAreaElement && target.classList.contains("inputarea"); + +export const webKitTextAreaArrowCommand = (event) => { + if (event?.altKey || event?.ctrlKey || event?.metaKey) { + return null; + } + + const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key]; + + if (!command) { + return null; + } + + return event.shiftKey ? `${command}Select` : command; +}; + +export const bridgeWebKitTextAreaArrowKey = (editor, event) => { + if (!editor || !isMonacoInputArea(event.target)) { + return false; + } + + const command = webKitTextAreaArrowCommand(event); + + if (!command) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + editor.trigger("keyboard", command, { source: "keyboard" }); + return true; +}; + export const MonacoEditor = { mounted() { this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); @@ -17,6 +63,10 @@ export const MonacoEditor = { this.isApplyingRemoteUpdate = false; this.lastKnownValue = this.textarea?.value || ""; + this.handleWebKitTextAreaArrowKey = (event) => { + bridgeWebKitTextAreaArrowKey(this.editor, event); + }; + this.syncEditorFromTextarea = () => { this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); @@ -246,6 +296,10 @@ export const MonacoEditor = { this.el.addEventListener("dragover", this.handleDragOver); this.el.addEventListener("drop", this.handleDrop); } + + if (isWebKitEngine()) { + this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true); + } }) .catch((error) => { console.error("Failed to load Monaco editor", error); @@ -287,6 +341,8 @@ export const MonacoEditor = { this.el.removeEventListener("drop", this.handleDrop); } + this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true); + unregisterMonacoEditor(this.editorId || this.el.id); this.editor?.dispose(); } diff --git a/priv/static/assets/app.js b/priv/static/assets/app.js index b9db938..d58b0bd 100644 --- a/priv/static/assets/app.js +++ b/priv/static/assets/app.js @@ -9470,6 +9470,40 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" }; // js/hooks/monaco_editor.js + var WEBKIT_TEXTAREA_ARROW_COMMANDS = { + ArrowLeft: "cursorLeft", + ArrowRight: "cursorRight", + ArrowUp: "cursorUp", + ArrowDown: "cursorDown" + }; + var isWebKitEngine = () => { + const userAgent = globalThis.navigator?.userAgent || ""; + return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium"); + }; + var isMonacoInputArea = (target) => target instanceof HTMLTextAreaElement && target.classList.contains("inputarea"); + var webKitTextAreaArrowCommand = (event) => { + if (event?.altKey || event?.ctrlKey || event?.metaKey) { + return null; + } + const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key]; + if (!command) { + return null; + } + return event.shiftKey ? `${command}Select` : command; + }; + var bridgeWebKitTextAreaArrowKey = (editor, event) => { + if (!editor || !isMonacoInputArea(event.target)) { + return false; + } + const command = webKitTextAreaArrowCommand(event); + if (!command) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + editor.trigger("keyboard", command, { source: "keyboard" }); + return true; + }; var MonacoEditor = { mounted() { this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); @@ -9481,6 +9515,9 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" this.syncTimer = null; this.isApplyingRemoteUpdate = false; this.lastKnownValue = this.textarea?.value || ""; + this.handleWebKitTextAreaArrowKey = (event) => { + bridgeWebKitTextAreaArrowKey(this.editor, event); + }; this.syncEditorFromTextarea = () => { this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); if (!this.textarea || !this.editor) { @@ -9660,6 +9697,9 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" this.el.addEventListener("dragover", this.handleDragOver); this.el.addEventListener("drop", this.handleDrop); } + if (isWebKitEngine()) { + this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true); + } }).catch((error) => { console.error("Failed to load Monaco editor", error); }); @@ -9691,6 +9731,7 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" this.el.removeEventListener("dragover", this.handleDragOver); this.el.removeEventListener("drop", this.handleDrop); } + this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true); unregisterMonacoEditor(this.editorId || this.el.id); this.editor?.dispose(); } diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 507cc51..7a43760 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -358,6 +358,48 @@ defmodule BDS.UI.ShellTest do assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/languages.js") end + test "monaco hook maps WebKit textarea arrow keys to cursor commands" do + script = """ + import assert from "node:assert/strict"; + class FakeTextArea {} + globalThis.HTMLTextAreaElement = FakeTextArea; + + const { bridgeWebKitTextAreaArrowKey, webKitTextAreaArrowCommand } = await import("./assets/js/hooks/monaco_editor.js"); + + assert.equal(webKitTextAreaArrowCommand({ key: "ArrowLeft", shiftKey: false }), "cursorLeft"); + assert.equal(webKitTextAreaArrowCommand({ key: "ArrowRight", shiftKey: false }), "cursorRight"); + assert.equal(webKitTextAreaArrowCommand({ key: "ArrowUp", shiftKey: true }), "cursorUpSelect"); + assert.equal(webKitTextAreaArrowCommand({ key: "ArrowDown", shiftKey: true }), "cursorDownSelect"); + assert.equal(webKitTextAreaArrowCommand({ key: "ArrowLeft", altKey: true }), null); + assert.equal(webKitTextAreaArrowCommand({ key: "A", shiftKey: false }), null); + + const target = new FakeTextArea(); + target.classList = { contains: (className) => className === "inputarea" }; + + const calls = []; + const event = { + key: "ArrowLeft", + shiftKey: false, + target, + preventDefault: () => calls.push("preventDefault"), + stopPropagation: () => calls.push("stopPropagation") + }; + const editor = { trigger: (...args) => calls.push(args) }; + + assert.equal(bridgeWebKitTextAreaArrowKey(editor, event), true); + assert.deepEqual(calls, ["preventDefault", "stopPropagation", ["keyboard", "cursorLeft", { source: "keyboard" }]]); + assert.equal(bridgeWebKitTextAreaArrowKey(editor, { ...event, target: {} }), false); + """ + + {output, status} = + System.cmd("node", ["--input-type=module", "--eval", script], + cd: "/Users/gb/Projects/bDS2", + stderr_to_stdout: true + ) + + assert status == 0, output + end + test "monaco ESM build config emits served worker bundles" do mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs") config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")