ink drawing overlay
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from '../store'
|
||||
import DisambiguationPopup from './DisambiguationPopup'
|
||||
|
||||
interface Props {
|
||||
svgRef: React.RefObject<SVGSVGElement | null>
|
||||
}
|
||||
|
||||
// iPadOS Scribble prevention -
|
||||
//
|
||||
// Scribble intercepts Apple Pencil touches at the OS compositing level,
|
||||
// BEFORE the web engine dispatches pointer/touch events. It probes the
|
||||
// DOM looking for editable targets (input, textarea, contentEditable).
|
||||
// When it claims a stroke, Safari never fires pointerdown at all - the
|
||||
// event just vanishes.
|
||||
//
|
||||
// No single CSS property or JS handler can fully stop it because it
|
||||
// operates above the event dispatch layer. The working defense, derived
|
||||
// from reading tldraw and excalidraw source, Apple Developer Forums
|
||||
// threads, and empirical testing, is a layered approach:
|
||||
//
|
||||
// Layer 1: CSS - touch-action:none, user-select:none, contain:strict
|
||||
// on the canvas and the wrapper. These prevent Safari's own
|
||||
// text-selection and gesture-recognition from firing.
|
||||
//
|
||||
// Layer 2: Event interception - preventDefault on touchstart/touchmove/
|
||||
// touchend with passive:false on the WRAPPER element (not just
|
||||
// the canvas). Scribble's probe touches arrive as touch events
|
||||
// first; canceling them at the wrapper level stops the
|
||||
// escalation to text recognition in many cases.
|
||||
//
|
||||
// Layer 3: Pointer capture - setPointerCapture on pointerdown with
|
||||
// pointerType==='pen'. Once captured, the element owns all
|
||||
// subsequent events for that pointer, preventing the OS from
|
||||
// re-routing them to Scribble.
|
||||
//
|
||||
// Layer 4: Selection cleanup - a pointerdown handler on document that
|
||||
// clears any window.getSelection() when a pen stroke starts
|
||||
// inside the grid, and a selectstart handler that cancels
|
||||
// selection originating from pen input.
|
||||
//
|
||||
// Layer 5: Scribble-trap textarea - a hidden, zero-opacity textarea
|
||||
// positioned behind the canvas. Scribble preferentially targets
|
||||
// editable elements; this gives it something to latch onto
|
||||
// instead of probing the broader DOM. The textarea is readonly
|
||||
// and intercepts any input Scribble tries to inject.
|
||||
|
||||
export default function InkOverlay({ svgRef }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const gridCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const scribbleTrapRef = useRef<HTMLTextAreaElement>(null)
|
||||
const puzzle = useStore((s) => s.puzzle)
|
||||
const inputMode = useStore((s) => s.inputMode)
|
||||
const selectCell = useStore((s) => s.selectCell)
|
||||
|
||||
const lastTapTime = useRef(0)
|
||||
const lastTapCell = useRef('')
|
||||
|
||||
// sync grid canvas size to SVG
|
||||
const syncSize = useCallback(() => {
|
||||
const svg = svgRef.current
|
||||
const canvas = gridCanvasRef.current
|
||||
if (!svg || !canvas) return
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const dpr = devicePixelRatio || 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
canvas.style.width = rect.width + 'px'
|
||||
canvas.style.height = rect.height + 'px'
|
||||
}, [svgRef])
|
||||
|
||||
useEffect(() => {
|
||||
syncSize()
|
||||
const svg = svgRef.current
|
||||
if (!svg) return
|
||||
const observer = new ResizeObserver(syncSize)
|
||||
observer.observe(svg)
|
||||
window.addEventListener('resize', syncSize)
|
||||
return () => { observer.disconnect(); window.removeEventListener('resize', syncSize) }
|
||||
}, [svgRef, syncSize, puzzle])
|
||||
|
||||
// Layer 2+3+4: event interception on the wrapper AND canvas
|
||||
useEffect(() => {
|
||||
const canvas = gridCanvasRef.current
|
||||
if (!canvas || !puzzle) return
|
||||
if (inputMode === 'keyboard') return
|
||||
|
||||
// get the grid-wrapper parent for wrapper-level interception
|
||||
const wrapper = canvas.closest<HTMLElement>('.grid-wrapper')
|
||||
|
||||
function onGridPointerDown(e: PointerEvent) {
|
||||
// keyboard mode disables the overlay entirely; auto and stylus both
|
||||
// accept pen, touch, and mouse input on the canvas
|
||||
if (e.pointerType === 'mouse' && inputMode === 'keyboard') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Layer 3: capture the pointer so the OS can't re-route it for pen;
|
||||
// mouse capture is harmless and keeps click handling on this element
|
||||
try { canvas!.setPointerCapture(e.pointerId) } catch { /* ignored */ }
|
||||
|
||||
// Layer 4: kill any text selection Scribble may have started
|
||||
try { window.getSelection()?.removeAllRanges() } catch { /* ignored */ }
|
||||
|
||||
const rect = canvas!.getBoundingClientRect()
|
||||
const cellW = rect.width / puzzle!.width
|
||||
const cellH = rect.height / puzzle!.height
|
||||
const c = Math.floor((e.clientX - rect.left) / cellW)
|
||||
const r = Math.floor((e.clientY - rect.top) / cellH)
|
||||
|
||||
if (r < 0 || r >= puzzle!.height || c < 0 || c >= puzzle!.width) return
|
||||
if (puzzle!.cells[r][c] === 'black') return
|
||||
|
||||
// double-tap to delete
|
||||
const now = Date.now()
|
||||
const cellKey = `${r},${c}`
|
||||
if (now - lastTapTime.current < 300 && lastTapCell.current === cellKey) {
|
||||
useStore.getState().deleteLetter()
|
||||
lastTapTime.current = 0
|
||||
return
|
||||
}
|
||||
lastTapTime.current = now
|
||||
lastTapCell.current = cellKey
|
||||
|
||||
selectCell(r, c)
|
||||
}
|
||||
|
||||
// prevent default on pointer move/up too - Scribble can start
|
||||
// recognition mid-stroke if it gets a move event it can interpret
|
||||
function onPenPointerMove(e: PointerEvent) {
|
||||
if (e.pointerType !== 'pen') return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onPenPointerUp(e: PointerEvent) {
|
||||
if (e.pointerType !== 'pen') return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// Layer 2: prevent touch events at the wrapper level.
|
||||
// iPadOS Scribble sends touch events before pointer events;
|
||||
// canceling them here stops the text-recognition probe.
|
||||
function preventTouch(e: TouchEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// Layer 4: cancel text selection from pen input
|
||||
function preventSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// attach to canvas
|
||||
canvas.addEventListener('pointerdown', onGridPointerDown)
|
||||
canvas.addEventListener('pointermove', onPenPointerMove)
|
||||
canvas.addEventListener('pointerup', onPenPointerUp)
|
||||
canvas.addEventListener('touchstart', preventTouch, { passive: false })
|
||||
canvas.addEventListener('touchmove', preventTouch, { passive: false })
|
||||
canvas.addEventListener('touchend', preventTouch, { passive: false })
|
||||
canvas.addEventListener('selectstart', preventSelect)
|
||||
|
||||
// Layer 4: document-level pen selection cleanup
|
||||
function onDocPointerDown(e: PointerEvent) {
|
||||
if (e.pointerType !== 'pen') return
|
||||
const target = e.target
|
||||
if (target instanceof Node && !(canvas !== null && canvas.contains(target)) && !(wrapper !== null && wrapper.contains(target))) return
|
||||
try { window.getSelection()?.removeAllRanges() } catch { /* ignored */ }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocPointerDown, true)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('pointerdown', onGridPointerDown)
|
||||
canvas.removeEventListener('pointermove', onPenPointerMove)
|
||||
canvas.removeEventListener('pointerup', onPenPointerUp)
|
||||
canvas.removeEventListener('touchstart', preventTouch)
|
||||
canvas.removeEventListener('touchmove', preventTouch)
|
||||
canvas.removeEventListener('touchend', preventTouch)
|
||||
canvas.removeEventListener('selectstart', preventSelect)
|
||||
document.removeEventListener('pointerdown', onDocPointerDown, true)
|
||||
}
|
||||
}, [gridCanvasRef, puzzle, inputMode, selectCell])
|
||||
|
||||
// Layer 5: keep the scribble trap textarea empty and unfocusable
|
||||
useEffect(() => {
|
||||
const trap = scribbleTrapRef.current
|
||||
if (!trap) return
|
||||
function onInput() { trap!.value = '' }
|
||||
function onFocus() { trap!.blur() }
|
||||
trap.addEventListener('input', onInput)
|
||||
trap.addEventListener('focus', onFocus)
|
||||
return () => {
|
||||
trap.removeEventListener('input', onInput)
|
||||
trap.removeEventListener('focus', onFocus)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!puzzle) return null
|
||||
|
||||
const pointerEvents = inputMode === 'keyboard' ? 'none' : 'auto'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Layer 5: scribble trap - gives iPadOS Scribble a target to latch
|
||||
onto instead of probing the rest of the DOM. Positioned behind
|
||||
the canvas, readonly, zero-opacity, with attributes that tell
|
||||
the browser this is not a real input target. */}
|
||||
{inputMode === 'stylus' && (
|
||||
<textarea
|
||||
ref={scribbleTrapRef}
|
||||
className="scribble-trap"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
inputMode="none"
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={gridCanvasRef}
|
||||
className="ink-overlay"
|
||||
aria-label={t('grid.handwritingCanvas')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
touchAction: 'none',
|
||||
pointerEvents,
|
||||
contain: 'strict',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<DisambiguationPopup svgRef={svgRef} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user