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.
This commit is contained in:
293
src/app/components/message/StationeryMedia.tsx
Normal file
293
src/app/components/message/StationeryMedia.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user