Fix Apple Pencil double-tap bug: switch to Touch Events API on iOS
iPadOS Pointer Events fire pointercancel immediately after pointerdown for Apple Pencil strokes (triggered by palm detection or canvas-width resets from React re-renders), forcing the user to tap twice to start every stroke. The Touch Events API with touchType === 'stylus' bypasses this issue entirely — Touch Events have no equivalent cancellation problem and reliably identify Pencil vs finger input. Pointer Events remain as the fallback for non-iOS platforms. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+96
-32
@@ -68,42 +68,11 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Use native addEventListener (not React synthetic events) so that
|
|
||||||
// passive:false works correctly on iOS Safari and events are handled
|
|
||||||
// directly on the canvas element rather than via document-root delegation.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
function down(e) {
|
function commitStroke() {
|
||||||
if (e.pointerType === 'touch') return;
|
|
||||||
isDrawingRef.current = true;
|
|
||||||
activeStrokeRef.current = [getPoint(e)];
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function move(e) {
|
|
||||||
if (e.pointerType === 'touch') return;
|
|
||||||
if (!isDrawingRef.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const pt = getPoint(e);
|
|
||||||
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
|
||||||
if (drawToolRef.current === 'eraser') {
|
|
||||||
const [ex, ey] = pt;
|
|
||||||
const r = drawSizeRef.current * 3;
|
|
||||||
const remaining = strokesRef.current.filter(
|
|
||||||
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
|
|
||||||
);
|
|
||||||
if (remaining.length !== strokesRef.current.length)
|
|
||||||
onStrokesChangeRef.current(remaining);
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function up(e) {
|
|
||||||
if (e.pointerType === 'touch') return;
|
|
||||||
if (!isDrawingRef.current) return;
|
|
||||||
isDrawingRef.current = false;
|
|
||||||
const pts = activeStrokeRef.current;
|
const pts = activeStrokeRef.current;
|
||||||
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
|
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
|
||||||
onStrokesChangeRef.current([
|
onStrokesChangeRef.current([
|
||||||
@@ -118,9 +87,104 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
activeStrokeRef.current = [];
|
activeStrokeRef.current = [];
|
||||||
|
isDrawingRef.current = false;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eraseAt(pt) {
|
||||||
|
const [ex, ey] = pt;
|
||||||
|
const r = drawSizeRef.current * 3;
|
||||||
|
const remaining = strokesRef.current.filter(
|
||||||
|
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
|
||||||
|
);
|
||||||
|
if (remaining.length !== strokesRef.current.length)
|
||||||
|
onStrokesChangeRef.current(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'.
|
||||||
|
// Pointer Events on iPadOS emit pointercancel immediately after pointerdown
|
||||||
|
// (palm detection or canvas-width resets from React re-renders), which forces
|
||||||
|
// a double-tap to start every stroke. Touch Events bypass this entirely.
|
||||||
|
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
|
||||||
|
function pointFromTouch(t) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return [
|
||||||
|
(t.clientX - rect.left) / rect.width,
|
||||||
|
(t.clientY - rect.top) / rect.height,
|
||||||
|
t.force ?? 0.5,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tStart(e) {
|
||||||
|
for (const t of e.changedTouches) {
|
||||||
|
if (t.touchType !== 'stylus') continue;
|
||||||
|
e.preventDefault();
|
||||||
|
isDrawingRef.current = true;
|
||||||
|
activeStrokeRef.current = [pointFromTouch(t)];
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tMove(e) {
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
for (const t of e.changedTouches) {
|
||||||
|
if (t.touchType !== 'stylus') continue;
|
||||||
|
e.preventDefault();
|
||||||
|
const pt = pointFromTouch(t);
|
||||||
|
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
||||||
|
if (drawToolRef.current === 'eraser') eraseAt(pt);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tEnd(e) {
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
for (const t of e.changedTouches) {
|
||||||
|
if (t.touchType !== 'stylus') continue;
|
||||||
|
e.preventDefault();
|
||||||
|
commitStroke();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('touchstart', tStart, { passive: false });
|
||||||
|
canvas.addEventListener('touchmove', tMove, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', tEnd, { passive: false });
|
||||||
|
canvas.addEventListener('touchcancel', tEnd, { passive: false });
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('touchstart', tStart);
|
||||||
|
canvas.removeEventListener('touchmove', tMove);
|
||||||
|
canvas.removeEventListener('touchend', tEnd);
|
||||||
|
canvas.removeEventListener('touchcancel', tEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-iOS: Pointer Events (mouse, Windows pen, etc.)
|
||||||
|
function down(e) {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
isDrawingRef.current = true;
|
||||||
|
activeStrokeRef.current = [getPoint(e)];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(e) {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const pt = getPoint(e);
|
||||||
|
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
||||||
|
if (drawToolRef.current === 'eraser') eraseAt(pt);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function up(e) {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
commitStroke();
|
||||||
|
}
|
||||||
|
|
||||||
canvas.addEventListener('pointerdown', down);
|
canvas.addEventListener('pointerdown', down);
|
||||||
canvas.addEventListener('pointermove', move, { passive: false });
|
canvas.addEventListener('pointermove', move, { passive: false });
|
||||||
canvas.addEventListener('pointerup', up);
|
canvas.addEventListener('pointerup', up);
|
||||||
|
|||||||
Reference in New Issue
Block a user