mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-09 19:37:56 +08:00
update ui
This commit is contained in:
@@ -84,7 +84,7 @@ export function App() {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<header style={styles.header}>
|
||||
<span style={styles.logo}>◈ QR-over-GIF</span>
|
||||
<span style={styles.logo}>◈ RaptorQR</span>
|
||||
<nav style={styles.tabBar}>
|
||||
<button
|
||||
style={{ ...styles.tab, ...(tab === 'sender' ? styles.tabActive : {}) }}
|
||||
|
||||
@@ -194,6 +194,17 @@ const S = {
|
||||
resize: 'vertical' as const,
|
||||
minHeight: 120,
|
||||
} as CSSProps,
|
||||
hiddenInput: {
|
||||
display: 'none',
|
||||
} as CSSProps,
|
||||
fileName: {
|
||||
color: '#c9d1d9',
|
||||
fontSize: 14,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
} as CSSProps,
|
||||
checkboxLabel: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -211,6 +222,8 @@ export function ReceiverPage() {
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const textResultRef = useRef<HTMLDivElement>(null);
|
||||
const fileResultRef = useRef<HTMLDivElement>(null);
|
||||
const gifFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const copyResetTimerRef = useRef<number | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
@@ -227,6 +240,8 @@ export function ReceiverPage() {
|
||||
const [neededPackets, setNeededPackets] = useState(0);
|
||||
const [receivedFile, setReceivedFile] = useState<ReceivedFile | null>(null);
|
||||
const [receivedText, setReceivedText] = useState('');
|
||||
const [textCopied, setTextCopied] = useState(false);
|
||||
const [gifFileName, setGifFileName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [hasZoomSupport, setHasZoomSupport] = useState(false);
|
||||
@@ -340,6 +355,7 @@ export function ReceiverPage() {
|
||||
case 'complete': {
|
||||
if (msg.isText) {
|
||||
setReceivedText(msg.text);
|
||||
setTextCopied(false);
|
||||
setReceivedFile(null);
|
||||
} else {
|
||||
setReceivedFile({
|
||||
@@ -348,6 +364,7 @@ export function ReceiverPage() {
|
||||
mime: msg.mime ?? 'application/octet-stream',
|
||||
});
|
||||
setReceivedText('');
|
||||
setTextCopied(false);
|
||||
}
|
||||
const elapsed = scanStartRef.current > 0 ? Date.now() - scanStartRef.current : 0;
|
||||
setElapsedMs(elapsed);
|
||||
@@ -448,6 +465,7 @@ export function ReceiverPage() {
|
||||
setError('');
|
||||
setReceivedFile(null);
|
||||
setReceivedText('');
|
||||
setTextCopied(false);
|
||||
setTotalFrames(0);
|
||||
setFramesWithQR(0);
|
||||
setUniquePackets(0);
|
||||
@@ -530,9 +548,11 @@ export function ReceiverPage() {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setGifFileName(`${file.name} · ${formatBytes(file.size)}`);
|
||||
setError('');
|
||||
setReceivedFile(null);
|
||||
setReceivedText('');
|
||||
setTextCopied(false);
|
||||
setTotalFrames(0);
|
||||
setFramesWithQR(0);
|
||||
setUniquePackets(0);
|
||||
@@ -666,6 +686,30 @@ export function ReceiverPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
}, [receivedFile]);
|
||||
|
||||
const handleChooseGifFile = useCallback(() => {
|
||||
gifFileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleCopyRecoveredText = useCallback(async () => {
|
||||
if (!receivedText) return;
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(receivedText);
|
||||
setTextCopied(true);
|
||||
|
||||
if (copyResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
|
||||
copyResetTimerRef.current = window.setTimeout(() => {
|
||||
setTextCopied(false);
|
||||
copyResetTimerRef.current = null;
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(`Copy failed: ${err.message ?? String(err)}`);
|
||||
}
|
||||
}, [receivedText]);
|
||||
|
||||
// ── Cleanup on unmount ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -676,6 +720,9 @@ export function ReceiverPage() {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
}
|
||||
if (copyResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -974,9 +1021,28 @@ export function ReceiverPage() {
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Upload GIF</div>
|
||||
<p style={{ fontSize: 13, color: '#8b949e', marginBottom: 8 }}>
|
||||
Upload a QR-over-GIF file generated by the Sender.
|
||||
Upload a RaptorQR file generated by the Sender.
|
||||
</p>
|
||||
<input type="file" accept=".gif,image/gif" onChange={handleGifFile} />
|
||||
<div style={S.row}>
|
||||
<input
|
||||
ref={gifFileInputRef}
|
||||
type="file"
|
||||
accept=".gif,image/gif"
|
||||
style={S.hiddenInput}
|
||||
onChange={handleGifFile}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={S.btnSecondary}
|
||||
disabled={scanning}
|
||||
onClick={handleChooseGifFile}
|
||||
>
|
||||
Choose GIF
|
||||
</button>
|
||||
<span style={S.fileName} title={gifFileName}>
|
||||
{scanning ? 'Processing GIF…' : gifFileName || 'No GIF selected'}
|
||||
</span>
|
||||
</div>
|
||||
{scanning && (
|
||||
<div style={S.statsBar}>
|
||||
<span>
|
||||
@@ -1051,7 +1117,16 @@ export function ReceiverPage() {
|
||||
{/* ── Received text ────────────────────────────────────────────────────────────────── */}
|
||||
{receivedText && (
|
||||
<div style={S.section} ref={textResultRef}>
|
||||
<div style={S.label}>Recovered Text</div>
|
||||
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ ...S.label, marginBottom: 0 }}>Recovered Text</div>
|
||||
<button
|
||||
type="button"
|
||||
style={S.btnSecondary}
|
||||
onClick={handleCopyRecoveredText}
|
||||
>
|
||||
{textCopied ? 'Copied' : 'Copy text'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
style={S.textarea}
|
||||
value={receivedText}
|
||||
@@ -1086,6 +1161,30 @@ function formatBytes(n: number): string {
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
if (!document.execCommand('copy')) {
|
||||
throw new Error('Clipboard command was rejected.');
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const s = Math.floor(ms / 1000);
|
||||
|
||||
@@ -205,6 +205,17 @@ const S = {
|
||||
padding: '9px 10px',
|
||||
fontSize: 14,
|
||||
} as CSSProps,
|
||||
hiddenInput: {
|
||||
display: 'none',
|
||||
} as CSSProps,
|
||||
fileName: {
|
||||
color: '#c9d1d9',
|
||||
fontSize: 14,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
} as CSSProps,
|
||||
warn: {
|
||||
background: '#3d2600',
|
||||
border: '1px solid #bb8009',
|
||||
@@ -280,6 +291,7 @@ export function SenderPage() {
|
||||
const [fullscreenActive, setFullscreenActive] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const qrStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const liveTransferRef = useRef<LiveTransfer | null>(null);
|
||||
const encodeWorkerRef = useRef<Worker | null>(null);
|
||||
@@ -697,6 +709,10 @@ export function SenderPage() {
|
||||
resetOutput();
|
||||
}, [resetOutput]);
|
||||
|
||||
const handleChooseFile = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleQRVersionChange = useCallback((value: string) => {
|
||||
setQrVersion(parseQRVersionOption(value));
|
||||
resetOutput();
|
||||
@@ -974,8 +990,24 @@ export function SenderPage() {
|
||||
onInput={(e) => handleTextChange((e.target as HTMLTextAreaElement).value)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<input type="file" onChange={handleFile} />
|
||||
<div style={{ ...S.row, marginTop: 10 }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={S.hiddenInput}
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={S.btnSecondary}
|
||||
disabled={encodingLive}
|
||||
onClick={handleChooseFile}
|
||||
>
|
||||
Choose file
|
||||
</button>
|
||||
<span style={S.fileName} title={file?.name ?? ''}>
|
||||
{file ? `${file.name} · ${formatBytes(file.size)}` : 'No file selected'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user