From 5c02bb7d1cf6bec556f98ff91c4ea5259724eeea Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 12 Feb 2026 10:27:08 +0200 Subject: [PATCH] ink drawing overlay --- frontend/src/components/InkOverlay.tsx | 237 +++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 frontend/src/components/InkOverlay.tsx diff --git a/frontend/src/components/InkOverlay.tsx b/frontend/src/components/InkOverlay.tsx new file mode 100644 index 0000000..39725b8 --- /dev/null +++ b/frontend/src/components/InkOverlay.tsx @@ -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 +} + +// 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(null) + const scribbleTrapRef = useRef(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('.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' && ( +