Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions src/components/DraggableEmojiOverlays.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"use client";

import { useRef, useCallback, useState } from "react";
import { OverlayElement } from "@/lib/types";
import { X, RotateCcw } from "lucide-react";

interface Props {
elements: OverlayElement[];
containerWidth?: number;
containerHeight?: number;
onUpdate: (id: string, patch: Partial<OverlayElement>) => void;
onRemove: (id: string) => void;
}

const EMOJI_BASE_SIZE = 64; // px at scale 1.0
const DRAG_THRESHOLD = 4; // px — movement below this is treated as a click, not a drag
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));

export default function DraggableEmojiOverlays({ elements, onUpdate, onRemove }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);

// Deselect when clicking the container background
const handleContainerPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.target === containerRef.current) {
setSelectedId(null);
}
}, []);

const startDrag = useCallback(
(id: string, startX: number, startY: number, startPctX: number, startPctY: number) => {
const container = containerRef.current;
if (!container) return;

const { width, height } = container.getBoundingClientRect();
let hasMoved = false;

const onMove = (e: PointerEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// Only start moving after threshold so click-to-select still works
if (!hasMoved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
hasMoved = true;
onUpdate(id, {
x: clamp(startPctX + (dx / width) * 100, 0, 100),
y: clamp(startPctY + (dy / height) * 100, 0, 100),
});
};

const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};

window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[onUpdate]
);

const startScale = useCallback(
(id: string, el: OverlayElement, startY: number) => {
const initialScale = el.scale;

const onMove = (e: PointerEvent) => {
const dy = startY - e.clientY; // drag up = bigger
const newScale = clamp(initialScale + dy * 0.01, 0.3, 5);
onUpdate(id, { scale: parseFloat(newScale.toFixed(2)) });
};

const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};

window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[onUpdate]
);

const startRotate = useCallback(
(id: string, el: OverlayElement) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const cx = rect.left + (el.x / 100) * rect.width;
const cy = rect.top + (el.y / 100) * rect.height;

const onMove = (e: PointerEvent) => {
const angle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI) + 90;
onUpdate(id, { rotation: parseFloat(angle.toFixed(1)) });
};

const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};

window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[onUpdate]
);

return (
<div
ref={containerRef}
className="absolute inset-0 pointer-events-none"
aria-label="Emoji sticker overlays"
onPointerDown={handleContainerPointerDown}
>
{elements.map((el) => {
const size = EMOJI_BASE_SIZE * el.scale;
const isSelected = selectedId === el.id;

return (
<div
key={el.id}
className="absolute pointer-events-auto"
style={{
left: `${el.x}%`,
top: `${el.y}%`,
transform: `translate(-50%, -50%) rotate(${el.rotation}deg)`,
width: size,
height: size,
zIndex: isSelected ? 30 : 20,
}}
>
{/* Main emoji — pointerDown selects + starts drag */}
<img
src={el.src}
alt=""
aria-label={`Emoji sticker at ${Math.round(el.x)}% ${Math.round(el.y)}%`}
draggable={false}
className="w-full h-full select-none drop-shadow-lg"
style={{ cursor: isSelected ? "grab" : "pointer" }}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
// Select immediately on pointer down — controls appear right away
setSelectedId(el.id);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Start drag — won't actually move until past DRAG_THRESHOLD
startDrag(el.id, e.clientX, e.clientY, el.x, el.y);
}}
/>

{/* Controls — only shown when selected */}
{isSelected && (
<>
{/* Selection ring */}
<div className="absolute inset-0 rounded border-2 border-white/70 border-dashed pointer-events-none" />

{/* Remove — top right */}
<button
type="button"
aria-label="Remove sticker"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onRemove(el.id);
setSelectedId(null);
}}
className="absolute -top-3 -right-3 w-6 h-6 rounded-full bg-red-500 text-white flex items-center justify-center shadow-md hover:bg-red-400 transition-colors"
style={{ transform: `rotate(${-el.rotation}deg)`, zIndex: 10 }}
>
<X size={11} />
</button>

{/* Scale handle — bottom right, drag up/down */}
<div
role="slider"
aria-label="Scale sticker (drag up to enlarge, down to shrink)"
aria-valuenow={el.scale}
aria-valuemin={0.3}
aria-valuemax={5}
tabIndex={0}
className="absolute -bottom-3 -right-3 w-6 h-6 rounded-full bg-blue-500 cursor-ns-resize flex items-center justify-center shadow-md hover:bg-blue-400 transition-colors"
style={{ transform: `rotate(${-el.rotation}deg)`, zIndex: 10 }}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startScale(el.id, el, e.clientY);
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 8L8 2M5 8L8 5M2 5L5 2" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>

{/* Rotate handle — bottom left */}
<div
role="button"
aria-label="Rotate sticker (drag to rotate)"
tabIndex={0}
className="absolute -bottom-3 -left-3 w-6 h-6 rounded-full bg-amber-500 cursor-crosshair flex items-center justify-center shadow-md hover:bg-amber-400 transition-colors"
style={{ transform: `rotate(${-el.rotation}deg)`, zIndex: 10 }}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startRotate(el.id, el);
}}
>
<RotateCcw size={10} color="white" />
</div>
</>
)}
</div>
);
})}
</div>
);
}
119 changes: 119 additions & 0 deletions src/components/Elements.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"use client";

