Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 98 additions & 10 deletions packages/core/src/extensions/image-click.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<EditorView, PendingTap>()

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,
Expand All @@ -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
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/extensions/image.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -169,6 +170,97 @@ 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],
}),
}
}

// 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.skipIf(cannotConstructTouch)('fires on a touch tap and cancels the touchend', async () => {
const onImageClick = vi.fn<ImageClickHandler>()
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.skipIf(cannotConstructTouch)('ignores a touch that moved too far to be a tap', async () => {
const onImageClick = vi.fn<ImageClickHandler>()
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.skipIf(cannotConstructTouch)('leaves a touch tap on the resize handle alone', async () => {
const onImageClick = vi.fn<ImageClickHandler>()
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<ImageClickHandler>()
using fixture = setupClickable('![cat](https://example.com/cat.png)', onImageClick)
Expand Down
Loading