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:
@@ -26,6 +26,7 @@ import {
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
|
||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||
@@ -207,6 +208,13 @@ const resolveMediaBoxSize = (
|
||||
h?: number
|
||||
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
|
||||
|
||||
/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */
|
||||
const STATIONERY_LINE_PX = 24;
|
||||
const snapStationeryGridPad = (height: number): number => {
|
||||
const rem = height % STATIONERY_LINE_PX;
|
||||
return rem === 0 ? 0 : STATIONERY_LINE_PX - rem;
|
||||
};
|
||||
|
||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
const imgInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
@@ -215,11 +223,21 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
}
|
||||
|
||||
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
|
||||
const gridPad = snapStationeryGridPad(height);
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined} transparent>
|
||||
<StationeryMedia
|
||||
outlined={outlined}
|
||||
transparent
|
||||
tiltSeed={mxcUrl}
|
||||
gridPad={gridPad}
|
||||
>
|
||||
<AttachmentBox
|
||||
style={{ width: toRem(width), height: toRem(height) }}
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
['--media-h' as string]: `${height}px`,
|
||||
}}
|
||||
data-paarrot-media-height={height}
|
||||
>
|
||||
{renderImageContent({
|
||||
@@ -232,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
</StationeryMedia>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,9 +287,15 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
videoInfo.w ?? videoInfo.thumbnail_info?.w,
|
||||
videoInfo.h ?? videoInfo.thumbnail_info?.h
|
||||
);
|
||||
const gridPad = snapStationeryGridPad(height);
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined} transparent>
|
||||
<StationeryMedia
|
||||
outlined={outlined}
|
||||
transparent
|
||||
tiltSeed={mxcUrl}
|
||||
gridPad={gridPad}
|
||||
>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={filename}
|
||||
@@ -287,7 +311,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{ width: toRem(width), height: toRem(height) }}
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
}}
|
||||
data-paarrot-media-height={height}
|
||||
>
|
||||
{renderVideoContent({
|
||||
@@ -300,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
</StationeryMedia>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
|
||||
return <MessageBrokenContent />;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
||||
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
|
||||
|
||||
return (
|
||||
<AttachmentBox
|
||||
data-stationery-media=""
|
||||
data-tape-alt={tapeAlt ? '' : undefined}
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
width: toRem(152),
|
||||
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
|
||||
}}
|
||||
>
|
||||
{renderImageContent({
|
||||
|
||||
@@ -53,6 +53,19 @@ export const Reaction = style([
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionStack = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
gridTemplateColumns: '1fr',
|
||||
gridTemplateRows: '1fr',
|
||||
lineHeight: toRem(20),
|
||||
minWidth: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionText = style([
|
||||
DefaultReset,
|
||||
{
|
||||
@@ -60,7 +73,15 @@ export const ReactionText = style([
|
||||
maxWidth: toRem(150),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: toRem(20),
|
||||
gridArea: '1 / 1',
|
||||
transformOrigin: 'center center',
|
||||
// Fan each stacked copy so count>1 reads as multiple stickers
|
||||
transform: `translate(
|
||||
calc(var(--sticker-i, 0) * 7px - 2px),
|
||||
calc(var(--sticker-i, 0) * -5px)
|
||||
) rotate(calc((var(--sticker-i, 0) - 1) * 12deg))`,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
|
||||
const MAX_STICKER_STACK = 4;
|
||||
|
||||
export const Reaction = as<
|
||||
'button',
|
||||
{
|
||||
@@ -15,35 +17,67 @@ export const Reaction = as<
|
||||
reaction: string;
|
||||
useAuthentication?: boolean;
|
||||
}
|
||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
gap="200"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.ReactionText} as="span" size="T400">
|
||||
{reaction.startsWith('mxc://') ? (
|
||||
<img
|
||||
className={css.ReactionImg}
|
||||
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||
}
|
||||
alt={reaction}
|
||||
/>
|
||||
) : (
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{reaction}
|
||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
||||
const showCount = count > 2;
|
||||
const isCustomEmoji = reaction.startsWith('mxc://');
|
||||
const customSrc = isCustomEmoji
|
||||
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
gap="200"
|
||||
data-reaction=""
|
||||
data-reaction-stack={stackCount}
|
||||
aria-label={`${reaction}, ${count}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
||||
{Array.from({ length: stackCount }, (_, i) => {
|
||||
// Draw back-to-front so the top sticker is the last layer
|
||||
const layer = stackCount - 1 - i;
|
||||
return (
|
||||
<Text
|
||||
key={layer}
|
||||
className={css.ReactionText}
|
||||
as="span"
|
||||
size="T400"
|
||||
data-reaction-sticker=""
|
||||
style={{
|
||||
['--sticker-i' as string]: layer,
|
||||
zIndex: i + 1,
|
||||
}}
|
||||
aria-hidden={i < stackCount - 1 ? true : undefined}
|
||||
>
|
||||
{isCustomEmoji ? (
|
||||
<img
|
||||
className={css.ReactionImg}
|
||||
src={customSrc}
|
||||
alt={i === stackCount - 1 ? reaction : ''}
|
||||
/>
|
||||
) : (
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{reaction}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{showCount && (
|
||||
<Text as="span" size="T300" data-reaction-count="">
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text as="span" size="T300">
|
||||
{count}
|
||||
</Text>
|
||||
</Box>
|
||||
));
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
type ReactionTooltipMsgProps = {
|
||||
room: Room;
|
||||
|
||||
@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
|
||||
|
||||
type ReplyLayoutProps = {
|
||||
userColor?: string;
|
||||
username?: ReactNode;
|
||||
};
|
||||
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||
({ username, userColor, className, children, ...props }, ref) => (
|
||||
<Box
|
||||
className={classNames(css.Reply, className)}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
|
||||
<Icon size="100" src={Icons.ReplyArrow} />
|
||||
{username}
|
||||
({ username, userColor, className, children, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const isStationery = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.Reply, className)}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box
|
||||
style={
|
||||
isStationery
|
||||
? {
|
||||
color: STATIONERY_NAME_INK,
|
||||
maxWidth: toRem(200),
|
||||
...(userColor
|
||||
? {
|
||||
['--username-accent' as string]: userColor,
|
||||
textDecorationColor: userColor,
|
||||
}
|
||||
: null),
|
||||
}
|
||||
: { color: userColor, maxWidth: toRem(200) }
|
||||
}
|
||||
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
>
|
||||
<Icon size="100" src={Icons.ReplyArrow} />
|
||||
{username}
|
||||
</Box>
|
||||
<Box grow="Yes" className={css.ReplyContent}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box grow="Yes" className={css.ReplyContent}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { Text, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './layout.css';
|
||||
import { isStationeryTheme, useTheme } from '../../../hooks/useTheme';
|
||||
import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk';
|
||||
|
||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
||||
@@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
||||
className
|
||||
)}
|
||||
data-message-collapsed={collapse ? '' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
);
|
||||
|
||||
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
||||
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
|
||||
<span
|
||||
className={classNames(css.AvatarBase, className)}
|
||||
data-message-avatar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
|
||||
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
||||
));
|
||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const isStationery = isStationeryTheme(theme);
|
||||
const userColor =
|
||||
typeof (style as CSSProperties | undefined)?.color === 'string'
|
||||
? ((style as CSSProperties).color as string)
|
||||
: undefined;
|
||||
|
||||
const safeStyle = useMemo((): CSSProperties | undefined => {
|
||||
if (!isStationery) return style as CSSProperties | undefined;
|
||||
return {
|
||||
...(style as CSSProperties | undefined),
|
||||
// Name in ink; keep their color as an accent underline
|
||||
color: STATIONERY_NAME_INK,
|
||||
...(userColor
|
||||
? { ['--username-accent' as string]: userColor, textDecorationColor: userColor }
|
||||
: null),
|
||||
};
|
||||
}, [style, isStationery, userColor]);
|
||||
|
||||
return (
|
||||
<AsUsername
|
||||
className={classNames(css.Username, className)}
|
||||
data-message-username=""
|
||||
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||
style={safeStyle}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
||||
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
||||
@@ -36,6 +72,8 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
|
||||
priority={notice ? '300' : '400'}
|
||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||
data-allow-text-selection="true"
|
||||
data-message-body=""
|
||||
data-jumbo-emoji={jumboEmoji ? '' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -204,6 +204,14 @@ export const Username = style({
|
||||
'button&:hover, button&:focus-visible': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
// Stationery uses a colored border-bottom accent instead
|
||||
'html.stationery &, body.stationery &': {
|
||||
overflow: 'visible',
|
||||
overflowY: 'visible',
|
||||
},
|
||||
'body.stationery button&:hover, body.stationery button&:focus-visible': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,5 +7,10 @@ type NavCategoryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
|
||||
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
|
||||
<div
|
||||
className={classNames(css.NavCategory, className)}
|
||||
data-nav-category=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -13,6 +13,7 @@ export const NavItem = as<
|
||||
<AsNavItem
|
||||
className={classNames(css.NavItem({ variant, radii }), className)}
|
||||
data-highlight={highlight}
|
||||
data-nav-item=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as css from './style.css';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type PageRootProps = {
|
||||
nav: ReactNode;
|
||||
@@ -43,6 +44,7 @@ export function PageNav({
|
||||
grow={isMobile ? 'Yes' : undefined}
|
||||
className={classNames(css.PageNav({ size }), className)}
|
||||
shrink={isMobile ? 'Yes' : 'No'}
|
||||
data-page-nav=""
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{children}
|
||||
@@ -58,6 +60,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||
variant="Background"
|
||||
size="600"
|
||||
data-folder-tab="nav"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -73,13 +76,16 @@ export function PageNavContent({
|
||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hideScrollbar = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
size={hideScrollbar ? '0' : '300'}
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
{...scrollProps}
|
||||
@@ -95,6 +101,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||
data-page=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -106,6 +113,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||
as="header"
|
||||
size="600"
|
||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||
data-folder-tab-bar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -4,5 +4,10 @@ import React from 'react';
|
||||
import * as css from './Sidebar.css';
|
||||
|
||||
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
||||
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
|
||||
<AsSidebar
|
||||
className={classNames(css.Sidebar, className)}
|
||||
data-sidebar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -8,10 +8,10 @@ type SidebarContentProps = {
|
||||
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
||||
return (
|
||||
<>
|
||||
<Box direction="Column" grow="Yes">
|
||||
<Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
|
||||
{scrollable}
|
||||
</Box>
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
|
||||
{sticky}
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
|
||||
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
||||
<AsSidebarAvatarBox
|
||||
className={classNames(css.SidebarItem({ active }), className)}
|
||||
data-sidebar-item=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
|
||||
<Avatar
|
||||
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
|
||||
radii={radii}
|
||||
data-sidebar-avatar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
|
||||
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
|
||||
<AsSidebarFolder
|
||||
className={classNames(css.SidebarFolder({ state }), className)}
|
||||
data-sidebar-folder=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
|
||||
({ className, virtualItem, style, ...props }, ref) => (
|
||||
<div
|
||||
className={classNames(css.VirtualTile, className)}
|
||||
style={{ top: virtualItem.start, ...style }}
|
||||
style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
|
||||
data-index={virtualItem.index}
|
||||
{...props}
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user