1import type { SnapShotSource } from "@t3tools/contracts";
2import { ImageIcon, TextIcon } from "lucide-react";
3import { Suspense } from "react";
4
5import { useTheme } from "../../hooks/useTheme";
6import { cn } from "../../lib/utils";
7import { RenderErrorBoundary } from "../RenderErrorBoundary";
8import { Button } from "../ui/button";
9import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11import { HighlightedTokens } from "./HighlightedTokens";
12
13export const SNAP_SHOT_ATTACHMENT_FRAME_CLASS =
14 "relative h-28 w-52 max-w-full overflow-hidden rounded-lg border border-border/80";
15
16export interface SnapShotAccessibilityDetails {
17 content: string;
18 format: "json" | "text";
19}
20
21export function SnapShotAccessibilityData({
22 details,
23 className,
24}: {
25 details: SnapShotAccessibilityDetails;
26 className?: string;
27}) {
28 const { resolvedTheme } = useTheme();
29 const content =
30 details.format === "json" ? (
31 <RenderErrorBoundary fallback={details.content}>
32 <Suspense fallback={details.content}>
33 <HighlightedTokens code={details.content} language="json" theme={resolvedTheme} />
34 </Suspense>
35 </RenderErrorBoundary>
36 ) : (
37 details.content
38 );
39
40 return (
41 <pre
42 className={cn(
43 "overflow-auto whitespace-pre-wrap break-words font-mono focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
44 className,
45 )}
46 tabIndex={0}
47 >
48 {content}
49 </pre>
50 );
51}
52
53export function snapShotAccessibilityDetails(
54 source: SnapShotSource,
55): SnapShotAccessibilityDetails | undefined {
56 if (source.accessibility?.format === "element-tree") {
57 return {
58 content: JSON.stringify(source.accessibility, null, 2),
59 format: "json",
60 };
61 }
62
63 const text =
64 source.accessibility?.format === "flat-text"
65 ? source.accessibility.text.trim()
66 : source.accessibleText?.trim();
67 return text ? { content: text, format: "text" } : undefined;
68}
69
70export function snapShotIncludesAccessibility(source: SnapShotSource): boolean {
71 return Boolean(source.accessibility || source.accessibleText?.trim());
72}
73
74export function SnapShotContentsButton({
75 source,
76 className,
77 side = "top",
78}: {
79 source: SnapShotSource;
80 className?: string;
81 side?: "top" | "right" | "bottom" | "left";
82}) {
83 const includesAccessibility = snapShotIncludesAccessibility(source);
84 const ContentsIcon = includesAccessibility ? TextIcon : ImageIcon;
85 const accessibilityDetails = snapShotAccessibilityDetails(source);
86 const tooltip = includesAccessibility ? "Accessibility data" : "No accessibility data";
87
88 return (
89 <Popover>
90 <Tooltip>
91 <TooltipTrigger
92 render={
93 <PopoverTrigger
94 render={
95 <Button
96 aria-label={
97 includesAccessibility ? "View accessibility data" : "No accessibility data"
98 }
99 className={className}
100 onClick={(event) => event.stopPropagation()}
101 size="icon-micro"
102 variant="overlay"
103 />
104 }
105 />
106 }
107 >
108 <ContentsIcon className="size-3" aria-hidden="true" />
109 </TooltipTrigger>
110 <TooltipPopup side={side}>{tooltip}</TooltipPopup>
111 </Tooltip>
112 <PopoverPopup side={side} align="center" width="md">
113 <div className="max-h-[min(28rem,70vh)] space-y-2 overflow-y-auto">
114 <PopoverTitle>Accessibility data</PopoverTitle>
115 {accessibilityDetails ? (
116 <SnapShotAccessibilityData
117 details={accessibilityDetails}
118 className="max-h-64 rounded-md border border-border/70 bg-muted/45 p-2.5 text-2xs leading-4"
119 />
120 ) : includesAccessibility ? (
121 <div className="rounded-md border border-border/70 bg-muted/45 p-2.5 text-muted-foreground text-xs leading-4">
122 Structured accessibility elements were included, but they have no readable names or
123 values.
124 </div>
125 ) : (
126 <div className="rounded-md border border-border/70 bg-muted/45 p-2.5 text-muted-foreground text-xs leading-4">
127 The app or capture backend did not provide verified accessibility data.
128 </div>
129 )}
130 </div>
131 </PopoverPopup>
132 </Popover>
133 );
134}
135
136export function SnapShotAttachmentDetails({
137 source,
138 className,
139}: {
140 source: SnapShotSource;
141 className?: string;
142}) {
143 return (
144 <div
145 className={cn(
146 "pointer-events-none absolute inset-x-0 bottom-0 flex min-w-0 items-center gap-1.5 bg-linear-to-t from-black/85 via-black/55 to-transparent px-2.5 pb-2 pt-6",
147 className,
148 )}
149 >
150 {source.appIconDataUrl ? (
151 <img src={source.appIconDataUrl} alt="" className="size-7 shrink-0 rounded-md" />
152 ) : (
153 <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-white/20 text-3xs font-medium text-white uppercase">
154 {source.appName.slice(0, 1)}
155 </div>
156 )}
157 <div className="min-w-0 flex-1">
158 <div className="flex min-w-0 items-center gap-1.5 text-2xs font-medium leading-3.5 text-white">
159 <span className="truncate">{source.appName}</span>
160 <SnapShotContentsButton source={source} className="pointer-events-auto" />
161 </div>
162 <div className="truncate text-3xs leading-3.5 text-white/70">
163 {source.windowTitle || "Captured window"}
164 </div>
165 </div>
166 </div>
167 );
168}