import React, { ComponentProps, CSSProperties, MouseEventHandler, ReactNode, useCallback, useMemo, useRef, } from 'react'; import { Attachment } from './attachment'; import { isStationeryTheme, useTheme } from '../../hooks/useTheme'; type Corner = 'tl' | 'tr' | 'bl' | 'br'; type StationeryMediaProps = ComponentProps & { children: ReactNode; /** Stable id (e.g. mxc URL) so tilt/tape don't reshuffle on timeline re-renders */ tiltSeed?: string; /** Invisible bottom spacer (px) so following text stays on the ruled grid */ gridPad?: number; }; const CORNERS: { id: Corner; x: 0 | 1; y: 0 | 1 }[] = [ { id: 'tl', x: 0, y: 0 }, { id: 'tr', x: 1, y: 0 }, { id: 'bl', x: 0, y: 1 }, { id: 'br', x: 1, y: 1 }, ]; const MEDIA_ROTS = [-0.9, 0.7, -0.5, 1.0, -1.1, 0.8, -0.4, 0.9] as const; export function hashStationerySeed(seed: string): number { let h = 2166136261; for (let i = 0; i < seed.length; i += 1) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } export function stationeryMediaRot(seed: string): string { return `${MEDIA_ROTS[hashStationerySeed(seed) % MEDIA_ROTS.length]}deg`; } const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); /** * Fold sits halfway between corner and cursor; flap hinges on that fold. * `keep` = remaining photo; `flap` = lifted triangle. */ function cornerPeelPaths( id: Corner, w: number, h: number, cx: number, cy: number, ux: number, uy: number, size: number ): { keep: string; flap: string; origin: string; axisX: number; axisY: number; hingeSign: number; } { const eps = 0.0001; // Fold line through the midpoint on corner→cursor, ⊥ to that ray const px = cx + ux * size; const py = cy + uy * size; let ax = 0; let ay = 0; let bx = 0; let by = 0; if (id === 'tr') { ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w); ay = 0; bx = w; by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h); } else if (id === 'tl') { ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w); ay = 0; bx = 0; by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h); } else if (id === 'br') { ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w); ay = h; bx = w; by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h); } else { ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w); ay = h; bx = 0; by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h); } let keep: string; if (id === 'tr') { keep = `polygon(0 0, ${ax}px 0, ${w}px ${by}px, ${w}px ${h}px, 0 ${h}px)`; } else if (id === 'tl') { keep = `polygon(${ax}px 0, ${w}px 0, ${w}px ${h}px, 0 ${h}px, 0 ${by}px)`; } else if (id === 'br') { keep = `polygon(0 0, ${w}px 0, ${w}px ${by}px, ${ax}px ${h}px, 0 ${h}px)`; } else { keep = `polygon(0 0, ${w}px 0, ${w}px ${h}px, ${ax}px ${h}px, 0 ${by}px)`; } const flap = `polygon(${cx}px ${cy}px, ${ax}px ${ay}px, ${bx}px ${by}px)`; // Hinge on the fold (midpoint), axis along the fold edge const midX = (ax + bx) / 2; const midY = (ay + by) / 2; const fdx = bx - ax; const fdy = by - ay; const flen = Math.hypot(fdx, fdy) || 1; const axisX = fdx / flen; const axisY = fdy / flen; // Sign so the corner swings up over the fold (right-hand rule) const cross = fdx * (cy - midY) - fdy * (cx - midX); const hingeSign = cross >= 0 ? 1 : -1; return { keep, flap, origin: `${midX}px ${midY}px`, axisX, axisY, hingeSign, }; } /** * Stationery-theme image chrome: clear corner tape + a mouse-driven peel * that shows a duplicated slice of the photo lifting toward the cursor. */ export function StationeryMedia({ children, tiltSeed = 'stationery', gridPad = 0, style, ...attachmentProps }: StationeryMediaProps) { const theme = useTheme(); const isStationery = isStationeryTheme(theme); const peelRef = useRef(null); const tiltStyle = useMemo((): CSSProperties => { return { ...(style as CSSProperties | undefined), // Margin sits outside the paper chrome — snaps the grid without a white bar ...(isStationery && gridPad > 0 ? { marginBottom: `${gridPad}px` } : null), ['--media-rot' as string]: stationeryMediaRot(tiltSeed), }; }, [tiltSeed, style, isStationery, gridPad]); const tapeAlt = useMemo(() => hashStationerySeed(tiltSeed) % 2 === 1, [tiltSeed]); // Default tape covers TL; alt covers TR — peel only the free top corner const peelCorner = useMemo( () => (tapeAlt ? CORNERS.find((c) => c.id === 'tl')! : CORNERS.find((c) => c.id === 'tr')!), [tapeAlt] ); const clearPeel = useCallback((host: HTMLElement) => { const peel = peelRef.current; const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null; if (peel) { // Hide flap first (no opacity tween) so restoring the photo can't double-draw peel.style.transition = 'none'; peel.style.opacity = '0'; peel.style.setProperty('--peel-hinge', '0deg'); peel.style.clipPath = ''; } if (target) target.style.clipPath = ''; }, []); const syncPeel = useCallback( (host: HTMLElement, clientX: number, clientY: number) => { const peel = peelRef.current; if (!peel) return; const rect = host.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; const w = rect.width; const h = rect.height; const best = peelCorner; const cx = best.x * w; const cy = best.y * h; const dist = Math.hypot(cx - x, cy - y); const maxReach = Math.min(w, h) * 0.55; // Fold halfway between corner and cursor; fade when leaving the corner zone let size = 0; let amount = 0; if (dist > 8 && dist <= maxReach) { size = dist * 0.5; amount = Math.min(1, size / (Math.min(w, h) * 0.22)); } else if (dist > maxReach && dist < maxReach * 1.35) { const fade = 1 - (dist - maxReach) / (maxReach * 0.35); size = maxReach * 0.5 * Math.max(0, fade); amount = Math.max(0, fade); } const img = host.querySelector('img'); const url = img?.currentSrc || img?.src || ''; // Unit vector from corner → cursor (triangle aims this way) let ux = x - cx; let uy = y - cy; if (best.id === 'tr') { ux = Math.min(-0.05, ux); uy = Math.max(0.05, uy); } else { ux = Math.max(0.05, ux); uy = Math.max(0.05, uy); } const len = Math.hypot(ux, uy) || 1; ux /= len; uy /= len; peel.dataset.corner = best.id; if (url) { peel.style.setProperty('--peel-img', `url(${JSON.stringify(url)})`); } const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null; if (target && amount > 0.06 && size > 6) { const { keep, flap, origin, axisX, axisY, hingeSign } = cornerPeelPaths( best.id, w, h, cx, cy, ux, uy, size ); target.style.clipPath = keep; peel.style.clipPath = flap; peel.style.transformOrigin = origin; peel.style.setProperty('--peel-axis-x', String(axisX)); peel.style.setProperty('--peel-axis-y', String(axisY)); peel.style.setProperty('--peel-hinge', `${hingeSign * amount * 52}deg`); peel.style.setProperty('--peel-tip-x', `${cx}px`); peel.style.setProperty('--peel-tip-y', `${cy}px`); peel.style.setProperty('--peel-grad-r', `${Math.max(size * 1.15, 12)}px`); peel.style.opacity = '1'; } else { clearPeel(host); } }, [peelCorner, clearPeel] ); const handleMove: MouseEventHandler = (evt) => { syncPeel(evt.currentTarget, evt.clientX, evt.clientY); }; const handleEnter: MouseEventHandler = (evt) => { syncPeel(evt.currentTarget, evt.clientX, evt.clientY); }; const handleLeave: MouseEventHandler = (evt) => { clearPeel(evt.currentTarget); }; if (!isStationery) { return ( {children} ); } return ( ); }