mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Apply smooth corners to inline media (#1422)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"\)$/);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user