Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
294 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|