From 9cf0d29bb16373414ae2a93988e809a83ad79781 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:41:45 +0530 Subject: [PATCH 1/3] feat(editor): improve mobile selection menu overflow UX --- src/cm/selectionMenuUtils.ts | 71 +++++ src/cm/touchSelectionMenu.js | 365 ++++++++++++++++++++++--- src/lib/selectionMenu.js | 60 +++- src/main.scss | 154 ++++++++++- tests/unit/selectionMenuButton.test.ts | 52 ++++ tests/unit/selectionMenuUtils.test.ts | 34 ++- 6 files changed, 680 insertions(+), 56 deletions(-) create mode 100644 tests/unit/selectionMenuButton.test.ts diff --git a/src/cm/selectionMenuUtils.ts b/src/cm/selectionMenuUtils.ts index 414dbfa83..ab7c56733 100644 --- a/src/cm/selectionMenuUtils.ts +++ b/src/cm/selectionMenuUtils.ts @@ -1,4 +1,5 @@ export interface SelectionMenuItem { + id?: string; mode?: "selected" | "all"; readOnly?: boolean; } @@ -8,6 +9,50 @@ export interface SelectionMenuFilterOptions { hasSelection: boolean; } +/** Preserve editor focus during a pointer press and activate on release. */ +export function bindSelectionMenuButton( + button: HTMLButtonElement, + onActivate: (event: Event) => void, +): void { + let activePointerId: number | null = null; + + const stopEvent = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + }; + const clearPointer = () => { + activePointerId = null; + button.classList.remove("is-pressed"); + }; + + button.addEventListener("pointerdown", (event) => { + if (event.isPrimary === false) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + activePointerId = event.pointerId; + button.classList.add("is-pressed"); + stopEvent(event); + try { + button.setPointerCapture?.(event.pointerId); + } catch { + // Pointer capture is optional in older Android WebViews. + } + }); + + button.addEventListener("pointerup", (event) => { + if (event.pointerId !== activePointerId) return; + clearPointer(); + stopEvent(event); + onActivate(event); + }); + + button.addEventListener("pointercancel", clearPointer); + button.addEventListener("lostpointercapture", clearPointer); + button.addEventListener("click", (event) => { + stopEvent(event); + if (event.detail === 0) onActivate(event); + }); +} + /** Filter selection actions using Acode's read-only and selection rules. */ export function filterSelectionMenuItems( items: readonly T[], @@ -23,3 +68,29 @@ export function filterSelectionMenuItems( return true; }); } + +const SELECTION_PRIMARY_ACTIONS = new Set([ + "copy", + "cut", + "paste", + "select-all", +]); +const CARET_PRIMARY_ACTIONS = new Set(["paste", "select-all"]); + +/** Keep the touch toolbar compact by moving secondary/plugin actions into More. */ +export function partitionSelectionMenuItems( + items: readonly T[], + options: Pick, +): { primary: T[]; overflow: T[] } { + const primaryIds = options.hasSelection + ? SELECTION_PRIMARY_ACTIONS + : CARET_PRIMARY_ACTIONS; + const primary: T[] = []; + const overflow: T[] = []; + + for (const item of items) { + (primaryIds.has(item.id ?? "") ? primary : overflow).push(item); + } + + return { primary, overflow }; +} diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index f99badae3..fc004d532 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -1,3 +1,4 @@ +import { LSPPlugin } from "@codemirror/lsp-client"; import { EditorSelection } from "@codemirror/state"; import { focusEditorIfEditable, @@ -5,8 +6,13 @@ import { resolveReadOnlyContextSelection, shouldCommitReadOnlyTap, } from "cm/editorReadOnly"; -import { filterSelectionMenuItems } from "cm/selectionMenuUtils"; +import { + bindSelectionMenuButton, + filterSelectionMenuItems, + partitionSelectionMenuItems, +} from "cm/selectionMenuUtils"; import selectionMenu from "lib/selectionMenu"; +import { animate } from "motion"; export { filterSelectionMenuItems } from "cm/selectionMenuUtils"; @@ -18,6 +24,7 @@ const MENU_SHOW_DELAY = 120; const MENU_CARET_GAP = 10; const MENU_SELECTION_GAP = 12; const MENU_HANDLE_CLEARANCE = 28; +const OVERFLOW_GRID_THRESHOLD = 10; const TAP_MAX_COLUMN_DELTA = 2; const TAP_MAX_POS_DELTA = 2; @@ -127,6 +134,19 @@ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } +function hasCodeActionProvider(view) { + return LSPPlugin.getAll(view, "codeAction").some( + (plugin) => !!plugin.client.serverCapabilities?.codeActionProvider, + ); +} + +function animationsDisabled() { + return ( + document.body.classList.contains("no-animation") || + globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches + ); +} + export default function createTouchSelectionMenu(view, options = {}) { return new TouchSelectionMenuController(view, options); } @@ -149,6 +169,11 @@ class TouchSelectionMenuController { #handlingMenuAction = false; #menuShowTimer = null; #tooltipObserver = null; + #dismissedSelection = null; + #renderedMenuKey = ""; + #menuAnchor = null; + #menuAnimation = null; + #viewAnimations = []; constructor(view, options = {}) { this.#view = view; @@ -226,6 +251,7 @@ class TouchSelectionMenuController { this.#pointerSelectionSession = null; this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; + this.#dismissedSelection = null; this.#menuRequested = false; this.#isPointerInteracting = false; this.#isScrolling = false; @@ -238,6 +264,7 @@ class TouchSelectionMenuController { setSelection(value) { if (!this.#enabled) return; if (value) { + this.#dismissedSelection = null; this.#menuRequested = true; } this.onStateChanged({ @@ -254,6 +281,7 @@ class TouchSelectionMenuController { this.#hideMenu(); return; } + this.#dismissedSelection = null; this.#scheduleMenuShow(MENU_SHOW_DELAY); } @@ -279,6 +307,12 @@ class TouchSelectionMenuController { onStateChanged(meta = {}) { if (!this.#enabled) return; if (meta.selectionChanged) this.#cancelReadOnlyTap(); + if ( + meta.selectionChanged && + this.#dismissedSelection !== this.#selectionSignature() + ) { + this.#dismissedSelection = null; + } if (this.#handlingMenuAction) return; if (!this.#shouldShowMenu()) { if (!this.#hasSelection()) { @@ -298,6 +332,7 @@ class TouchSelectionMenuController { this.#pointerSelectionSession = null; this.#readOnlyTapSession = null; this.#pendingPointerSelectionClick = null; + this.#dismissedSelection = null; this.#menuRequested = false; this.#isPointerInteracting = false; this.#isScrolling = false; @@ -321,6 +356,7 @@ class TouchSelectionMenuController { } event.preventDefault(); event.stopPropagation(); + this.#dismissedSelection = null; this.#menuRequested = true; this.#scheduleMenuShow(MENU_SHOW_DELAY); }; @@ -337,6 +373,7 @@ class TouchSelectionMenuController { return; } if (target instanceof Node && this.#view.dom.contains(target)) { + this.#dismissedSelection = null; this.#capturePointerSelection(event); this.#captureReadOnlyTap(event); this.#isPointerInteracting = true; @@ -549,6 +586,12 @@ class TouchSelectionMenuController { #shouldShowMenu() { if (this.#isScrolling || this.#isPointerInteracting) return false; if (!this.#view.hasFocus && !this.#isReadOnly()) return false; + if ( + !this.#menuRequested && + this.#dismissedSelection === this.#selectionSignature() + ) { + return false; + } return this.#hasSelection() || this.#menuRequested; } @@ -616,52 +659,248 @@ class TouchSelectionMenuController { } #showMenu(anchor) { + this.#menuAnchor = anchor; const hasSelection = this.#hasSelection(); - const items = filterSelectionMenuItems(selectionMenu(), { - readOnly: this.#isReadOnly(), - hasSelection, - }); + const items = filterSelectionMenuItems( + selectionMenu({ + codeActionsAvailable: hasCodeActionProvider(this.#view), + }), + { + readOnly: this.#isReadOnly(), + hasSelection, + }, + ); - this.$menu.innerHTML = ""; if (!items.length) { this.#menuRequested = false; this.#hideMenu(true); return; } - items.forEach(({ onclick, text }) => { - const $item = document.createElement("div"); - if (typeof text === "string") { - $item.textContent = text; - } else if (text instanceof Node) { - $item.append(text.cloneNode(true)); - } - let handled = false; - const runAction = (event) => { - if (handled) return; - handled = true; - event.preventDefault(); - event.stopPropagation(); - this.#handlingMenuAction = true; - try { - onclick?.(); - } finally { - this.#handlingMenuAction = false; - this.#menuRequested = false; - this.#hideMenu(); - focusEditorIfEditable(this.#view); - } - }; - $item.addEventListener("pointerdown", runAction); - $item.addEventListener("click", runAction); - this.$menu.append($item); - }); + const menuKey = `${hasSelection}:${items + .map((item) => item.id || this.#getItemLabel(item)) + .join("|")}`; + if (menuKey !== this.#renderedMenuKey) { + const groups = partitionSelectionMenuItems(items, { hasSelection }); + this.#renderMenu(groups.primary, groups.overflow); + this.#renderedMenuKey = menuKey; + } if (!this.$menu.isConnected) { this.#container.append(this.$menu); } + const isOpening = !this.#menuActive; + this.#positionMenu(anchor); + this.#menuActive = true; + this.#menuRequested = false; + if (isOpening) this.#animateMenuIn(); + } + + #renderMenu(primaryItems, overflowItems) { + this.$menu.replaceChildren(); + this.$menu.setAttribute("aria-label", "Text selection actions"); + + const $primary = document.createElement("div"); + $primary.className = "cursor-menu__primary"; + $primary.setAttribute("role", "toolbar"); + for (const item of primaryItems) { + $primary.append(this.#createActionButton(item, false)); + } + + if (overflowItems.length) { + const $overflow = document.createElement("div"); + $overflow.className = "cursor-menu__overflow"; + if (overflowItems.length > OVERFLOW_GRID_THRESHOLD) { + $overflow.classList.add("cursor-menu__overflow--grid"); + } + $overflow.setAttribute("role", "toolbar"); + $overflow.setAttribute("aria-label", "More text actions"); + $overflow.hidden = true; + + const backLabel = globalThis.strings?.back || "Back"; + const $back = document.createElement("button"); + $back.type = "button"; + $back.className = "cursor-menu__action cursor-menu__back"; + $back.setAttribute("aria-label", backLabel); + $back.append(this.#createIcon("arrow_back")); + + const $overflowActions = document.createElement("div"); + $overflowActions.className = "cursor-menu__overflow-actions"; + for (const item of overflowItems) { + $overflowActions.append(this.#createActionButton(item, true)); + } + $overflow.append($back, $overflowActions); + + const moreLabel = globalThis.strings?.more || "More"; + const $more = document.createElement("button"); + $more.type = "button"; + $more.className = "cursor-menu__action cursor-menu__more"; + $more.setAttribute("aria-label", moreLabel); + $more.setAttribute("aria-expanded", "false"); + $more.append(this.#createIcon("keyboard_control")); + bindSelectionMenuButton($more, () => { + $more.setAttribute("aria-expanded", "true"); + this.#setOverflowExpanded($primary, $overflow, true); + }); + bindSelectionMenuButton($back, () => { + $more.setAttribute("aria-expanded", "false"); + this.#setOverflowExpanded($primary, $overflow, false); + }); + + $primary.append($more); + this.$menu.append($primary, $overflow); + return; + } + + this.$menu.append($primary); + } + + #createActionButton(item, isOverflow) { + const $item = document.createElement("button"); + const label = this.#getItemLabel(item); + $item.type = "button"; + $item.className = `cursor-menu__action${ + isOverflow ? " cursor-menu__overflow-action" : "" + }`; + $item.setAttribute("aria-label", label); + if (label !== "More action") $item.title = label; + + if (isOverflow) { + if (item.text instanceof Node) { + $item.append(item.text.cloneNode(true)); + } else { + $item.textContent = label; + } + } else if (item.text instanceof Node) { + $item.append(item.text.cloneNode(true)); + } else { + $item.textContent = label; + } + + let handled = false; + const runAction = (event) => { + if (handled) return; + handled = true; + event.preventDefault(); + event.stopPropagation(); + this.#clearMenuShowTimer(); + cancelAnimationFrame(this.#stateSyncRaf); + this.#stateSyncRaf = 0; + this.#handlingMenuAction = true; + try { + item.onclick?.(); + } finally { + this.#handlingMenuAction = false; + this.#menuRequested = false; + this.#dismissedSelection = this.#selectionSignature(); + this.#hideMenu(); + focusEditorIfEditable(this.#view); + } + }; + bindSelectionMenuButton($item, runAction); + return $item; + } + + #animateMenuIn() { + this.#menuAnimation?.cancel?.(); + if (animationsDisabled()) { + this.$menu.style.opacity = "1"; + this.$menu.style.transform = "none"; + return; + } + + const y = this.$menu.dataset.placement === "above" ? 3 : -3; + this.#menuAnimation = animate( + this.$menu, + { + opacity: [0, 1], + scale: [0.96, 1], + y: [y, 0], + }, + { duration: 0.14, ease: "easeOut" }, + ); + } + + #setOverflowExpanded($primary, $overflow, expanded) { + for (const animation of this.#viewAnimations) animation.cancel?.(); + this.#viewAnimations = []; + const initialRect = this.$menu.getBoundingClientRect(); + const outgoing = expanded ? $primary : $overflow; + const incoming = expanded ? $overflow : $primary; + const direction = expanded ? 1 : -1; + this.$menu.style.width = `${this.$menu.getBoundingClientRect().width}px`; + incoming.hidden = false; + incoming.style.visibility = ""; + incoming.style.pointerEvents = "auto"; + outgoing.style.pointerEvents = "none"; + + const usesGrid = $overflow.classList.contains( + "cursor-menu__overflow--grid", + ); + const targetHeight = + expanded && usesGrid ? $overflow.getBoundingClientRect().height : 40; + this.$menu.style.height = `${targetHeight}px`; + if (usesGrid && this.#menuAnchor) this.#positionMenu(this.#menuAnchor); + const finalRect = this.$menu.getBoundingClientRect(); + + const finish = () => { + outgoing.hidden = true; + outgoing.style.opacity = ""; + outgoing.style.transform = ""; + outgoing.style.visibility = ""; + incoming.style.opacity = ""; + incoming.style.transform = ""; + this.$menu.style.height = `${targetHeight}px`; + this.$menu.style.transform = ""; + this.#viewAnimations = []; + }; + if (animationsDisabled()) { + finish(); + return; + } + + const outgoingAnimation = animate( + outgoing, + { opacity: [1, 0], x: [0, -6 * direction] }, + { duration: 0.1, ease: "easeIn" }, + ); + const incomingAnimation = animate( + incoming, + { opacity: [0, 1], x: [6 * direction, 0] }, + { duration: 0.14, ease: "easeOut" }, + ); + const animations = [outgoingAnimation, incomingAnimation]; + if (usesGrid && initialRect.height !== finalRect.height) { + animations.push( + animate( + this.$menu, + { + height: [initialRect.height, finalRect.height], + y: [initialRect.top - finalRect.top, 0], + }, + { duration: 0.16, ease: "easeOut" }, + ), + ); + } + this.#viewAnimations = animations; + Promise.allSettled(animations).then(() => { + if (this.#viewAnimations !== animations) return; + finish(); + }); + } + + #positionMenu(anchor) { + if (!this.$menu.isConnected) return; const containerRect = this.#container.getBoundingClientRect(); + this.$menu.style.setProperty( + "--cursor-menu-max-width", + `${Math.max(0, containerRect.width - MENU_MARGIN * 2)}px`, + ); + this.$menu.style.setProperty( + "--cursor-menu-grid-max-height", + `${Math.max(40, Math.min(240, containerRect.height - MENU_MARGIN * 2))}px`, + ); this.$menu.style.left = "0px"; this.$menu.style.top = "0px"; this.$menu.style.visibility = "hidden"; @@ -679,10 +918,11 @@ class TouchSelectionMenuController { containerRect.top + containerRect.height - menuRect.height - MENU_MARGIN; const fitsAbove = topAbove >= minTop; const fitsBelow = topBelow <= maxTop; + const placedAbove = fitsAbove || !fitsBelow; const clamped = clampMenuPosition( { left: preferredLeft, - top: fitsAbove || !fitsBelow ? topAbove : topBelow, + top: placedAbove ? topAbove : topBelow, width: menuRect.width, height: menuRect.height, }, @@ -698,9 +938,29 @@ class TouchSelectionMenuController { this.$menu.style.left = `${clamped.left - containerRect.left}px`; this.$menu.style.top = `${clamped.top - containerRect.top}px`; + this.$menu.dataset.placement = placedAbove ? "above" : "below"; this.$menu.style.visibility = ""; - this.#menuActive = true; - this.#menuRequested = false; + } + + #getItemLabel(item) { + if (item.label) return item.label; + if (typeof item.text === "string" && item.text.trim()) return item.text; + if (item.text instanceof Element) { + return ( + item.text.getAttribute("aria-label") || + item.text.getAttribute("title") || + item.text.textContent?.trim() || + "More action" + ); + } + return "More action"; + } + + #createIcon(name) { + const $icon = document.createElement("span"); + $icon.className = `icon ${name}`; + $icon.setAttribute("aria-hidden", "true"); + return $icon; } #showMenuDeferred() { @@ -771,9 +1031,36 @@ class TouchSelectionMenuController { #hideMenu(force = false) { if (!force && !this.#menuActive && !this.$menu.isConnected) return; + this.#menuAnimation?.cancel?.(); + for (const animation of this.#viewAnimations) animation.cancel?.(); + this.#menuAnimation = null; + this.#viewAnimations = []; if (this.$menu.isConnected) { this.$menu.remove(); } + const overflow = this.$menu.querySelector(".cursor-menu__overflow"); + if (overflow) overflow.hidden = true; + if (overflow) { + overflow.style.opacity = ""; + overflow.style.transform = ""; + overflow.style.pointerEvents = ""; + } + const primary = this.$menu.querySelector(".cursor-menu__primary"); + if (primary) { + primary.hidden = false; + primary.style.opacity = ""; + primary.style.transform = ""; + primary.style.pointerEvents = ""; + } + this.$menu.style.opacity = ""; + this.$menu.style.transform = ""; + this.$menu.style.width = ""; + this.$menu.style.height = "40px"; + this.$menu + .querySelector(".cursor-menu__more") + ?.setAttribute("aria-expanded", "false"); + this.#renderedMenuKey = ""; + this.#menuAnchor = null; this.#menuActive = false; } @@ -817,4 +1104,10 @@ class TouchSelectionMenuController { const selection = this.#view.state.selection.main; return selection.from !== selection.to; } + + #selectionSignature() { + return this.#view.state.selection.ranges + .map((range) => `${range.anchor}:${range.head}`) + .join("|"); + } } diff --git a/src/lib/selectionMenu.js b/src/lib/selectionMenu.js index e697802e6..333333fb8 100644 --- a/src/lib/selectionMenu.js +++ b/src/lib/selectionMenu.js @@ -29,21 +29,42 @@ const showCodeActions = async () => { const items = []; -export default function selectionMenu() { +export default function selectionMenu(options = {}) { + const { codeActionsAvailable = true } = options; return [ item( () => exec("copy"), , "selected", true, + { id: "copy", label: getLabel("copy", "Copy") }, + ), + item( + () => exec("cut"), + , + "selected", + false, + { + id: "cut", + label: getLabel("cut", "Cut"), + }, + ), + item( + () => exec("paste"), + , + "all", + false, + { + id: "paste", + label: getLabel("paste", "Paste"), + }, ), - item(() => exec("cut"), , "selected"), - item(() => exec("paste"), , "all"), item( () => exec("selectall"), , "all", true, + { id: "select-all", label: getLabel("select all", "Select all") }, ), appSettings.get("showShareButton") && item( @@ -51,18 +72,26 @@ export default function selectionMenu() { , "selected", true, + { id: "share", label: getLabel("share", "Share") }, ), item( (color) => acode.exec("insert-color", color), , "all", + false, + { id: "insert-color", label: getLabel("insert color", "Insert color") }, ), - item( - () => showCodeActions(), - , - "all", - true, - ), + codeActionsAvailable && + item( + () => showCodeActions(), + , + "all", + true, + { + id: "code-actions", + label: getLabel("code actions", "Code Actions"), + }, + ), ...items, ].filter(Boolean); } @@ -73,15 +102,20 @@ export default function selectionMenu() { * @param {string | HTMLElement} text content of the item * @param {'selected'|'all'} mode mode supported by the item * @param {boolean} readOnly whether to show the item in readOnly mode + * @param {{id?: string, label?: string}} options display metadata */ -selectionMenu.add = (onclick, text, mode, readOnly) => { - items.push(item(onclick, text, mode, readOnly)); +selectionMenu.add = (onclick, text, mode, readOnly, options) => { + items.push(item(onclick, text, mode, readOnly, options)); }; selectionMenu.exec = (command) => { exec(command); }; -function item(onclick, text, mode = "all", readOnly = false) { - return { onclick, text, mode, readOnly }; +function item(onclick, text, mode = "all", readOnly = false, options = {}) { + return { onclick, text, mode, readOnly, ...options }; +} + +function getLabel(key, fallback) { + return globalThis.strings?.[key] || fallback; } diff --git a/src/main.scss b/src/main.scss index d49def88b..5b04d56e9 100644 --- a/src/main.scss +++ b/src/main.scss @@ -475,32 +475,61 @@ textarea { } .cursor-menu { + --cursor-menu-max-width: calc(100vw - 20px); + --cursor-menu-grid-max-height: min(40vh, 240px); position: absolute; top: 0; left: 0; + width: max-content; + max-width: var(--cursor-menu-max-width); height: 40px; + margin: 0; + padding: 0; + box-sizing: border-box; background-color: #ffffff; background-color: var(--secondary-color); display: flex; - border-radius: 4px; + flex-direction: column; + border-radius: var(--popup-border-radius); box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); box-shadow: 0 0 8px var(--box-shadow-color); - border: none; border: solid 1px var(--popup-border-color); color: #252525; color: var(--secondary-text-color); transform-origin: left center; + isolation: isolate; + overflow: hidden; z-index: 4; - >span, - >div { + &__primary { + display: flex; + height: 100%; + align-items: stretch; + + &[hidden] { + visibility: hidden; + } + } + + &__action { + appearance: none; display: inline-flex; align-items: center; justify-content: center; + min-width: 50px; height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + outline: none; + background: transparent; + font: inherit; font-size: 0.9em; - min-width: 50px; color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; user-select: none; white-space: nowrap; @@ -508,6 +537,120 @@ textarea { opacity: 0.6; pointer-events: none; } + + &:active { + background-color: rgba(0, 0, 0, 0.2); + } + + &.is-pressed { + background-color: rgba(0, 0, 0, 0.2); + } + + &:focus-visible { + outline: 1px solid currentColor; + outline-offset: -2px; + } + } + + &__more { + width: 50px; + padding: 0; + } + + &__overflow { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + height: 100%; + min-width: 0; + color: inherit; + background-color: inherit; + border-radius: var(--popup-border-radius); + + &[hidden] { + visibility: hidden; + } + + &--grid { + inset: 0 auto auto 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)); + grid-auto-rows: 40px; + align-content: start; + width: 100%; + height: max-content; + max-height: var(--cursor-menu-grid-max-height); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + scrollbar-width: none; + touch-action: pan-y; + + &::-webkit-scrollbar { + display: none; + } + } + } + + &__back { + flex: 0 0 50px; + border-inline-end: solid 1px var(--border-color); + } + + &__overflow-actions { + display: flex; + min-width: 0; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; + touch-action: pan-x; + + &::-webkit-scrollbar { + display: none; + } + } + + &__overflow--grid &__overflow-actions { + display: contents; + } + + &__overflow--grid &__back { + position: sticky; + top: 0; + z-index: 1; + width: 50px; + height: 40px; + background-color: inherit; + } + + &__overflow--grid &__overflow-action { + height: 40px; + touch-action: pan-y; + } + + &__overflow-action { + flex: 0 0 50px; + justify-content: center; + width: 50px; + height: 100%; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + touch-action: pan-x; + + > .icon { + color: inherit; + } + + > :not(.icon) { + max-width: 46px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } } @@ -759,4 +902,3 @@ input[type="search"]::-webkit-search-results-decoration { } } } - diff --git a/tests/unit/selectionMenuButton.test.ts b/tests/unit/selectionMenuButton.test.ts new file mode 100644 index 000000000..508444050 --- /dev/null +++ b/tests/unit/selectionMenuButton.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom + +import { bindSelectionMenuButton } from "cm/selectionMenuUtils"; +import { describe, expect, it, vi } from "vitest"; + +function pointerEvent(type: string, pointerId = 7) { + return new PointerEvent(type, { + bubbles: true, + button: 0, + cancelable: true, + isPrimary: true, + pointerId, + pointerType: "touch", + }); +} + +describe("selection menu button interaction", () => { + it("preserves pointer-down and activates exactly once on release", () => { + const button = document.createElement("button"); + const activate = vi.fn(); + bindSelectionMenuButton(button, activate); + + const down = pointerEvent("pointerdown"); + button.dispatchEvent(down); + expect(down.defaultPrevented).toBe(true); + expect(button.classList.contains("is-pressed")).toBe(true); + expect(activate).not.toHaveBeenCalled(); + + button.dispatchEvent(pointerEvent("pointerup")); + button.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }), + ); + + expect(button.classList.contains("is-pressed")).toBe(false); + expect(activate).toHaveBeenCalledTimes(1); + }); + + it("supports keyboard clicks and cancels interrupted presses", () => { + const button = document.createElement("button"); + const activate = vi.fn(); + bindSelectionMenuButton(button, activate); + + button.dispatchEvent(pointerEvent("pointerdown")); + button.dispatchEvent(pointerEvent("pointercancel")); + expect(activate).not.toHaveBeenCalled(); + + button.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, detail: 0 }), + ); + expect(activate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/selectionMenuUtils.test.ts b/tests/unit/selectionMenuUtils.test.ts index 962f912e9..360fafdc5 100644 --- a/tests/unit/selectionMenuUtils.test.ts +++ b/tests/unit/selectionMenuUtils.test.ts @@ -1,4 +1,7 @@ -import { filterSelectionMenuItems } from "cm/selectionMenuUtils"; +import { + filterSelectionMenuItems, + partitionSelectionMenuItems, +} from "cm/selectionMenuUtils"; import { describe, expect, it } from "vitest"; const items = [ @@ -41,3 +44,32 @@ describe("selection menu filtering", () => { ]); }); }); + +describe("selection menu hierarchy", () => { + it("keeps selection essentials in the compact toolbar", () => { + const pluginItem = { id: "plugin-action", mode: "all" } as const; + const { primary, overflow } = partitionSelectionMenuItems( + [...items, pluginItem], + { hasSelection: true }, + ); + + expect(primary.map((item) => item.id)).toEqual([ + "copy", + "cut", + "paste", + "select-all", + ]); + expect(overflow.map((item) => item.id)).toEqual(["plugin-action"]); + }); + + it("keeps caret actions concise and moves plugins into More", () => { + const pluginItem = { mode: "all" } as const; + const { primary, overflow } = partitionSelectionMenuItems( + [...items, pluginItem], + { hasSelection: false }, + ); + + expect(primary.map((item) => item.id)).toEqual(["paste", "select-all"]); + expect(overflow).toEqual([items[0], items[1], pluginItem]); + }); +}); From ca29ae78ac765c6ca741bced56e4a62184539e59 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:58:09 +0530 Subject: [PATCH 2/3] fix(editor): prevent selection actions during overflow scrolling --- src/cm/selectionMenuUtils.ts | 17 +++++++++++++++++ tests/unit/selectionMenuButton.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/cm/selectionMenuUtils.ts b/src/cm/selectionMenuUtils.ts index ab7c56733..509cb8eb6 100644 --- a/src/cm/selectionMenuUtils.ts +++ b/src/cm/selectionMenuUtils.ts @@ -9,12 +9,15 @@ export interface SelectionMenuFilterOptions { hasSelection: boolean; } +const POINTER_MOVE_TOLERANCE = 8; + /** Preserve editor focus during a pointer press and activate on release. */ export function bindSelectionMenuButton( button: HTMLButtonElement, onActivate: (event: Event) => void, ): void { let activePointerId: number | null = null; + let pointerStart: { x: number; y: number } | null = null; const stopEvent = (event: Event) => { event.preventDefault(); @@ -22,6 +25,7 @@ export function bindSelectionMenuButton( }; const clearPointer = () => { activePointerId = null; + pointerStart = null; button.classList.remove("is-pressed"); }; @@ -29,6 +33,7 @@ export function bindSelectionMenuButton( if (event.isPrimary === false) return; if (event.pointerType === "mouse" && event.button !== 0) return; activePointerId = event.pointerId; + pointerStart = { x: event.clientX, y: event.clientY }; button.classList.add("is-pressed"); stopEvent(event); try { @@ -38,6 +43,18 @@ export function bindSelectionMenuButton( } }); + button.addEventListener("pointermove", (event) => { + if (event.pointerId !== activePointerId || !pointerStart) return; + const xDistance = event.clientX - pointerStart.x; + const yDistance = event.clientY - pointerStart.y; + if ( + xDistance ** 2 + yDistance ** 2 > + POINTER_MOVE_TOLERANCE ** 2 + ) { + clearPointer(); + } + }); + button.addEventListener("pointerup", (event) => { if (event.pointerId !== activePointerId) return; clearPointer(); diff --git a/tests/unit/selectionMenuButton.test.ts b/tests/unit/selectionMenuButton.test.ts index 508444050..c162b0acd 100644 --- a/tests/unit/selectionMenuButton.test.ts +++ b/tests/unit/selectionMenuButton.test.ts @@ -3,11 +3,17 @@ import { bindSelectionMenuButton } from "cm/selectionMenuUtils"; import { describe, expect, it, vi } from "vitest"; -function pointerEvent(type: string, pointerId = 7) { +function pointerEvent( + type: string, + pointerId = 7, + position: { x: number; y: number } = { x: 0, y: 0 }, +) { return new PointerEvent(type, { bubbles: true, button: 0, cancelable: true, + clientX: position.x, + clientY: position.y, isPrimary: true, pointerId, pointerType: "touch", @@ -49,4 +55,20 @@ describe("selection menu button interaction", () => { ); expect(activate).toHaveBeenCalledTimes(1); }); + + it("does not activate an action when the pointer becomes a scroll gesture", () => { + const button = document.createElement("button"); + const activate = vi.fn(); + bindSelectionMenuButton(button, activate); + + button.dispatchEvent(pointerEvent("pointerdown", 7, { x: 10, y: 10 })); + button.dispatchEvent(pointerEvent("pointermove", 7, { x: 10, y: 30 })); + button.dispatchEvent(pointerEvent("pointerup", 7, { x: 10, y: 30 })); + button.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }), + ); + + expect(button.classList.contains("is-pressed")).toBe(false); + expect(activate).not.toHaveBeenCalled(); + }); }); From 77de86078db3dd48cbb68b46bbcc4d4009608efe Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:03:30 +0530 Subject: [PATCH 3/3] fix(editor): validate pointer displacement on release --- src/cm/selectionMenuUtils.ts | 23 +++++++++++++---------- tests/unit/selectionMenuButton.test.ts | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/cm/selectionMenuUtils.ts b/src/cm/selectionMenuUtils.ts index 509cb8eb6..65abc0ced 100644 --- a/src/cm/selectionMenuUtils.ts +++ b/src/cm/selectionMenuUtils.ts @@ -28,6 +28,15 @@ export function bindSelectionMenuButton( pointerStart = null; button.classList.remove("is-pressed"); }; + const pointerMoved = (event: PointerEvent) => { + if (!pointerStart) return false; + const xDistance = event.clientX - pointerStart.x; + const yDistance = event.clientY - pointerStart.y; + return ( + xDistance ** 2 + yDistance ** 2 > + POINTER_MOVE_TOLERANCE ** 2 + ); + }; button.addEventListener("pointerdown", (event) => { if (event.isPrimary === false) return; @@ -45,21 +54,15 @@ export function bindSelectionMenuButton( button.addEventListener("pointermove", (event) => { if (event.pointerId !== activePointerId || !pointerStart) return; - const xDistance = event.clientX - pointerStart.x; - const yDistance = event.clientY - pointerStart.y; - if ( - xDistance ** 2 + yDistance ** 2 > - POINTER_MOVE_TOLERANCE ** 2 - ) { - clearPointer(); - } + if (pointerMoved(event)) clearPointer(); }); button.addEventListener("pointerup", (event) => { - if (event.pointerId !== activePointerId) return; + if (event.pointerId !== activePointerId || !pointerStart) return; + const moved = pointerMoved(event); clearPointer(); stopEvent(event); - onActivate(event); + if (!moved) onActivate(event); }); button.addEventListener("pointercancel", clearPointer); diff --git a/tests/unit/selectionMenuButton.test.ts b/tests/unit/selectionMenuButton.test.ts index c162b0acd..47e0b26b1 100644 --- a/tests/unit/selectionMenuButton.test.ts +++ b/tests/unit/selectionMenuButton.test.ts @@ -71,4 +71,19 @@ describe("selection menu button interaction", () => { expect(button.classList.contains("is-pressed")).toBe(false); expect(activate).not.toHaveBeenCalled(); }); + + it("checks final pointer displacement when no move event is delivered", () => { + const button = document.createElement("button"); + const activate = vi.fn(); + bindSelectionMenuButton(button, activate); + + button.dispatchEvent(pointerEvent("pointerdown", 7, { x: 10, y: 10 })); + button.dispatchEvent(pointerEvent("pointerup", 7, { x: 30, y: 10 })); + button.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }), + ); + + expect(button.classList.contains("is-pressed")).toBe(false); + expect(activate).not.toHaveBeenCalled(); + }); });