Import source: app/src/components/QuoteSharer
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
// QuoteSharer — renders a floating "share quote" tooltip on text selection inside the article.
|
||||
// Uses Canvas API for branded card rendering; Web Share API or download as PNG fallback.
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { contentApi } from '../lib/content-api.js';
|
||||
import shareIcon from '../assets/icons/share-2.svg';
|
||||
import clipboardIcon from '../assets/icons/clipboard-copy.svg';
|
||||
|
||||
const BRAND_BG1 = '#0C1E22';
|
||||
const BRAND_BG2 = '#0F6B73';
|
||||
const BRAND_GOLD = '#D49A2A';
|
||||
const FONT = '\'Vazirmatn\', Tahoma, sans-serif';
|
||||
|
||||
function loadFont() {
|
||||
if (typeof document === 'undefined') return Promise.resolve();
|
||||
return document.fonts.load(`700 18px Vazirmatn`).catch(() => {});
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
const words = text.split(' ').filter(Boolean);
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
const lines = [];
|
||||
for (const word of words) {
|
||||
const test = line ? `${line} ${word}` : word;
|
||||
const m = ctx.measureText(test);
|
||||
if (m.width > maxWidth && line) {
|
||||
lines.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
for (const l of lines.slice(0, 4)) {
|
||||
ctx.fillText(l, x, currentY);
|
||||
currentY += lineHeight;
|
||||
}
|
||||
return currentY;
|
||||
}
|
||||
|
||||
function renderCard(canvas, text, articleTitle, authorName) {
|
||||
const W = 1080, H = 1080;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background gradient
|
||||
const grad = ctx.createLinearGradient(0, 0, W, H);
|
||||
grad.addColorStop(0, BRAND_BG1);
|
||||
grad.addColorStop(1, '#0F3540');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Decorative corner accent
|
||||
ctx.fillStyle = BRAND_BG2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(W, 0);
|
||||
ctx.lineTo(W, 220);
|
||||
ctx.lineTo(W - 220, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Subtle grid pattern overlay
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < W; i += 60) {
|
||||
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, H); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(W, i); ctx.stroke();
|
||||
}
|
||||
|
||||
// Gold separator
|
||||
ctx.fillStyle = BRAND_GOLD;
|
||||
ctx.fillRect(80, 90, 5, 80);
|
||||
|
||||
// TEKNAV logo text
|
||||
ctx.fillStyle = BRAND_GOLD;
|
||||
ctx.font = `700 32px ${FONT}`;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.direction = 'rtl';
|
||||
ctx.fillText('تکناو', W - 60, 80);
|
||||
|
||||
// Tagline next to logo
|
||||
ctx.fillStyle = 'rgba(212,154,42,0.55)';
|
||||
ctx.font = `400 16px ${FONT}`;
|
||||
ctx.fillText('teknav.ir', W - 60, 106);
|
||||
|
||||
// Quote marks
|
||||
ctx.fillStyle = 'rgba(212,154,42,0.18)';
|
||||
ctx.font = `700 280px ${FONT}`;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('«', W - 40, 420);
|
||||
|
||||
// Quote text
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = `400 38px ${FONT}`;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.direction = 'rtl';
|
||||
const nextY = wrapText(ctx, text, W - 80, 220, W - 160, 60);
|
||||
|
||||
// Gold line
|
||||
ctx.fillStyle = BRAND_GOLD;
|
||||
ctx.fillRect(80, nextY + 20, W - 160, 2);
|
||||
|
||||
// Article title
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||||
ctx.font = `400 26px ${FONT}`;
|
||||
ctx.textAlign = 'right';
|
||||
const titleTrunc = articleTitle.length > 60 ? `${articleTitle.slice(0, 59)}…` : articleTitle;
|
||||
ctx.fillText(titleTrunc, W - 80, nextY + 70);
|
||||
|
||||
// Author
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
ctx.font = `400 22px ${FONT}`;
|
||||
ctx.fillText(authorName, W - 80, H - 60);
|
||||
}
|
||||
|
||||
export function QuoteSharer({ containerRef, article }) {
|
||||
const [tooltip, setTooltip] = useState(null); // { x, y, text }
|
||||
const [modal, setModal] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const canvasRef = useRef(null);
|
||||
const fontReady = useRef(false);
|
||||
|
||||
useEffect(() => { loadFont().then(() => { fontReady.current = true; }); }, []);
|
||||
|
||||
// Close modal on Escape
|
||||
useEffect(() => {
|
||||
if (!modal) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') { setModal(false); setTooltip(null); } };
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [modal]);
|
||||
|
||||
const handleSelection = useCallback(() => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
const container = containerRef?.current;
|
||||
if (!container) return;
|
||||
let node = sel.anchorNode;
|
||||
while (node && node !== container) node = node.parentNode;
|
||||
if (!node) { setTooltip(null); return; }
|
||||
|
||||
const text = sel.toString().trim();
|
||||
if (text.length < 20 || text.length > 400) { setTooltip(null); return; }
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + window.scrollY - 48,
|
||||
text,
|
||||
});
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleSelection);
|
||||
document.addEventListener('touchend', handleSelection);
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleSelection);
|
||||
document.removeEventListener('touchend', handleSelection);
|
||||
};
|
||||
}, [handleSelection]);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
if (!tooltip) return;
|
||||
setModal(true);
|
||||
try {
|
||||
contentApi.trackEvent({ type: 'share_clicked', metadata: { channel: 'quote_card', slug: article?.slug } });
|
||||
} catch {}
|
||||
requestAnimationFrame(() => {
|
||||
if (canvasRef.current) {
|
||||
renderCard(
|
||||
canvasRef.current,
|
||||
tooltip.text,
|
||||
article?.title || 'تکناو',
|
||||
article?.authorName || 'تکناو',
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [tooltip, article]);
|
||||
|
||||
const download = useCallback(() => {
|
||||
if (!canvasRef.current) return;
|
||||
canvasRef.current.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'teknav-quote.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 'image/png');
|
||||
}, []);
|
||||
|
||||
const copyText = useCallback(() => {
|
||||
if (!tooltip?.text) return;
|
||||
const fullText = `«${tooltip.text}»\n\n— از مقاله: ${article?.title || 'تکناو'}\nhttps://www.teknav.ir/article/${article?.slug || ''}`;
|
||||
try {
|
||||
navigator.clipboard.writeText(fullText).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
}, [tooltip, article]);
|
||||
|
||||
const share = useCallback(async () => {
|
||||
if (!canvasRef.current || sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
if (navigator.share && navigator.canShare) {
|
||||
const blob = await new Promise((res) => canvasRef.current.toBlob(res, 'image/png'));
|
||||
const file = new File([blob], 'teknav-quote.png', { type: 'image/png' });
|
||||
if (navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: article?.title || 'تکناو',
|
||||
url: `https://www.teknav.ir/article/${article?.slug || ''}`,
|
||||
});
|
||||
setSharing(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
download();
|
||||
} catch {}
|
||||
setSharing(false);
|
||||
}, [sharing, download, article]);
|
||||
|
||||
if (!tooltip && !modal) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tooltip && !modal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: tooltip.x,
|
||||
top: tooltip.y,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#0F3540',
|
||||
border: '1px solid #D49A2A',
|
||||
borderRadius: 8,
|
||||
padding: '6px 14px',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontFamily: FONT,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
onMouseDown={(e) => { e.preventDefault(); openModal(); }}
|
||||
>
|
||||
<span className="tek-icon" style={{ ['--i']: `url("${shareIcon}")` }} aria-hidden="true" />
|
||||
<span>اشتراکگذاری نقلقول</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="اشتراکگذاری نقلقول"
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 2000,
|
||||
background: 'rgba(0,0,0,0.85)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 16,
|
||||
fontFamily: FONT, direction: 'rtl',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) { setModal(false); setTooltip(null); } }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ maxWidth: 'min(90vw, 480px)', maxHeight: '65vh', borderRadius: 12, border: '2px solid #D49A2A' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={share}
|
||||
disabled={sharing}
|
||||
style={{
|
||||
background: '#0F6B73', color: '#fff', border: 'none',
|
||||
borderRadius: 8, padding: '10px 22px', fontSize: 14,
|
||||
fontFamily: FONT, cursor: 'pointer', fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{sharing ? 'در حال اشتراک…' : <><span className="tek-icon" style={{ ['--i']: `url("${shareIcon}")` }} aria-hidden="true" /> اشتراکگذاری</>}
|
||||
</button>
|
||||
<button
|
||||
onClick={copyText}
|
||||
style={{
|
||||
background: copied ? '#2E7D32' : '#1A3A42', color: copied ? '#fff' : '#D49A2A',
|
||||
border: `1px solid ${copied ? '#2E7D32' : '#D49A2A'}`,
|
||||
borderRadius: 8, padding: '10px 22px', fontSize: 14,
|
||||
fontFamily: FONT, cursor: 'pointer', fontWeight: 700,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{copied ? '✓ کپی شد' : <><span className="tek-icon" style={{ ['--i']: `url("${clipboardIcon}")` }} aria-hidden="true" /> کپی متن</>}
|
||||
</button>
|
||||
<button
|
||||
onClick={download}
|
||||
style={{
|
||||
background: '#1A3A42', color: '#D49A2A', border: '1px solid #D49A2A',
|
||||
borderRadius: 8, padding: '10px 22px', fontSize: 14,
|
||||
fontFamily: FONT, cursor: 'pointer', fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
⬇ PNG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setModal(false); setTooltip(null); }}
|
||||
style={{
|
||||
background: 'transparent', color: 'rgba(255,255,255,0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
borderRadius: 8, padding: '10px 16px', fontSize: 14,
|
||||
fontFamily: FONT, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12, margin: 0 }}>Esc برای بستن</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user