Apply smooth corners to inline media (#1422)

This commit is contained in:
klopez4212
2026-07-01 07:50:26 -07:00
committed by GitHub
parent 4ae5a0d5e3
commit 2fc8b9cf5f
14 changed files with 744 additions and 47 deletions
+2 -2
View File
@@ -23,9 +23,9 @@ import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Spinner } from "./spinner";
import {
getInlinePlaybackPosition,
@@ -707,6 +707,7 @@ export function VideoPlayer({
const videoRef = React.useRef<HTMLVideoElement>(null);
const inlineSurfaceRef = React.useRef<HTMLDivElement | null>(null);
const reviewVideoRef = React.useRef<HTMLVideoElement>(null);
useSmoothCorners(inlineSurfaceRef);
const [started, setStarted] = React.useState(false);
const [isPlaying, setIsPlaying] = React.useState(false);
const [isBuffering, setIsBuffering] = React.useState(false);
@@ -845,7 +846,6 @@ export function VideoPlayer({
observer.observe(element);
return () => observer.disconnect();
}, []);
const handlePlaybackSpeedChange = React.useCallback((speed: number) => {
if (isPlaybackSpeedOption(speed)) {
setPlaybackSpeed(speed);
+8 -3
View File
@@ -1,8 +1,9 @@
import type * as React from "react";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/shared/lib/cn";
import { Button, type ButtonProps } from "@/shared/ui/button";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
type AttachmentProps = React.ComponentProps<"div"> & {
orientation?: "horizontal" | "vertical";
@@ -17,10 +18,14 @@ function Attachment({
state = "done",
...props
}: AttachmentProps) {
const attachmentRef = React.useRef<HTMLDivElement | null>(null);
useSmoothCorners(attachmentRef);
return (
<div
ref={attachmentRef}
className={cn(
"group/attachment relative flex min-w-0 gap-3 overflow-hidden rounded-lg border border-border/70 bg-muted/30 text-left transition-colors",
"group/attachment relative flex min-w-0 gap-3 overflow-hidden rounded-2xl border border-border/70 bg-muted/30 text-left transition-colors",
"hover:border-border hover:bg-muted/50 data-[state=error]:border-destructive/40 data-[state=error]:bg-destructive/10",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
orientation === "horizontal" && "items-center",
@@ -160,7 +165,7 @@ function AttachmentTrigger({
return (
<Comp
className={cn(
"absolute inset-0 z-10 rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
"absolute inset-0 z-10 rounded-2xl focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
data-slot="attachment-trigger"
+38 -35
View File
@@ -38,6 +38,7 @@ import remarkSpoilers from "@/shared/lib/remarkSpoilers";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
INLINE_CODE_CHIP_CLASS,
MENTION_CHIP_BASE_CLASSES,
@@ -66,6 +67,7 @@ import {
import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { MarkdownInput } from "./markdown/MarkdownInput";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { resolveFileCard } from "./markdownFileCard";
import type {
@@ -595,6 +597,7 @@ function ImageZoomOverlay({
const galleryTransitionTimerRef = React.useRef<number | null>(null);
const closeTimerRef = React.useRef<number | null>(null);
const dialogRef = React.useRef<HTMLDivElement | null>(null);
const imageFrameSurfaceRef = React.useRef<HTMLDivElement | null>(null);
const descriptionId = React.useId();
const gestureScaleRef = React.useRef(1);
const previouslyFocusedElementRef = React.useRef<HTMLElement | null>(null);
@@ -603,6 +606,8 @@ function ImageZoomOverlay({
const hasPreviousImage = currentIndex > 0;
const hasNextImage = currentIndex < items.length - 1;
const canDownloadCurrentImage = Boolean(currentItem.src);
useSmoothCorners(imageFrameSurfaceRef);
const galleryTransitionFilter =
!prefersReducedMotion && isGalleryNavigating
? `blur(${IMAGE_LIGHTBOX_GALLERY_BLUR_PX}px)`
@@ -1145,31 +1150,36 @@ function ImageZoomOverlay({
: IMAGE_LIGHTBOX_EASE_OUT,
}}
>
<div className="relative h-full w-full overflow-hidden rounded-lg shadow-2xl">
<AnimatePresence
custom={galleryDirection}
initial={false}
mode="popLayout"
<div className="relative h-full w-full rounded-2xl shadow-2xl">
<div
ref={imageFrameSurfaceRef}
className="relative h-full w-full overflow-hidden rounded-2xl"
>
<motion.img
alt={currentItem.alt}
animate="center"
className="absolute inset-0 h-full w-full object-contain"
<AnimatePresence
custom={galleryDirection}
exit="exit"
initial="enter"
key={currentItem.resolvedSrc}
src={currentItem.resolvedSrc}
transition={{
duration: prefersReducedMotion
? IMAGE_LIGHTBOX_REDUCED_MOTION_MS / 1000
: IMAGE_LIGHTBOX_GALLERY_SLIDE_MS / 1000,
ease: IMAGE_LIGHTBOX_GALLERY_EASE,
}}
variants={galleryImageVariants}
onContextMenuCapture={handleImageContextMenu}
/>
</AnimatePresence>
initial={false}
mode="popLayout"
>
<motion.img
alt={currentItem.alt}
animate="center"
className="absolute inset-0 h-full w-full object-contain"
custom={galleryDirection}
exit="exit"
initial="enter"
key={currentItem.resolvedSrc}
src={currentItem.resolvedSrc}
transition={{
duration: prefersReducedMotion
? IMAGE_LIGHTBOX_REDUCED_MOTION_MS / 1000
: IMAGE_LIGHTBOX_GALLERY_SLIDE_MS / 1000,
ease: IMAGE_LIGHTBOX_GALLERY_EASE,
}}
variants={galleryImageVariants}
onContextMenuCapture={handleImageContextMenu}
/>
</AnimatePresence>
</div>
</div>
</div>
{hasPreviousImage ? (
@@ -1308,6 +1318,8 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
const [menu, setMenu] = React.useState<ImageContextMenuPosition | null>(null);
const inlineImageRef = React.useRef<HTMLImageElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
useSmoothCorners(inlineImageRef);
const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{
height: number;
src: string;
@@ -1464,7 +1476,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
aria-hidden={isHiddenInSpoiler ? true : undefined}
aria-label={alt?.trim() ? `Zoom image: ${alt}` : "Zoom image"}
className={cn(
"mt-1 inline-block min-w-0 max-w-full cursor-zoom-in rounded-xl border-0 bg-transparent p-0 text-left align-top focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50",
"mt-1 inline-block min-w-0 max-w-full cursor-zoom-in rounded-2xl border-0 bg-transparent p-0 text-left align-top focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50",
lightboxState && "opacity-0",
)}
data-image-lightbox-resolved-src={resolvedSrc}
@@ -1481,7 +1493,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
>
<img
alt={alt}
className="block h-auto max-h-64 max-w-[min(24rem,100%)] rounded-xl object-contain"
className="block h-auto max-h-64 max-w-[min(24rem,100%)] rounded-2xl object-contain"
data-spoiler-media-size={hiddenSpoilerMediaSize ? "" : undefined}
height={intrinsicDimensions.height}
ref={imageRef}
@@ -1793,16 +1805,7 @@ function createMarkdownComponents(
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
table: ({ children }) => (
<div
className="overflow-x-auto rounded-2xl border border-border/70"
data-table-block=""
>
<table className="w-full border-collapse text-left text-sm">
{children}
</table>
</div>
),
table: ({ children }) => <MarkdownTable>{children}</MarkdownTable>,
td: ({ children }) => (
<td className="border-t border-border/70 px-3 py-2 align-top">
{children}
+8 -1
View File
@@ -12,6 +12,7 @@ import {
import { useTheme } from "@/shared/theme/ThemeProvider";
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
import { Button } from "@/shared/ui/button";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { getReactNodeText } from "./utils";
@@ -70,7 +71,9 @@ export function MarkdownCodeBlock({
language?: string;
}) {
const [isCopying, setIsCopying] = React.useState(false);
const codeBlockRef = React.useRef<HTMLPreElement | null>(null);
const code = React.useMemo(() => getCodeBlockText(children), [children]);
useSmoothCorners(codeBlockRef);
const handleCopy = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -93,7 +96,11 @@ export function MarkdownCodeBlock({
return (
<div className="group relative" data-code-block="">
<pre className="max-h-[400px] overflow-x-auto overflow-y-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs">
<pre
ref={codeBlockRef}
className="max-h-[400px] overflow-x-auto overflow-y-auto rounded-2xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs"
style={{ borderRadius: "1rem" }}
>
{language && (
<div className="mb-1 text-xs text-muted-foreground/70">
{language}
+8 -1
View File
@@ -1,7 +1,9 @@
import * as React from "react";
import { Download, FileText } from "lucide-react";
import { toast } from "sonner";
import { invokeTauri } from "@/shared/api/tauri";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
/** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */
function formatFileSize(bytes: number): string {
@@ -36,9 +38,13 @@ export function FileCard({
filename: string;
size?: number;
}) {
const cardRef = React.useRef<HTMLButtonElement | null>(null);
const sizeLabel = size != null ? formatFileSize(size) : "";
useSmoothCorners(cardRef);
return (
<button
ref={cardRef}
type="button"
onClick={() => {
invokeTauri("download_file", { url: href, filename }).catch(
@@ -49,7 +55,8 @@ export function FileCard({
);
}}
data-testid="file-card"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
style={{ borderRadius: "1rem" }}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
<FileText className="h-4 w-4" />
@@ -0,0 +1,20 @@
import * as React from "react";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
export function MarkdownTable({ children }: { children?: React.ReactNode }) {
const tableBlockRef = React.useRef<HTMLDivElement | null>(null);
useSmoothCorners(tableBlockRef);
return (
<div
ref={tableBlockRef}
className="overflow-x-auto rounded-2xl border border-border/70"
data-table-block=""
>
<table className="w-full border-collapse text-left text-sm">
{children}
</table>
</div>
);
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
generateSmoothCornerClipPath,
generateSmoothCornerPath,
SMOOTH_CORNER_SMOOTHING,
} from "./smoothCorners.ts";
const radii = {
topLeft: 16,
topRight: 16,
bottomRight: 16,
bottomLeft: 16,
};
test("smooth corners use the app default smoothing", () => {
assert.equal(SMOOTH_CORNER_SMOOTHING, 0.6);
});
test("generateSmoothCornerPath keeps the radius while expanding the smoothed shoulder", () => {
const path = generateSmoothCornerPath(100, 50, radii);
assert.match(path, /^M 25\.0000 0 L 75\.0000 0/);
assert.match(path, /a 16\.0000 16\.0000 0 0 1/);
});
test("generateSmoothCornerClipPath emits a CSS path value", () => {
const clipPath = generateSmoothCornerClipPath(100, 50, radii);
assert.match(clipPath, /^path\("M /);
assert.match(clipPath, / Z"\)$/);
});
+488
View File
@@ -0,0 +1,488 @@
import * as React from "react";
export const SMOOTH_CORNER_SMOOTHING = 0.6;
type Corner = "topLeft" | "topRight" | "bottomRight" | "bottomLeft";
type Side = "top" | "right" | "bottom" | "left";
type CornerRadii = Record<Corner, number>;
type NormalizedCorner = {
radius: number;
roundingAndSmoothingBudget: number;
};
type NormalizedCorners = Record<Corner, NormalizedCorner>;
type CornerPathParams = {
a: number;
b: number;
c: number;
d: number;
p: number;
arcSectionLength: number;
cornerRadius: number;
};
type SmoothCornersOptions = {
enabled?: boolean;
smoothing?: number;
};
const ADJACENTS_BY_CORNER: Record<
Corner,
Array<{ corner: Corner; side: Side }>
> = {
topLeft: [
{ corner: "topRight", side: "top" },
{ corner: "bottomLeft", side: "left" },
],
topRight: [
{ corner: "topLeft", side: "top" },
{ corner: "bottomRight", side: "right" },
],
bottomRight: [
{ corner: "bottomLeft", side: "bottom" },
{ corner: "topRight", side: "right" },
],
bottomLeft: [
{ corner: "bottomRight", side: "bottom" },
{ corner: "topLeft", side: "left" },
],
};
function supportsClipPathPath() {
return (
typeof CSS === "undefined" ||
typeof CSS.supports !== "function" ||
CSS.supports("clip-path", 'path("M 0 0 L 1 0 L 1 1 L 0 1 Z")') ||
CSS.supports("-webkit-clip-path", 'path("M 0 0 L 1 0 L 1 1 L 0 1 Z")')
);
}
function toRadians(degrees: number) {
return (degrees * Math.PI) / 180;
}
function round(value: number) {
return Number.isFinite(value) ? value.toFixed(4) : "0.0000";
}
function rounded(strings: TemplateStringsArray, ...values: number[]): string {
let output = strings[0] ?? "";
for (let i = 0; i < values.length; i += 1) {
output += round(values[i]);
output += strings[i + 1] ?? "";
}
return output;
}
function parseRadiusLength(value: string | undefined, axisLength: number) {
const trimmed = value?.trim();
if (!trimmed) return 0;
if (trimmed.endsWith("%")) {
const percent = Number.parseFloat(trimmed);
return Number.isFinite(percent) ? (percent / 100) * axisLength : 0;
}
const pixels = Number.parseFloat(trimmed);
return Number.isFinite(pixels) ? Math.max(0, pixels) : 0;
}
function parseCornerRadius(value: string, width: number, height: number) {
const [horizontal = "0", vertical = horizontal] = value.trim().split(/\s+/);
return Math.min(
parseRadiusLength(horizontal, width),
parseRadiusLength(vertical, height),
);
}
function getLayoutSize(element: HTMLElement) {
const style = window.getComputedStyle(element);
const width = Number.parseFloat(style.width);
const height = Number.parseFloat(style.height);
if (!Number.isFinite(width) || !Number.isFinite(height)) {
return {
height: element.offsetHeight,
width: element.offsetWidth,
};
}
if (style.boxSizing === "border-box") {
return { height, width };
}
const paddingX =
(Number.parseFloat(style.paddingLeft) || 0) +
(Number.parseFloat(style.paddingRight) || 0);
const paddingY =
(Number.parseFloat(style.paddingTop) || 0) +
(Number.parseFloat(style.paddingBottom) || 0);
const borderX =
(Number.parseFloat(style.borderLeftWidth) || 0) +
(Number.parseFloat(style.borderRightWidth) || 0);
const borderY =
(Number.parseFloat(style.borderTopWidth) || 0) +
(Number.parseFloat(style.borderBottomWidth) || 0);
return {
height: height + paddingY + borderY,
width: width + paddingX + borderX,
};
}
function readCornerRadii(
element: HTMLElement,
width: number,
height: number,
): CornerRadii {
const style = window.getComputedStyle(element);
const inlineStyle = element.style;
return {
topLeft: parseCornerRadius(
style.borderTopLeftRadius ||
inlineStyle.borderTopLeftRadius ||
inlineStyle.borderRadius,
width,
height,
),
topRight: parseCornerRadius(
style.borderTopRightRadius ||
inlineStyle.borderTopRightRadius ||
inlineStyle.borderRadius,
width,
height,
),
bottomRight: parseCornerRadius(
style.borderBottomRightRadius ||
inlineStyle.borderBottomRightRadius ||
inlineStyle.borderRadius,
width,
height,
),
bottomLeft: parseCornerRadius(
style.borderBottomLeftRadius ||
inlineStyle.borderBottomLeftRadius ||
inlineStyle.borderRadius,
width,
height,
),
};
}
function distributeAndNormalize(
radii: CornerRadii,
width: number,
height: number,
): NormalizedCorners {
const radiusMap: CornerRadii = { ...radii };
const budgetMap: Record<Corner, number> = {
topLeft: -1,
topRight: -1,
bottomRight: -1,
bottomLeft: -1,
};
(Object.entries(radiusMap) as Array<[Corner, number]>)
.sort(([, first], [, second]) => second - first)
.forEach(([corner, radius]) => {
const budget = Math.min(
...ADJACENTS_BY_CORNER[corner].map((adjacent) => {
const adjacentRadius = radiusMap[adjacent.corner];
if (radius === 0 && adjacentRadius === 0) {
return 0;
}
const adjacentBudget = budgetMap[adjacent.corner];
const sideLength =
adjacent.side === "top" || adjacent.side === "bottom"
? width
: height;
if (adjacentBudget >= 0) {
return sideLength - adjacentBudget;
}
return (radius / (radius + adjacentRadius)) * sideLength;
}),
);
budgetMap[corner] = budget;
radiusMap[corner] = Math.min(radius, budget);
});
return {
topLeft: {
radius: radiusMap.topLeft,
roundingAndSmoothingBudget: budgetMap.topLeft,
},
topRight: {
radius: radiusMap.topRight,
roundingAndSmoothingBudget: budgetMap.topRight,
},
bottomRight: {
radius: radiusMap.bottomRight,
roundingAndSmoothingBudget: budgetMap.bottomRight,
},
bottomLeft: {
radius: radiusMap.bottomLeft,
roundingAndSmoothingBudget: budgetMap.bottomLeft,
},
};
}
function getPathParamsForCorner({
cornerRadius,
cornerSmoothing,
roundingAndSmoothingBudget,
}: {
cornerRadius: number;
cornerSmoothing: number;
roundingAndSmoothingBudget: number;
}): CornerPathParams {
if (cornerRadius <= 0) {
return {
a: 0,
b: 0,
c: 0,
d: 0,
p: 0,
arcSectionLength: 0,
cornerRadius: 0,
};
}
let p = (1 + cornerSmoothing) * cornerRadius;
const arcMeasure = 90 * (1 - cornerSmoothing);
const arcSectionLength =
Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2);
const angleAlpha = (90 - arcMeasure) / 2;
const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2));
const angleBeta = 45 * cornerSmoothing;
const c = p3ToP4Distance * Math.cos(toRadians(angleBeta));
const d = c * Math.tan(toRadians(angleBeta));
let b = (p - arcSectionLength - c - d) / 3;
let a = 2 * b;
if (p > roundingAndSmoothingBudget) {
const p1ToP3MaxDistance =
roundingAndSmoothingBudget - d - arcSectionLength - c;
const minA = p1ToP3MaxDistance / 6;
const maxB = p1ToP3MaxDistance - minA;
b = Math.min(b, maxB);
a = p1ToP3MaxDistance - b;
p = Math.min(p, roundingAndSmoothingBudget);
}
return { a, b, c, d, p, arcSectionLength, cornerRadius };
}
function buildCorner(
corner: NormalizedCorner,
smoothing: number,
): { p: number; pathSegment: (corner: Corner) => string } {
const params = getPathParamsForCorner({
cornerRadius: corner.radius,
cornerSmoothing: smoothing,
roundingAndSmoothingBudget: corner.roundingAndSmoothingBudget,
});
if (params.cornerRadius <= 0) {
return { p: 0, pathSegment: () => "" };
}
return {
p: params.p,
pathSegment: (name) => {
switch (name) {
case "topRight":
return drawTopRightPath(params);
case "bottomRight":
return drawBottomRightPath(params);
case "bottomLeft":
return drawBottomLeftPath(params);
case "topLeft":
return drawTopLeftPath(params);
}
},
};
}
function drawTopRightPath({
cornerRadius,
a,
b,
c,
d,
arcSectionLength,
}: CornerPathParams): string {
return rounded`c ${a} 0 ${a + b} 0 ${a + b + c} ${d} a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength} c ${d} ${c} ${d} ${b + c} ${d} ${a + b + c}`;
}
function drawBottomRightPath({
cornerRadius,
a,
b,
c,
d,
arcSectionLength,
}: CornerPathParams): string {
return rounded`c 0 ${a} 0 ${a + b} ${-d} ${a + b + c} a ${cornerRadius} ${cornerRadius} 0 0 1 ${-arcSectionLength} ${arcSectionLength} c ${-c} ${d} ${-(b + c)} ${d} ${-(a + b + c)} ${d}`;
}
function drawBottomLeftPath({
cornerRadius,
a,
b,
c,
d,
arcSectionLength,
}: CornerPathParams): string {
return rounded`c ${-a} 0 ${-(a + b)} 0 ${-(a + b + c)} ${-d} a ${cornerRadius} ${cornerRadius} 0 0 1 ${-arcSectionLength} ${-arcSectionLength} c ${-d} ${-c} ${-d} ${-(b + c)} ${-d} ${-(a + b + c)}`;
}
function drawTopLeftPath({
cornerRadius,
a,
b,
c,
d,
arcSectionLength,
}: CornerPathParams): string {
return rounded`c 0 ${-a} 0 ${-(a + b)} ${d} ${-(a + b + c)} a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${-arcSectionLength} c ${c} ${-d} ${b + c} ${-d} ${a + b + c} ${-d}`;
}
export function generateSmoothCornerPath(
width: number,
height: number,
radii: CornerRadii,
smoothing = SMOOTH_CORNER_SMOOTHING,
) {
if (width <= 0 || height <= 0) {
return "M 0 0 H 0 V 0 H 0 Z";
}
const normalized = distributeAndNormalize(radii, width, height);
const topLeft = buildCorner(normalized.topLeft, smoothing);
const topRight = buildCorner(normalized.topRight, smoothing);
const bottomRight = buildCorner(normalized.bottomRight, smoothing);
const bottomLeft = buildCorner(normalized.bottomLeft, smoothing);
const seg = (segment: string) => (segment.length > 0 ? ` ${segment}` : "");
return (
`M ${round(topLeft.p)} 0` +
` L ${round(width - topRight.p)} 0` +
seg(topRight.pathSegment("topRight")) +
` L ${round(width)} ${round(bottomRight.p)}` +
` L ${round(width)} ${round(height - bottomRight.p)}` +
seg(bottomRight.pathSegment("bottomRight")) +
` L ${round(width - bottomLeft.p)} ${round(height)}` +
` L ${round(bottomLeft.p)} ${round(height)}` +
seg(bottomLeft.pathSegment("bottomLeft")) +
` L 0 ${round(height - topLeft.p)}` +
` L 0 ${round(topLeft.p)}` +
seg(topLeft.pathSegment("topLeft")) +
" Z"
);
}
export function generateSmoothCornerClipPath(
width: number,
height: number,
radii: CornerRadii,
smoothing = SMOOTH_CORNER_SMOOTHING,
) {
return `path("${generateSmoothCornerPath(width, height, radii, smoothing)}")`;
}
export function useSmoothCorners<T extends HTMLElement>(
ref: React.RefObject<T | null>,
options: SmoothCornersOptions = {},
) {
const enabled = options.enabled ?? true;
const smoothing = options.smoothing ?? SMOOTH_CORNER_SMOOTHING;
React.useLayoutEffect(() => {
if (!enabled || typeof window === "undefined") return;
const element = ref.current;
if (!element) return;
if (!supportsClipPathPath()) return;
const savedClipPath = element.style.clipPath;
const savedWebkitClipPath =
element.style.getPropertyValue("-webkit-clip-path");
let animationFrame = 0;
let lastClipPath = "";
const sync = () => {
const { width, height } = getLayoutSize(element);
if (width <= 0 || height <= 0) return;
const clipPath = generateSmoothCornerClipPath(
width,
height,
readCornerRadii(element, width, height),
smoothing,
);
if (clipPath === lastClipPath) return;
element.style.clipPath = clipPath;
element.style.setProperty("-webkit-clip-path", clipPath);
element.dataset.smoothCorners = "";
element.dataset.smoothCornersSmoothing = String(smoothing);
lastClipPath = clipPath;
};
const scheduleSync = () => {
if (animationFrame) return;
animationFrame = window.requestAnimationFrame(() => {
animationFrame = 0;
sync();
});
};
sync();
const mutationObserver = new MutationObserver(scheduleSync);
mutationObserver.observe(element, {
attributeFilter: ["class", "style"],
attributes: true,
});
let resizeObserver: ResizeObserver | undefined;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(scheduleSync);
resizeObserver.observe(element);
} else {
window.addEventListener("resize", scheduleSync);
}
return () => {
if (animationFrame) {
window.cancelAnimationFrame(animationFrame);
}
mutationObserver.disconnect();
resizeObserver?.disconnect();
if (!resizeObserver) {
window.removeEventListener("resize", scheduleSync);
}
element.style.clipPath = savedClipPath;
if (savedWebkitClipPath) {
element.style.setProperty("-webkit-clip-path", savedWebkitClipPath);
} else {
element.style.removeProperty("-webkit-clip-path");
}
delete element.dataset.smoothCorners;
delete element.dataset.smoothCornersSmoothing;
};
}, [enabled, ref, smoothing]);
}