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