ink drawing overlay

This commit is contained in:
2026-02-12 10:27:08 +02:00
parent ec11ac44b0
commit 5c02bb7d1c
+237
View File
@@ -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} />
</>
)
}