Use real device rotation for signature overlay

CSS transform: rotate(90deg) only rotates the visual; pointer events
still fire in the unrotated viewport coordinate system, and
canvas.getBoundingClientRect returns the rotated bounding box with
swapped width/height. signature_pad computed stroke positions from
clientX/clientY minus that swapped rect, so most strokes landed
outside the canvas drawing buffer. The brief flash of marks visible
when the phone was rotated back to portrait was the small fraction of
points that happened to land inside the canvas, revealed once the CSS
rotation was undone.

Drop the CSS fake-landscape and gate the signature pad behind real
device orientation: portrait shows a rotate-your-phone prompt, and
the pad only renders in landscape where the coordinate system is
clean. Attempt screen.orientation.lock('landscape') where supported;
iOS users with portrait lock see the prompt with a hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frudrax Cheng
2026-05-27 09:17:56 +08:00
parent ab5acbc452
commit 0f6d4a5f07
2 changed files with 73 additions and 22 deletions
+35 -8
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from 'antd';
import { RotateRightOutlined } from '@ant-design/icons';
import SignaturePad, { type SignaturePadHandle } from './SignaturePad';
import './SignatureOverlay.css';
@@ -9,26 +10,38 @@ interface SignatureOverlayProps {
onConfirm: (dataUrl: string) => void;
}
const isLandscapeNow = () =>
typeof window !== 'undefined' && window.matchMedia('(orientation: landscape)').matches;
function SignatureOverlay({ open, onCancel, onConfirm }: SignatureOverlayProps) {
const padRef = useRef<SignaturePadHandle>(null);
const [data, setData] = useState('');
const [, setData] = useState('');
const [landscape, setLandscape] = useState<boolean>(isLandscapeNow);
useEffect(() => {
if (!open) return;
const mq = window.matchMedia('(orientation: landscape)');
const update = () => setLandscape(mq.matches);
update();
mq.addEventListener('change', update);
const orientation = (screen as Screen & {
orientation?: { lock?: (o: string) => Promise<void> };
orientation?: { lock?: (o: string) => Promise<void>; unlock?: () => void };
}).orientation;
if (orientation?.lock) {
orientation.lock('landscape').catch(() => {
// ignore; iOS Safari etc. don't permit lock outside fullscreen
// ignore; iOS Safari and most browsers refuse outside fullscreen
});
}
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
mq.removeEventListener('change', update);
document.body.style.overflow = prevOverflow;
orientation?.unlock?.();
};
}, [open]);
@@ -40,15 +53,29 @@ function SignatureOverlay({ open, onCancel, onConfirm }: SignatureOverlayProps)
};
const handleConfirm = () => {
if (padRef.current?.isEmpty()) return;
const url = padRef.current?.getDataURL() || data;
if (!url) return;
onConfirm(url);
const pad = padRef.current;
if (!pad || pad.isEmpty()) return;
onConfirm(pad.getDataURL());
};
return (
<div className="signature-overlay" role="dialog" aria-modal="true">
<div className="signature-overlay-stage">
{!landscape && (
<div className="signature-overlay-rotate">
<RotateRightOutlined className="signature-overlay-rotate-icon" />
<p className="signature-overlay-rotate-text"></p>
<p className="signature-overlay-rotate-hint">
</p>
<Button onClick={onCancel}></Button>
</div>
)}
{/* SignaturePad stays mounted so its strokes survive accidental rotation flips */}
<div
className="signature-overlay-stage"
style={{ visibility: landscape ? 'visible' : 'hidden' }}
aria-hidden={!landscape}
>
<div className="signature-overlay-bar">
<Button type="text" onClick={onCancel}>