fix: detect typing inside the QuotedHtml shadow island via composedPath #654

This commit is contained in:
Linus Rath
2026-07-21 23:26:51 +02:00
parent 4ad9267a2d
commit a909593dda
6 changed files with 120 additions and 37 deletions
+4 -4
View File
@@ -14,6 +14,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
import { isEditableEventTarget } from "@/lib/keyboard";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
@@ -1154,10 +1155,9 @@ export function EmailComposer({
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const tag = target?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (target?.getAttribute('contenteditable') === 'true') return;
// composedPath-based check so editing inside the QuotedHtml shadow
// island doesn't trigger the picker (#654).
if (isEditableEventTarget(e)) return;
if (!templatesEnabled) return;
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
+3 -10
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
@@ -887,16 +888,8 @@ export function Sidebar({
// window listener, so without this guard typing in a new email (the
// contentEditable composer, the subject field, search, etc.) toggled the
// selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return;
}
// composedPath-based so it also sees the QuotedHtml shadow island (#654).
if (isEditableEventTarget(e)) return;
if (!selectedMailbox || isCollapsed) return;
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
+5 -14
View File
@@ -2,6 +2,7 @@
import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
import { isEditableEventTarget } from "@/lib/keyboard";
export interface KeyboardShortcutHandlers {
// Navigation
@@ -43,18 +44,6 @@ export interface UseKeyboardShortcutsOptions {
handlers: KeyboardShortcutHandlers;
}
// Check if user is typing in an input field
function isInputFocused(): boolean {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
return isInput || isContentEditable;
}
// Shortcuts must fire regardless of the active keyboard layout (e.g. Cyrillic,
// Greek). Derive the key from the PHYSICAL key (event.code) instead of the
// layout-dependent event.key: letters from KeyA..KeyZ, and the symbol shortcuts
@@ -94,8 +83,10 @@ export function useKeyboardShortcuts({
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (isInputFocused()) return;
// Don't trigger shortcuts when typing in inputs. Must be event-based
// (composedPath), not document.activeElement: the QuotedHtml island's
// shadow root retargets activeElement to its plain-div host (#654).
if (isEditableEventTarget(event)) return;
const h = handlersRef.current;
const key = physicalShortcutKey(event);
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, afterEach } from 'vitest';
import { isEditableEventTarget } from '../keyboard';
// Regression tests for #654: global single-key shortcuts fired while the user
// was editing inside the QuotedHtml shadow-DOM island, because the shadow
// boundary retargets both document.activeElement and event.target to the
// plain-div host. isEditableEventTarget must rely on composedPath instead.
// Evaluate from a window-level listener DURING dispatch — composedPath() is
// only populated while the event is being dispatched, matching how the real
// shortcut handlers run.
function dispatchAndCheck(el: HTMLElement): { editable: boolean; target: EventTarget | null } {
let result: { editable: boolean; target: EventTarget | null } | null = null;
const listener = (e: Event) => {
result = { editable: isEditableEventTarget(e), target: e.target };
};
window.addEventListener('keydown', listener);
el.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, composed: true })
);
window.removeEventListener('keydown', listener);
if (!result) throw new Error('keydown never reached window');
return result;
}
describe('isEditableEventTarget', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('detects plain inputs and textareas', () => {
for (const tag of ['input', 'textarea', 'select'] as const) {
const el = document.createElement(tag);
document.body.appendChild(el);
expect(dispatchAndCheck(el).editable).toBe(true);
}
});
it('detects a contentEditable element', () => {
const el = document.createElement('div');
el.setAttribute('contenteditable', 'true');
document.body.appendChild(el);
expect(dispatchAndCheck(el).editable).toBe(true);
});
it('returns false for a non-editable element', () => {
const el = document.createElement('div');
document.body.appendChild(el);
expect(dispatchAndCheck(el).editable).toBe(false);
});
it('sees through a shadow boundary to an inner contentEditable (QuotedHtml island)', () => {
// Mirror the structure quoted-html.ts builds: plain-div host, open shadow
// root, inner contentEditable div.
const host = document.createElement('div');
host.className = 'quoted-html-island';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
inner.setAttribute('contenteditable', 'true');
shadow.appendChild(inner);
const { editable, target } = dispatchAndCheck(inner);
// Sanity: the shadow boundary retargets the event — the outside listener
// sees the host, which is exactly why a target/activeElement check fails.
expect(target).toBe(host);
expect(editable).toBe(true);
});
it('still returns false for a non-editable shadow tree', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
shadow.appendChild(inner);
expect(dispatchAndCheck(inner).editable).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
// Shared helper for global key listeners to decide whether a keyboard event
// originated from a typing context (input, textarea, select, contentEditable).
//
// Checking `document.activeElement` / `event.target` is NOT enough: inside a
// shadow root both are retargeted to the host element, so the QuotedHtml
// island's inner contentEditable (components/email/quoted-html.ts) looks like
// a plain <div> from outside. Single-key mailbox shortcuts then fire while the
// user is editing quoted text — up to and including deleting the open email on
// Backspace (#654). `composedPath()` sees through the shadow boundary and
// starts at the real inner target, so it is the reliable signal.
export function isEditableEventTarget(event: Event): boolean {
const path =
typeof event.composedPath === "function"
? event.composedPath()
: [event.target];
return path.some((node) => {
if (!(node instanceof HTMLElement)) return false;
const tag = node.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
if (node.isContentEditable) return true;
// jsdom (tests) doesn't implement isContentEditable; browsers reflect the
// property into the attribute, so this also covers "plaintext-only".
const attr = node.getAttribute("contenteditable");
return attr === "" || attr === "true" || attr === "plaintext-only";
});
}
+2 -9
View File
@@ -8,6 +8,7 @@
// The listener ignores events when an editable element has focus, matching
// the convention in `use-keyboard-shortcuts.ts`.
import { isEditableEventTarget } from '@/lib/keyboard';
import type { SandboxInstance } from './host-bridge';
interface Binding {
@@ -62,16 +63,8 @@ function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
return ev.key.toLowerCase() === combo.key;
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable) return true;
return false;
}
function onKeyDown(ev: KeyboardEvent): void {
if (isEditableTarget(ev.target)) return;
if (isEditableEventTarget(ev)) return;
if (bindings.size === 0) return;
for (const binding of bindings.values()) {
const combo = normaliseCombo(binding.keys);