import { useMemo } from "react";
import { Smile, Trash2 } from "lucide-react";
import { EMOJI_CATEGORIES } from "@/lib/emojis";
import { OverlayElement } from "@/lib/types";

interface Props {
overlayElements: OverlayElement[];
onAdd: (unicode: string) => void;
onRemove: (id: string) => void;
onClearAll: () => void;
}

export default function Elements({ overlayElements, onAdd, onRemove, onClearAll }: Props) {
// Mobile Keypad Flattening: Merge all category arrays into one single flat list
const flatEmojis = useMemo(() => {
return Object.values(EMOJI_CATEGORIES).flat();
}, []);

return (
<div className="space-y-3 w-full">
{/* Clean Section Subheading */}
<div className="flex items-center gap-1.5 px-0.5">
<span className="text-[10px] font-heading font-bold uppercase tracking-wider text-[var(--muted)]">
Emoji Stickers
</span>
</div>

{/* 🗚 High-Density Viewport Content Box */}
<div className="bg-[var(--bg)] border border-[var(--border)] rounded-lg p-2 min-h-[140px] max-h-[200px] overflow-y-auto elements-blended-scrollbar">

{/* 💉 Pure CSS Injection to make a thin, transparent-blended scrollbar */}
<style>{`
.elements-blended-scrollbar::-webkit-scrollbar {
width: 5px !important;
}
.elements-blended-scrollbar::-webkit-scrollbar-track {
background: transparent !important;
}
.elements-blended-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--border, #334155) !important;
border-radius: 9999px !important;
}
.elements-blended-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--muted, #64748b) !important;
}
.elements-blended-scrollbar {
scrollbar-width: thin !important;
scrollbar-color: var(--border, #334155) transparent !important;
}
`}</style>

{/* Dense Mobile Keypad Emoji Layout Grid */}
<div className="flex flex-wrap gap-1.5 justify-center">
{flatEmojis.map((emoji) => (
<button
key={emoji.unicode}
type="button"
title={emoji.name}
aria-label={`Add ${emoji.name} sticker`}
onClick={() => onAdd(emoji.unicode)}
className="w-8 h-8 flex items-center justify-center rounded bg-[var(--surface)] border border-[var(--border)] hover:border-film-400 hover:bg-film-50/20 active:scale-90 text-base transition-all cursor-pointer flex-shrink-0"
>
{emoji.char}
</button>
))}
</div>
</div>

{/* Active Layer Controls Management Section */}
{overlayElements.length > 0 ? (
<div className="space-y-1.5 pt-2 border-t border-[var(--border)]">
<div className="flex items-center justify-between">
<span className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] flex items-center gap-1">
<Smile size={10} />
Active elements ({overlayElements.length})
</span>
<button
type="button"
onClick={onClearAll}
className="text-[10px] text-red-400 hover:text-red-300 font-heading font-semibold uppercase tracking-wider transition-colors cursor-pointer"
>
Clear all
</button>
</div>

<ul className="space-y-1 max-h-24 overflow-y-auto pr-0.5 elements-blended-scrollbar">
{overlayElements.map((el) => (
<li
key={el.id}
className="flex items-center justify-between gap-2 px-2 py-1 rounded bg-[var(--surface)] border border-[var(--border)]"
>
<img src={el.src} alt="" aria-hidden="true" className="w-4 h-4 object-contain flex-shrink-0" />
<span className="text-[10px] text-[var(--muted)] font-heading truncate flex-1">
{el.unicode
? (flatEmojis.find((e) => e.unicode === el.unicode)?.name ?? el.unicode)
: "Custom Layer"}
</span>
<button
type="button"
onClick={() => onRemove(el.id)}
aria-label="Remove element layer"
className="text-[var(--muted)] hover:text-red-400 transition-colors flex-shrink-0 cursor-pointer"
>
<Trash2 size={11} />
</button>
</li>
))}
</ul>
</div>
) : (
<p className="text-[9px] text-[var(--muted)] text-center py-0.5 font-heading opacity-60">
Click an element above to mount it to the video canvas
</p>
)}
</div>
);
}
Loading