Files
cinny/src/app/components/message/StationeryMedia.tsx
litruv 61d41900cc Add Stationery and Stationery Dark themes with notebook chrome.
Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
2026-07-23 00:24:10 +10:00

294 lines
8.6 KiB
TypeScript

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<typeof Attachment> & {
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<HTMLSpanElement>(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<HTMLElement> = (evt) => {
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
};
const handleEnter: MouseEventHandler<HTMLElement> = (evt) => {
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
};
const handleLeave: MouseEventHandler<HTMLElement> = (evt) => {
clearPeel(evt.currentTarget);
};
if (!isStationery) {
return (
<Attachment style={style} {...attachmentProps}>
{children}
</Attachment>
);
}
return (
<Attachment
{...attachmentProps}
style={tiltStyle}
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
onMouseEnter={handleEnter}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
>
<span ref={peelRef} data-stationery-peel="" aria-hidden="true" />
{children}
</Attachment>
);
}