Skip to content

slides: press and hold opens the same menu on a phone - #276

Open
iamgeo92 wants to merge 3 commits into
nyblnet:mainfrom
iamgeo92:longpress-context-menu
Open

slides: press and hold opens the same menu on a phone#276
iamgeo92 wants to merge 3 commits into
nyblnet:mainfrom
iamgeo92:longpress-context-menu

Conversation

@iamgeo92

@iamgeo92 iamgeo92 commented Aug 10, 2026

Copy link
Copy Markdown

Stacked on #275. This branch contains that commit as its base — it is the
menu this one reaches from touch. Merge #275 first and this reduces to its own
single commit.

The problem

A touch screen has no right mouse button. And iOS fires no contextmenu event
for an ordinary element
— a long press there raises the system callout ("Copy
/ Look Up / Share"), not a menu.

So the context menu in #275 would have shipped mouse-only, and on a phone
Duplicate, Group, Bring to front, Send to back and Delete would have had no
route at all
— none of them appear in the phone toolbar, which folds down to
☰ · + · undo · Format · Save · ⋯.

Measured before this change: a 900ms press on a canvas element produced
contextmenu events: 0.

The fix

Hold for 500ms — what iOS itself uses for the callout, so it matches the muscle
memory already on the device — and the same menu opens. Element, slide
thumbnail, or the canvas beside the slide, exactly as with a right-click.

The gesture is recognised by hand and routed into the same
openContextMenuAt() the right-click uses, so the two can never drift apart in
what they offer or in what they refuse.

The three things this had to get right

This listener sits on the surface Moveable drags elements on, so a careless
version is worse than no menu:

  1. Movement cancels it (>10px) — a drag or a pan is never stolen.
  2. A second finger cancels it — a pinch is never stolen.
  3. The release is cancelled. This is the subtle one: the menu opens under
    the finger
    , so the lift that ends the press would land as a click on the row
    now sitting at that point — the first item would fire itself. Cancelling the
    touchend stops the browser generating that tap at all. There is a test
    asserting nothing is duplicated or deleted by the release.

iOS's own callout bubble is suppressed on the canvas and the slide list so the
two gestures don't race:

.ed-scroll, .ed-sidebar .ed-thumb { -webkit-touch-callout: none; }
.ed-scroll .bento-editing        { -webkit-touch-callout: default; }

The exemption matters: over text being edited, the system bubble carries
paste, dictation and spelling, and is the better menu of the two. .ed-root
already sets user-select: none, so nothing else changes.

Before / after

Same gesture both sides: press and hold an element, then a slide thumbnail.

long press, before and after

after — element after — slide thumbnail

How this was verified

iPhone emulation (390×664, DPR 3, hasTouch) with real CDP touch dispatch —
genuine press / move / lift sequences, not synthetic clicks:

case result
a 800ms press opens a menu pass
…and it is the element menu (Edit text … Delete) pass
…and it selected the element under the finger pass
…and it is fully on screen pass
a 120ms tap does not open it pass
a press that drags does not open it pass
the menu is still open after the finger lifts pass
nothing is duplicated or deleted by the release pass
tapping Duplicate from touch duplicates, and closes the menu pass
a press on a slide thumbnail offers the slide verbs pass
desktop right-click is unaffected (the shared path) pass

Gates:

node_modules/.bin/tsc -b                                        clean
npm run build:single                                            ok (667KB shell)
node scripts/shell-gate.mjs …/Bento_Slides.bento.html           splice contract OK

No new user-facing strings (the menu's strings landed with #275), so no i18n
changes here. No format or kernel change; nothing under sync/.

Update after review

@7jameslondon's review on #271 applies to this gesture as well, so the second
commit here carries it across. The recogniser was a pointerdown handler that
runs for a mouse too and filtered itself back out by pointerType, plus a
window-level listener swallowing pointerup/mouseup/click for 500ms after
the menu opened. It is now touchstart/touchmove/touchend, which cannot
fire for a mouse at all, and the release is stopped by cancelling the
touchend rather than by a timed suppression window — so
swallowTapAfterLongPress and TAP_AFTER_PRESS_MS are gone. The test that
asserts the release cannot fire the row beneath it is unchanged and still
passes.

Caveat: verified under Chromium's iPhone emulation, not on physical iOS
hardware. The two places real Safari could still differ are worth a device
check: whether -webkit-touch-callout: none fully suppresses the callout on a
long press over rendered slide text, and whether iOS's own haptic/selection
behaviour interferes with the 500ms threshold. The recogniser itself is pointer
events and timers, which behave identically.


Changelog entry — not in the diff, on purpose

CHANGELOG.md is this repo's named conflict magnet (docs/PARALLEL-WORK.md §3). With several of these open at once, and [Unreleased] being emptied every time a release is cut, a changelog hunk made every one of them conflict on that file and nothing else — twice over. They carry no CHANGELOG.md change so they stay mergeable; here is the entry to drop in at release time, or I'll add it back in whatever form you prefer.

- **Right-click now opens a menu that belongs to the deck.** Until now the
  editor had no context menu at all, so a right-click anywhere on the canvas
  produced the browser's own — Back, Reload, View source — which is exactly the
  wrong set of verbs for a slide. Right-clicking an element offers Edit text,
  Cut, Copy, Duplicate, Bring to front, Send to back, Group / Ungroup and
  Delete; a slide thumbnail offers New slide, Duplicate slide and Delete slide;
  the canvas beside the slide offers Paste and the slide's own verbs. Aiming at
  something outside the selection selects it first, the way every editor does,
  so the verbs always describe what they are about to act on. The browser's menu
  is deliberately left alone where it is the better one — form fields, links,
  and text being edited, where the system carries spelling, dictation, look-up
  and a real paste.

- **Press and hold opens the same menu on a phone.** A touch screen has no right
  mouse button, and iOS fires no `contextmenu` event for an ordinary element — a
  long press there raises the system callout instead — so the menu would have
  been mouse-only, and Duplicate, Group, the z-order and Delete would have had
  no route on a phone at all. Holding an element, a slide thumbnail or the
  canvas for half a second now opens exactly what a right-click opens. The press
  is abandoned the moment the finger moves or a second one lands, so dragging,
  panning and pinching are untouched, and the release that ends the press cannot
  fall through onto the row that appeared under the finger. iOS's own callout
  bubble is suppressed on those surfaces so the two do not race — except over
  text being edited, where the system bubble is the better menu and keeps
  working.

@iamgeo92
iamgeo92 force-pushed the longpress-context-menu branch 2 times, most recently from aee8df4 to 98522b0 Compare August 10, 2026 02:28
The editor had no context menu at all, so a right-click on the canvas
produced the browser's own — Back, Reload, View source — which is
precisely the wrong set of verbs for a slide, and the one gesture every
user of every other editor tries first.

Three menus, chosen by what was actually clicked. An element offers Edit
text, Cut, Copy, Duplicate, Bring to front, Send to back, Group /
Ungroup and Delete. A slide thumbnail offers New slide, Duplicate slide
and Delete slide. The canvas beside the slide offers Paste and the
slide's own verbs. Aiming at something outside the selection selects it
first, the way every editor does, so the verbs always describe what they
are about to act on.

The browser's menu is deliberately left where it is the better one: form
fields, links, and text mid-edit, where the system carries spelling,
dictation, look-up and a real paste. That last case needs the question
asked on the PRESS — a right-click commits the edit before contextmenu
is dispatched — and answered by geometry, because Moveable's control box
sits on top of the caret and swallows the target.

ctxmenu.ts renders and dismisses; it knows nothing about slides. Paste
shares the text path with the paste EVENT rather than growing a second
copy of it, which is why wirePaste is split.
A touch screen has no right mouse button, and iOS fires no contextmenu
event for an ordinary element — a long press there raises the system
callout instead. So the menu the previous commit added would have been
mouse-only, and Duplicate, Group, the z-order and Delete would have had
no route on a phone at all.

The gesture is recognised by hand and routed into the SAME
openContextMenuAt() the right-click uses, so the two can never drift
apart in what they offer or in what they refuse.

Three things this has to get right, because the listener sits on the
surface Moveable drags elements on:

- movement cancels it, so a drag or a pan is never stolen;
- a second finger cancels it, so a pinch is never stolen;
- the release is swallowed, because the finger comes up ON the menu that
  just appeared beneath it and would otherwise fire its first row.

iOS's callout bubble is suppressed on the canvas and the slide list so
the two gestures do not race — except over text being edited, where the
system bubble carries paste, dictation and spelling and is the better
menu of the two.
Carries @7jameslondon's review of nyblnet#271 across to this gesture, which had
the same shape: a pointer handler that runs for a mouse and filters
itself back out by pointerType, plus a window-level listener swallowing
pointerup/mouseup/click for 500ms afterwards.

Touch events cannot fire for a mouse, and cancelling the touchend stops
the browser generating the tap that ends the press rather than racing
it. swallowTapAfterLongPress and TAP_AFTER_PRESS_MS go away.

The release still cannot fire the row that opened under the finger —
that is now a property of the cancelled touchend rather than of a timed
suppression window, and the test that asserts it is unchanged.
@iamgeo92

Copy link
Copy Markdown
Author

@nyblnet — review at your convenience; this is the touch half of #275.

iOS fires no contextmenu for an ordinary element — a long press there raises the system callout — so without this the menu in #275 ships mouse-only, and Duplicate, Group, the z-order and Delete have no route on a phone at all (none of them are in the phone toolbar).

Three things it had to get right, since the listener sits on the surface Moveable drags on: movement cancels it, a second finger cancels it, and the release is cancelled so it can't fire the row that appeared under the finger. There's a test asserting nothing is duplicated or deleted by the lift.

The second commit carries @7jameslondon's review from #271 across to this gesture — touch events instead of pointer events, cancelling at source instead of a timed suppression window.

Stacked on #275. One thing worth a real device: whether -webkit-touch-callout: none fully suppresses the callout over rendered slide text on actual iOS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant