Files
mintel.me/apps/web/src/components/TextSelectionShare.tsx
Marc Mintel 3eccff42e4
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 1m31s
Build & Deploy / 🏗️ Build (push) Failing after 3m51s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
chore: fix linting and build errors
2026-02-17 23:48:52 +01:00

185 lines
6.7 KiB
TypeScript

"use client";
import React, { useEffect, useState, useRef } from "react";
import { Copy, Check } from "lucide-react";
import { useAnalytics } from "./analytics/useAnalytics";
import { motion, AnimatePresence } from "framer-motion";
export function TextSelectionShare() {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [selectedText, setSelectedText] = useState("");
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { trackEvent } = useAnalytics();
const selectionTimeoutRef = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
const handleSelection = () => {
// Clear any pending timeout
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
// Small delay to ensure selection is complete
selectionTimeoutRef.current = setTimeout(() => {
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && text.length > 10) {
const range = selection?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
if (rect) {
// Position menu above selection, centered
setPosition({
x: rect.left + rect.width / 2,
y: rect.top + window.scrollY - 10,
});
setSelectedText(text);
setIsVisible(true);
}
} else {
setIsVisible(false);
setCopied(false);
}
}, 100);
};
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
const selection = window.getSelection();
if (!selection?.toString()) {
setIsVisible(false);
setCopied(false);
}
}
};
// Listen to both mouseup and selectionchange for better reliability
document.addEventListener("mouseup", handleSelection);
document.addEventListener("selectionchange", handleSelection);
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mouseup", handleSelection);
document.removeEventListener("selectionchange", handleSelection);
document.removeEventListener("mousedown", handleClickOutside);
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
const url = window.location.href;
const shareText = `"${selectedText}"\n\n${url}`;
try {
await navigator.clipboard.writeText(shareText);
setCopied(true);
trackEvent("text_selection_copied", {
text_length: selectedText.length,
url: url,
});
setTimeout(() => {
setIsVisible(false);
setCopied(false);
}, 1500);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleShareX = () => {
const url = window.location.href;
const text = encodeURIComponent(`"${selectedText}"\n\n`);
const shareUrl = `https://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(url)}`;
window.open(shareUrl, "_blank", "width=550,height=420");
trackEvent("text_selection_shared_x", {
text_length: selectedText.length,
url: url,
});
setIsVisible(false);
};
const handleShareLinkedIn = () => {
const url = window.location.href;
const shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
window.open(shareUrl, "_blank", "width=550,height=420");
trackEvent("text_selection_shared_linkedin", {
text_length: selectedText.length,
url: url,
});
setIsVisible(false);
};
return (
<AnimatePresence>
{isVisible && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="fixed z-[200]"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
transform: "translate(-50%, -100%)",
}}
>
<div className="bg-slate-900 text-white rounded-xl shadow-2xl px-2 py-2 flex items-center gap-1">
<button
onClick={handleCopy}
className="px-3 py-2 hover:bg-slate-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
title="Zitat kopieren"
>
{copied ? (
<>
<Check size={16} className="text-green-400" />
<span>Kopiert!</span>
</>
) : (
<>
<Copy size={16} />
<span>Kopieren</span>
</>
)}
</button>
<div className="w-px h-6 bg-slate-700" />
<button
onClick={handleShareX}
className="px-3 py-2 hover:bg-slate-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
title="Auf X teilen"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
<span>X</span>
</button>
<button
onClick={handleShareLinkedIn}
className="px-3 py-2 hover:bg-slate-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
title="Auf LinkedIn teilen"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
<span>LinkedIn</span>
</button>
</div>
{/* Arrow */}
<div className="absolute left-1/2 -translate-x-1/2 -bottom-2 w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent border-t-slate-900" />
</motion.div>
)}
</AnimatePresence>
);
}