From 9669f7dc0e691a3b6faa55c85c7a64e72a73cf5e Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 10:46:50 +0100 Subject: [PATCH 1/2] fix: open image previews from touchend so iOS taps don't raise the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preventing pointerdown (#253) suppresses the compatibility mouse events, but iOS WebKit's tap-to-focus is a native gesture default action: tapping an image preview still focuses the surrounding contenteditable and briefly raises the software keyboard before the click handler opens its surface (e.g. a lightbox). Only cancelling the tap's touchend stops it — which also suppresses the synthetic click, so the image click handler now tracks single-finger taps on previews itself (movement tolerance, resize-handle and multi-touch exclusions) and fires onClick from touchend. ImageClickPayload.event widens to MouseEvent | TouchEvent accordingly. Co-Authored-By: Claude Fable 5 --- packages/core/src/extensions/image-click.ts | 108 ++++++++++++++++++-- packages/core/src/extensions/image.test.ts | 87 ++++++++++++++++ 2 files changed, 185 insertions(+), 10 deletions(-) diff --git a/packages/core/src/extensions/image-click.ts b/packages/core/src/extensions/image-click.ts index b1c60b21..46caa00c 100644 --- a/packages/core/src/extensions/image-click.ts +++ b/packages/core/src/extensions/image-click.ts @@ -1,5 +1,6 @@ import { definePlugin, type PlainExtension } from '@prosekit/core' import { Plugin, PluginKey, type EditorState } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' import { getMarkRangeAt } from './get-mark-range-at.ts' import type { MdImageAttrs } from './inline-marks.ts' @@ -24,23 +25,83 @@ function findImageAt(state: EditorState, pos: number): ImageHit | undefined { return { from: range.from, to: range.to, src, alt } } +/** + * Resolve the image hit for a preview element via its content holder, not the + * event's document position: an event on the non-editable preview lands on the + * run boundary, where `getMarkRange` would pick the next adjacent image. + */ +function findImageForPreview(view: EditorView, preview: HTMLElement): ImageHit | undefined { + const content = preview.closest('.md-image-view')?.querySelector('.md-image-view-content') + if (!content) return + return findImageAt(view.state, view.posAtDOM(content, 0)) +} + /** Payload for {@link ImageClickHandler}. */ export interface ImageClickPayload { /** The markdown `src`, exactly as written in `![alt](src)`. */ src: string /** The image alt text. */ alt: string - /** The originating click. Read modifier keys or position a popover from it. */ - event: MouseEvent + /** + * The originating click or touch tap. Read the target or position a popover + * from it; a touch surface delivers the `touchend` instead of a click. + */ + event: MouseEvent | TouchEvent } export type ImageClickHandler = (payload: ImageClickPayload) => void +/** A touch that might become a tap on an image preview. */ +interface PendingTap { + identifier: number + clientX: number + clientY: number +} + +/** Fingers wander a little during a tap; past this it is a scroll or a drag. */ +const TAP_MOVE_TOLERANCE = 10 + +function findTouch(touches: TouchList, identifier: number): Touch | undefined { + return Array.from(touches).find((touch) => touch.identifier === identifier) +} + +function isWithinTapTolerance(pending: PendingTap, touch: Touch): boolean { + return ( + Math.abs(touch.clientX - pending.clientX) <= TAP_MOVE_TOLERANCE && + Math.abs(touch.clientY - pending.clientY) <= TAP_MOVE_TOLERANCE + ) +} + /** - * Call `onClick` when the user clicks a rendered image preview, with the - * image's markdown `src`, `alt`, and the originating `MouseEvent`. + * Call `onClick` when the user clicks or taps a rendered image preview, with + * the image's markdown `src`, `alt`, and the originating event. + * + * Touch taps are handled from `touchend` rather than the synthetic click: + * previews live inside the editor contenteditable, and iOS WebKit's + * tap-to-focus is a native gesture default action that only cancelling the + * `touchend` can suppress — otherwise a tap briefly focuses the editor and + * raises the software keyboard before the handler opens its own surface + * (such as a lightbox). */ export function defineImageClickHandler(onClick: ImageClickHandler): PlainExtension { + const pendingTaps = new WeakMap() + + const handleTouchEnd = (view: EditorView, event: TouchEvent): boolean => { + const pending = pendingTaps.get(view) + pendingTaps.delete(view) + if (!pending || event.touches.length > 0) return false + const touch = findTouch(event.changedTouches, pending.identifier) + if (!touch || !isWithinTapTolerance(pending, touch)) return false + const preview = getClosestImagePreview(event.target) + if (!preview) return false + // Cancelling the touchend also suppresses the synthetic click, so the + // handler fires here instead of in handleClick. + event.preventDefault() + const hit = findImageForPreview(view, preview) + if (hit) onClick({ src: hit.src, alt: hit.alt, event }) + return true + } + return definePlugin( new Plugin({ key: imageClickKey, @@ -56,16 +117,43 @@ export function defineImageClickHandler(onClick: ImageClickHandler): PlainExtens } return false }, + touchstart: (view, event) => { + pendingTaps.delete(view) + if (event.touches.length !== 1) return false + if (!getClosestImagePreview(event.target)) return false + // A touch on the resize handle starts a resize, never a tap. + if ( + event.target instanceof HTMLElement && + event.target.closest('.md-image-resize-handle') + ) { + return false + } + const touch = event.changedTouches[0] + if (!touch) return false + pendingTaps.set(view, { + identifier: touch.identifier, + clientX: touch.clientX, + clientY: touch.clientY, + }) + return false + }, + touchmove: (view, event) => { + const pending = pendingTaps.get(view) + if (!pending) return false + const touch = findTouch(event.changedTouches, pending.identifier) + if (touch && !isWithinTapTolerance(pending, touch)) pendingTaps.delete(view) + return false + }, + touchcancel: (view) => { + pendingTaps.delete(view) + return false + }, + touchend: handleTouchEnd, }, handleClick: (view, _pos, event) => { const preview = getClosestImagePreview(event.target) if (!preview) return false - // Resolve the position from the preview's own content holder, not the - // click's `pos`: a click on the non-editable preview lands on the run - // boundary, where `getMarkRange` would pick the next adjacent image. - const content = preview.closest('.md-image-view')?.querySelector('.md-image-view-content') - if (!content) return false - const hit = findImageAt(view.state, view.posAtDOM(content, 0)) + const hit = findImageForPreview(view, preview) if (!hit) return false onClick({ src: hit.src, alt: hit.alt, event }) return true diff --git a/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index c511dc03..b9a2eaa5 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -169,6 +169,93 @@ describe('image click callback', () => { await vi.waitFor(() => expect(onImageClick).toHaveBeenCalledTimes(1)) }) + // Build the touchstart/touchend pair of a stationary single-finger tap. + function buildTap( + target: Element, + options: { endX?: number; endY?: number } = {}, + ): { start: TouchEvent; end: TouchEvent } { + const rect = target.getBoundingClientRect() + const startX = rect.left + rect.width / 2 + const startY = rect.top + rect.height / 2 + const touchAt = (clientX: number, clientY: number): Touch => + new Touch({ identifier: 7, target, clientX, clientY }) + const startTouch = touchAt(startX, startY) + const endTouch = touchAt(options.endX ?? startX, options.endY ?? startY) + return { + start: new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [startTouch], + targetTouches: [startTouch], + changedTouches: [startTouch], + }), + end: new TouchEvent('touchend', { + bubbles: true, + cancelable: true, + touches: [], + targetTouches: [], + changedTouches: [endTouch], + }), + } + } + + // iOS WebKit focuses the surrounding contenteditable (raising the software + // keyboard) unless the tap's touchend is cancelled, so touch taps fire the + // handler from touchend instead of the synthetic click. + it('fires on a touch tap and cancels the touchend', async () => { + const onImageClick = vi.fn() + using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) + void fixture + await expect.element(preview).toBeInTheDocument() + + const tap = buildTap(preview.element()) + preview.element().dispatchEvent(tap.start) + preview.element().dispatchEvent(tap.end) + + expect(tap.end.defaultPrevented).toBe(true) + await vi.waitFor(() => { + expect(onImageClick).toHaveBeenCalledWith( + expect.objectContaining({ src: 'https://example.com/cat.png', alt: 'cat' }), + ) + }) + expect(onImageClick).toHaveBeenCalledTimes(1) + expect(onImageClick.mock.calls[0][0].event).toBeInstanceOf(TouchEvent) + }) + + it('ignores a touch that moved too far to be a tap', async () => { + const onImageClick = vi.fn() + using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) + void fixture + await expect.element(preview).toBeInTheDocument() + + const rect = preview.element().getBoundingClientRect() + const tap = buildTap(preview.element(), { + endX: rect.left + rect.width / 2, + endY: rect.top + rect.height / 2 + 40, + }) + preview.element().dispatchEvent(tap.start) + preview.element().dispatchEvent(tap.end) + + expect(tap.end.defaultPrevented).toBe(false) + expect(onImageClick).not.toHaveBeenCalled() + }) + + it('leaves a touch tap on the resize handle alone', async () => { + const onImageClick = vi.fn() + using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) + void fixture + await expect.element(preview).toBeInTheDocument() + + const handle = preview.element().querySelector('.md-image-resize-handle') + if (!handle) throw new Error('missing resize handle') + const tap = buildTap(handle) + handle.dispatchEvent(tap.start) + handle.dispatchEvent(tap.end) + + expect(tap.end.defaultPrevented).toBe(false) + expect(onImageClick).not.toHaveBeenCalled() + }) + it('leaves mouse pointerdown on clickable previews alone', async () => { const onImageClick = vi.fn() using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) From 2d6eba647611708178ec677992cbdc460d37898b Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 11:09:12 +0100 Subject: [PATCH 2/2] test: skip touch-tap construction in desktop WebKit Desktop WebKit exposes the Touch interface but its constructor throws ("Illegal constructor"); the tap logic stays covered by chromium and firefox. Co-Authored-By: Claude Fable 5 --- packages/core/src/extensions/image.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index b9a2eaa5..e4fd1293 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -1,3 +1,4 @@ +import { isSafari } from '@meowdown/vitest/helpers' import { NodeSelection } from '@prosekit/pm/state' import { describe, expect, it, vi } from 'vitest' import { page, userEvent } from 'vitest/browser' @@ -199,10 +200,14 @@ describe('image click callback', () => { } } + // Desktop WebKit exposes the Touch interface but its constructor throws + // ("Illegal constructor"), so the tap tests run in chromium and firefox only. + const cannotConstructTouch = isSafari() + // iOS WebKit focuses the surrounding contenteditable (raising the software // keyboard) unless the tap's touchend is cancelled, so touch taps fire the // handler from touchend instead of the synthetic click. - it('fires on a touch tap and cancels the touchend', async () => { + it.skipIf(cannotConstructTouch)('fires on a touch tap and cancels the touchend', async () => { const onImageClick = vi.fn() using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) void fixture @@ -222,7 +227,7 @@ describe('image click callback', () => { expect(onImageClick.mock.calls[0][0].event).toBeInstanceOf(TouchEvent) }) - it('ignores a touch that moved too far to be a tap', async () => { + it.skipIf(cannotConstructTouch)('ignores a touch that moved too far to be a tap', async () => { const onImageClick = vi.fn() using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) void fixture @@ -240,7 +245,7 @@ describe('image click callback', () => { expect(onImageClick).not.toHaveBeenCalled() }) - it('leaves a touch tap on the resize handle alone', async () => { + it.skipIf(cannotConstructTouch)('leaves a touch tap on the resize handle alone', async () => { const onImageClick = vi.fn() using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick) void fixture