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
@@ -1,7 +1,9 @@
import * as React from "react";
import { FileDiff, Maximize2 } from "lucide-react";
import { isSafeUrl } from "@/shared/lib/url";
import { Button } from "@/shared/ui/button";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { DiffViewer } from "./DiffViewer";
@@ -32,6 +34,9 @@ export default function DiffMessage({
truncated,
onExpand,
}: DiffMessageProps) {
const diffCardRef = React.useRef<HTMLDivElement | null>(null);
useSmoothCorners(diffCardRef);
const safeRepoUrl = isSafeUrl(repoUrl) ? repoUrl : undefined;
const commitUrl =
@@ -40,7 +45,10 @@ export default function DiffMessage({
const shortSha = commitSha ? commitSha.slice(0, 7) : undefined;
return (
<div className="rounded-xl border border-border/70 bg-card/60 overflow-hidden text-sm">
<div
ref={diffCardRef}
className="overflow-hidden rounded-2xl border border-border/70 bg-card/60 text-sm"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/50 bg-muted/40">
<FileDiff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-mono text-xs text-foreground/80">
+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]);
}
+5 -1
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
// Exercises the generic file-attachment UI contract end-to-end through the
// mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the
@@ -38,13 +39,16 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => {
// Send the (attachment-only) message.
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
// A FileCard renders in the timeline: a button carrying the filename. It
// downloads via the native `download_file` command (HTTP inside the app's
// tunnel + save dialog), NOT a plain `<a download>` link — a bare link
// escapes the webview to the OS browser and hits a corporate CDN page.
const card = page.getByTestId("file-card");
const card = page.getByTestId("file-card").last();
await expect(card).toBeVisible();
await expectCornerRadiusPx(card, 16);
await expectSmoothCorners(card);
await expect(card).toContainText("quarterly-report.pdf");
await card.click();
@@ -1,6 +1,7 @@
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const IMAGE_SHAS = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
const SPOILER_VISIBLE_SHA = "d".repeat(64);
@@ -125,11 +126,19 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
const triggers = row.getByTestId("message-image-lightbox-trigger");
await expect(triggers).toHaveCount(3);
await expectCornerRadiusPx(triggers.first(), 16);
await expectCornerRadiusPx(triggers.first().locator("img"), 16);
await expectSmoothCorners(triggers.first().locator("img"));
await triggers.first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`)).toBeVisible();
const lightboxSurface = page
.locator("[data-image-lightbox-frame] > div > div")
.first();
await expectCornerRadiusPx(lightboxSurface, 16);
await expectSmoothCorners(lightboxSurface);
await expect(
page.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
@@ -162,6 +171,10 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
if (!closingFrameBox) {
throw new Error("Expected lightbox frame to remain mounted while closing");
}
await expectCornerRadiusPx(
page.locator("[data-image-lightbox-frame] > div > div").first(),
16,
);
expect(Math.abs(closingFrameBox.x - currentThumbnailBox.x)).toBeLessThan(2);
expect(Math.abs(closingFrameBox.y - currentThumbnailBox.y)).toBeLessThan(2);
+7 -3
View File
@@ -1,6 +1,7 @@
import { expect, test, type Locator } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
import { openSettings } from "../helpers/settings";
async function expectThreadReplyUnobscured(row: Locator) {
@@ -176,9 +177,10 @@ test("supported link previews keep the message link visible", async ({
await expect(
row.getByRole("link", { exact: true, name: previewUrl }),
).toBeVisible();
await expect(
row.locator('[data-link-preview="github-pull-request"]'),
).toBeVisible();
const previewCard = row.locator('[data-link-preview="github-pull-request"]');
await expect(previewCard).toBeVisible();
await expectCornerRadiusPx(previewCard, 16);
await expectSmoothCorners(previewCard);
});
test("send multiple messages in sequence", async ({ page }) => {
@@ -231,6 +233,8 @@ test("copy a rendered code block and paste it back as code", async ({
const codeBlock = page.locator("[data-code-block]");
await expect(codeBlock).toHaveCount(1);
await expectCornerRadiusPx(codeBlock.locator("pre"), 16);
await expectSmoothCorners(codeBlock.locator("pre"));
const copyButton = page.getByLabel("Copy code block");
await expect(copyButton).toHaveCSS("opacity", "0");
@@ -1,6 +1,7 @@
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const VIDEO_SHA = "b".repeat(64);
const VIDEO_URL = `http://localhost:3000/media/${VIDEO_SHA}.mp4`;
@@ -251,6 +252,9 @@ test("video upload previews use poster frames and inline videos open review mode
}
const inlinePlayer = page.getByTestId("video-player").last();
const inlineSurface = inlinePlayer.locator("[data-smooth-corners]").first();
await expectCornerRadiusPx(inlineSurface, 16);
await expectSmoothCorners(inlineSurface);
const inlineVideo = inlinePlayer.locator("video");
await inlinePlayer.getByRole("button", { name: "Play video" }).click();
+101
View File
@@ -0,0 +1,101 @@
import { expect, type Locator } from "@playwright/test";
export async function expectCornerRadiusPx(
locator: Locator,
expectedRadiusPx: number,
) {
const measurement = await locator.evaluate((element) => {
const style = window.getComputedStyle(element);
const rootFontSize = Number.parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
);
const resolveLength = (value: string) => {
const probe = document.createElement("div");
for (const sourceStyle of [
window.getComputedStyle(document.documentElement),
style,
]) {
for (let i = 0; i < sourceStyle.length; i += 1) {
const name = sourceStyle.item(i);
if (name.startsWith("--")) {
probe.style.setProperty(name, sourceStyle.getPropertyValue(name));
}
}
}
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
probe.style.width = value;
document.body.append(probe);
const resolved = window.getComputedStyle(probe).width;
probe.remove();
return resolved;
};
const toPx = (value: string): number => {
const trimmed = value.trim();
if (/^-?\d+(?:\.\d+)?px$/.test(trimmed)) {
return Number.parseFloat(trimmed);
}
if (/^-?\d+(?:\.\d+)?rem$/.test(trimmed)) {
return Number.parseFloat(trimmed) * rootFontSize;
}
const resolved = resolveLength(trimmed);
if (resolved !== trimmed) {
return toPx(resolved);
}
return Number.parseFloat(resolved);
};
const rawRadius =
style.borderTopLeftRadius ||
style.getPropertyValue("border-top-left-radius") ||
style.borderRadius ||
style.getPropertyValue("border-radius") ||
(element instanceof HTMLElement
? element.style.borderTopLeftRadius || element.style.borderRadius
: "");
const radius = toPx(rawRadius);
if (!Number.isFinite(radius)) {
throw new Error(`Could not resolve border radius from "${rawRadius}".`);
}
return {
className: element.getAttribute("class") ?? "",
radius,
rawRadius,
};
});
expect(
measurement.radius,
`Expected ${expectedRadiusPx}px corner radius, got ${measurement.rawRadius} on class "${measurement.className}".`,
).toBeCloseTo(expectedRadiusPx, 0);
}
export async function expectSmoothCorners(
locator: Locator,
expectedSmoothing = 0.6,
) {
await expect
.poll(async () =>
locator.evaluate((element, smoothing) => {
if (!(element instanceof HTMLElement)) {
return false;
}
const clipPath =
element.style.clipPath ||
element.style.getPropertyValue("-webkit-clip-path");
return (
element.dataset.smoothCorners === "" &&
element.dataset.smoothCornersSmoothing === String(smoothing) &&
clipPath.startsWith('path("M ') &&
clipPath.includes(" a ")
);
}, expectedSmoothing),
)
.toBe(true);
}