@copilotkit/react-core 1.56.3 → 1.56.4-canary.1777531098

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.3",
3
+ "version": "1.56.4-canary.1777531098",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -52,8 +52,8 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@ag-ui/client": "0.0.52",
56
- "@ag-ui/core": "0.0.52",
55
+ "@ag-ui/client": "0.0.53",
56
+ "@ag-ui/core": "0.0.53",
57
57
  "@jetbrains/websandbox": "^1.1.3",
58
58
  "@lit-labs/react": "^2.0.2",
59
59
  "@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -73,11 +73,11 @@
73
73
  "untruncate-json": "^0.0.1",
74
74
  "use-stick-to-bottom": "^1.1.1",
75
75
  "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/core": "1.56.3",
77
- "@copilotkit/a2ui-renderer": "1.56.3",
78
- "@copilotkit/runtime-client-gql": "1.56.3",
79
- "@copilotkit/web-inspector": "1.56.3",
80
- "@copilotkit/shared": "1.56.3"
76
+ "@copilotkit/a2ui-renderer": "1.56.4-canary.1777531098",
77
+ "@copilotkit/web-inspector": "1.56.4-canary.1777531098",
78
+ "@copilotkit/shared": "1.56.4-canary.1777531098",
79
+ "@copilotkit/runtime-client-gql": "1.56.4-canary.1777531098",
80
+ "@copilotkit/core": "1.56.4-canary.1777531098"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -1,13 +1,13 @@
1
- import React, { useCallback, useEffect, useId, useRef, useState } from "react";
2
- import { createPortal, flushSync } from "react-dom";
1
+ import React, { useEffect, useState } from "react";
3
2
  import type { Attachment } from "@copilotkit/shared";
4
3
  import {
5
4
  formatFileSize,
6
5
  getSourceUrl,
7
6
  getDocumentIcon,
8
7
  } from "@copilotkit/shared";
9
- import { Play, X } from "lucide-react";
8
+ import { Play } from "lucide-react";
10
9
  import { cn } from "../../lib/utils";
10
+ import { Lightbox, useLightbox } from "./Lightbox";
11
11
 
12
12
  interface CopilotChatAttachmentQueueProps {
13
13
  attachments: Attachment[];
@@ -21,7 +21,10 @@ export const CopilotChatAttachmentQueue: React.FC<
21
21
  if (attachments.length === 0) return null;
22
22
 
23
23
  return (
24
- <div className={cn("cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2", className)}>
24
+ <div
25
+ data-testid="copilot-attachment-queue"
26
+ className={cn("cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2", className)}
27
+ >
25
28
  {attachments.map((attachment) => {
26
29
  const isMedia =
27
30
  attachment.type === "image" || attachment.type === "video";
@@ -85,116 +88,6 @@ function AttachmentPreview({ attachment }: { attachment: Attachment }) {
85
88
  }
86
89
  }
87
90
 
88
- // ---------------------------------------------------------------------------
89
- // Lightbox – fullscreen overlay for images and videos (portal to body)
90
- // Uses the View Transition API when available for a smooth thumbnail-to-
91
- // fullscreen morph; falls back to a simple opacity fade.
92
- // ---------------------------------------------------------------------------
93
-
94
- interface LightboxProps {
95
- onClose: () => void;
96
- children: React.ReactNode;
97
- }
98
-
99
- function Lightbox({ onClose, children }: LightboxProps) {
100
- useEffect(() => {
101
- const handleKey = (e: KeyboardEvent) => {
102
- if (e.key === "Escape") onClose();
103
- };
104
- document.addEventListener("keydown", handleKey);
105
- return () => document.removeEventListener("keydown", handleKey);
106
- }, [onClose]);
107
-
108
- if (typeof document === "undefined") return null;
109
-
110
- return createPortal(
111
- <div
112
- className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
113
- onClick={onClose}
114
- >
115
- <button
116
- onClick={onClose}
117
- className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
118
- aria-label="Close preview"
119
- >
120
- <X className="cpk:w-5 cpk:h-5" />
121
- </button>
122
-
123
- <div onClick={(e) => e.stopPropagation()}>{children}</div>
124
- </div>,
125
- document.body,
126
- );
127
- }
128
-
129
- type DocWithVT = Document & {
130
- startViewTransition?: (cb: () => void) => { finished: Promise<void> };
131
- };
132
-
133
- /**
134
- * Hook that manages lightbox open/close and uses the View Transition API to
135
- * morph the thumbnail into fullscreen content.
136
- *
137
- * The trick: `view-transition-name` must live on exactly ONE element at a time.
138
- * - Old state (thumbnail visible): name is on the thumbnail.
139
- * - New state (lightbox visible): name moves to the lightbox content.
140
- * `flushSync` ensures React commits the DOM change synchronously inside the
141
- * `startViewTransition` callback so the API can snapshot old → new correctly.
142
- */
143
- function useLightbox() {
144
- const thumbnailRef = useRef<HTMLElement>(null);
145
- const [open, setOpen] = useState(false);
146
- const vtName = useId();
147
-
148
- const openLightbox = useCallback(() => {
149
- const thumb = thumbnailRef.current;
150
- const doc = document as DocWithVT;
151
-
152
- if (doc.startViewTransition && thumb) {
153
- // Old snapshot: name on the thumbnail
154
- thumb.style.viewTransitionName = vtName;
155
-
156
- doc.startViewTransition(() => {
157
- // New snapshot: remove from thumb (lightbox content will have it)
158
- thumb.style.viewTransitionName = "";
159
- flushSync(() => setOpen(true));
160
- });
161
- } else {
162
- setOpen(true);
163
- }
164
- }, []);
165
-
166
- const closeLightbox = useCallback(() => {
167
- const thumb = thumbnailRef.current;
168
- const doc = document as DocWithVT;
169
-
170
- if (doc.startViewTransition && thumb) {
171
- const transition = doc.startViewTransition(() => {
172
- // New snapshot: name back on thumbnail
173
- flushSync(() => setOpen(false));
174
- thumb.style.viewTransitionName = vtName;
175
- });
176
- // Clean up the name after animation finishes (or fails)
177
- transition.finished
178
- .then(() => {
179
- thumb.style.viewTransitionName = "";
180
- })
181
- .catch(() => {
182
- thumb.style.viewTransitionName = "";
183
- });
184
- } else {
185
- setOpen(false);
186
- }
187
- }, []);
188
-
189
- return {
190
- thumbnailRef,
191
- vtName,
192
- open,
193
- openLightbox,
194
- closeLightbox,
195
- };
196
- }
197
-
198
91
  // ---------------------------------------------------------------------------
199
92
  // Image
200
93
  // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import React, { memo, useState } from "react";
2
2
  import type { InputContentSource } from "@copilotkit/shared";
3
3
  import { getSourceUrl, getDocumentIcon } from "@copilotkit/shared";
4
4
  import { cn } from "../../lib/utils";
5
+ import { Lightbox, useLightbox } from "./Lightbox";
5
6
 
6
7
  interface CopilotChatAttachmentRendererProps {
7
8
  type: "image" | "audio" | "video" | "document";
@@ -18,6 +19,8 @@ const ImageAttachment = memo(function ImageAttachment({
18
19
  className?: string;
19
20
  }) {
20
21
  const [error, setError] = useState(false);
22
+ const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
23
+ useLightbox();
21
24
 
22
25
  if (error) {
23
26
  return (
@@ -33,12 +36,29 @@ const ImageAttachment = memo(function ImageAttachment({
33
36
  }
34
37
 
35
38
  return (
36
- <img
37
- src={src}
38
- alt="Image attachment"
39
- className={cn("cpk:max-w-full cpk:h-auto cpk:rounded-lg", className)}
40
- onError={() => setError(true)}
41
- />
39
+ <>
40
+ <img
41
+ ref={thumbnailRef as React.Ref<HTMLImageElement>}
42
+ src={src}
43
+ alt="Image attachment"
44
+ className={cn(
45
+ "cpk:max-w-[80px] cpk:max-h-[80px] cpk:w-auto cpk:h-auto cpk:rounded-xl cpk:object-cover cpk:cursor-pointer cpk:bg-muted",
46
+ className,
47
+ )}
48
+ onClick={openLightbox}
49
+ onError={() => setError(true)}
50
+ />
51
+ {open && (
52
+ <Lightbox onClose={closeLightbox}>
53
+ <img
54
+ style={{ viewTransitionName: vtName }}
55
+ src={src}
56
+ alt="Image attachment"
57
+ className="cpk:max-w-[90vw] cpk:max-h-[90vh] cpk:object-contain cpk:rounded-lg"
58
+ />
59
+ </Lightbox>
60
+ )}
61
+ </>
42
62
  );
43
63
  });
44
64
 
@@ -217,9 +217,8 @@ export function CopilotChatUserMessage({
217
217
  data-message-id={message.id}
218
218
  {...props}
219
219
  >
220
- {BoundMessageRenderer}
221
220
  {mediaParts.length > 0 && (
222
- <div className="cpk:flex cpk:flex-col cpk:items-end cpk:gap-2 cpk:mt-2">
221
+ <div className="cpk:flex cpk:flex-row cpk:flex-wrap cpk:justify-end cpk:gap-2 cpk:mb-2">
223
222
  {mediaParts.map((part, index) => (
224
223
  <CopilotChatAttachmentRenderer
225
224
  key={index}
@@ -230,6 +229,7 @@ export function CopilotChatUserMessage({
230
229
  ))}
231
230
  </div>
232
231
  )}
232
+ {BoundMessageRenderer}
233
233
  {BoundToolbar}
234
234
  </div>
235
235
  );
@@ -6,17 +6,19 @@ import React, {
6
6
  useLayoutEffect,
7
7
  } from "react";
8
8
  import { ScrollElementContext } from "./scroll-element-context";
9
- import { WithSlots, SlotValue, renderSlot } from "../../lib/slots";
9
+ import type { WithSlots, SlotValue } from "../../lib/slots";
10
+ import { renderSlot } from "../../lib/slots";
10
11
  import CopilotChatMessageView from "./CopilotChatMessageView";
11
- import CopilotChatInput, {
12
+ import type {
12
13
  CopilotChatInputProps,
13
14
  CopilotChatInputMode,
14
15
  } from "./CopilotChatInput";
16
+ import CopilotChatInput from "./CopilotChatInput";
15
17
  import CopilotChatSuggestionView, {
16
18
  CopilotChatSuggestionViewProps,
17
19
  } from "./CopilotChatSuggestionView";
18
- import { Suggestion } from "@copilotkit/core";
19
- import { Message } from "@ag-ui/core";
20
+ import type { Suggestion } from "@copilotkit/core";
21
+ import type { Message } from "@ag-ui/core";
20
22
  import type { Attachment } from "@copilotkit/shared";
21
23
  import { CopilotChatAttachmentQueue } from "./CopilotChatAttachmentQueue";
22
24
  import { twMerge } from "tailwind-merge";
@@ -37,29 +39,8 @@ import { normalizeAutoScroll } from "./normalize-auto-scroll";
37
39
  import type { AutoScrollMode } from "./normalize-auto-scroll";
38
40
  import { usePinToSend } from "../../hooks/use-pin-to-send";
39
41
 
40
- // Height of the feather gradient overlay (h-24 = 6rem = 96px)
41
- const FEATHER_HEIGHT = 96;
42
-
43
- // Pin-to-send uses a softer, shorter feather than pin-to-bottom so readable
44
- // content isn't obscured (h-12 = 3rem = 48px).
45
- const PIN_TO_SEND_FEATHER_HEIGHT = 48;
46
-
47
- const PinToSendSoftFeather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
48
- className,
49
- style,
50
- ...props
51
- }) => (
52
- <div
53
- className={cn(
54
- "cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-12 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
55
- "cpk:from-white cpk:to-transparent",
56
- "cpk:dark:from-[rgb(33,33,33)]",
57
- className,
58
- )}
59
- style={style}
60
- {...props}
61
- />
62
- );
42
+ // Vertical gap between the scroll-to-bottom button and the input container.
43
+ const SCROLL_BUTTON_OFFSET = 16;
63
44
 
64
45
  // Forward declaration for WelcomeScreen component type
65
46
  export type WelcomeScreenProps = WithSlots<
@@ -186,7 +167,17 @@ export function CopilotChatView({
186
167
  className,
187
168
  ...props
188
169
  }: CopilotChatViewProps) {
189
- const inputContainerRef = useRef<HTMLDivElement>(null);
170
+ // Element-as-state via callback ref. The overlay wrapper only renders on the
171
+ // chat-view branch (the welcome-screen branch omits it), so a plain
172
+ // useRef + `[]` useEffect would observe `null` on mount whenever the chat
173
+ // starts on the welcome screen and never re-attach after the user sends
174
+ // their first message — leaving inputContainerHeight at 0 and the scroll
175
+ // content's reserved bottom padding at 32px instead of ~input height. The
176
+ // result is the last messages scrolling underneath the absolute-positioned
177
+ // input pill. Subscribing to element state lets the observer attach (and
178
+ // detach) reactively as the overlay mounts/unmounts.
179
+ const [inputContainerEl, setInputContainerEl] =
180
+ useState<HTMLDivElement | null>(null);
190
181
  const [inputContainerHeight, setInputContainerHeight] = useState(0);
191
182
  const [isResizing, setIsResizing] = useState(false);
192
183
  const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -197,8 +188,14 @@ export function CopilotChatView({
197
188
 
198
189
  // Track input container height changes
199
190
  useEffect(() => {
200
- const element = inputContainerRef.current;
201
- if (!element) return;
191
+ const element = inputContainerEl;
192
+ if (!element) {
193
+ // Reset measured height so the scroll content's paddingBottom doesn't
194
+ // hold a stale value if the overlay unmounts (e.g. messages cleared
195
+ // and the welcome screen returns).
196
+ setInputContainerHeight(0);
197
+ return;
198
+ }
202
199
 
203
200
  const resizeObserver = new ResizeObserver((entries) => {
204
201
  for (const entry of entries) {
@@ -237,7 +234,7 @@ export function CopilotChatView({
237
234
  clearTimeout(resizeTimeoutRef.current);
238
235
  }
239
236
  };
240
- }, []);
237
+ }, [inputContainerEl]);
241
238
 
242
239
  const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
243
240
  messages,
@@ -258,11 +255,11 @@ export function CopilotChatView({
258
255
  onAddFile,
259
256
  positioning: "static",
260
257
  keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
261
- containerRef: inputContainerRef,
262
258
  showDisclaimer: true,
263
- // This input is the last flex child of the chat column, so it sits at
264
- // the bottom where the license banner would overlap. The welcome-screen
265
- // input (below) intentionally omits this flag.
259
+ // The parent overlay wrapper handles absolute bottom-0 positioning.
260
+ // `bottomAnchored` still triggers the license-banner offset padding
261
+ // inside CopilotChatInput. The welcome-screen input (below) intentionally
262
+ // omits this flag.
266
263
  bottomAnchored: true,
267
264
  ...(disclaimer !== undefined ? { disclaimer } : {}),
268
265
  } as CopilotChatInputProps);
@@ -271,6 +268,11 @@ export function CopilotChatView({
271
268
  // Otherwise, mid-replay (bootstrap stream from /connect) or mid-run, the
272
269
  // suggestions would render against a still-assembling message tree and
273
270
  // visibly jump as each final text chunk reflows the layout.
271
+ //
272
+ // `available: "always"` controls *eligibility windows* (welcome screen vs
273
+ // after first message), not whether to render through these transitions —
274
+ // we still wait for the connect/run to settle and the end-of-run reload
275
+ // to repopulate against the new context.
274
276
  const hasSuggestions =
275
277
  !isConnecting &&
276
278
  !isRunning &&
@@ -291,8 +293,9 @@ export function CopilotChatView({
291
293
  isResizing,
292
294
  children: (
293
295
  <div
296
+ data-testid="copilot-scroll-content"
294
297
  style={{
295
- paddingBottom: `${hasSuggestions ? 4 : 32}px`,
298
+ paddingBottom: `${inputContainerHeight + (hasSuggestions ? 4 : 32)}px`,
296
299
  }}
297
300
  >
298
301
  <div className="cpk:max-w-3xl cpk:mx-auto">
@@ -415,17 +418,22 @@ export function CopilotChatView({
415
418
  {dragOver && <DropOverlay />}
416
419
  {BoundScrollView}
417
420
 
418
- <div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full">
421
+ <div
422
+ ref={setInputContainerEl}
423
+ data-testid="copilot-input-overlay"
424
+ className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
425
+ >
419
426
  {attachments && attachments.length > 0 && (
420
- <CopilotChatAttachmentQueue
421
- attachments={attachments}
422
- onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
423
- className="cpk:px-4"
424
- />
427
+ <div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full cpk:pointer-events-auto">
428
+ <CopilotChatAttachmentQueue
429
+ attachments={attachments}
430
+ onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
431
+ className="cpk:px-4"
432
+ />
433
+ </div>
425
434
  )}
435
+ {BoundInput}
426
436
  </div>
427
-
428
- {BoundInput}
429
437
  </div>
430
438
  );
431
439
  }
@@ -476,7 +484,6 @@ export namespace CopilotChatView {
476
484
  </div>
477
485
  </StickToBottom.Content>
478
486
 
479
- {/* Feather gradient overlay */}
480
487
  {BoundFeather}
481
488
 
482
489
  {/* Scroll to bottom button - hidden during resize */}
@@ -484,7 +491,7 @@ export namespace CopilotChatView {
484
491
  <div
485
492
  className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
486
493
  style={{
487
- bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
494
+ bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
488
495
  }}
489
496
  >
490
497
  {renderSlot(
@@ -541,21 +548,13 @@ export namespace CopilotChatView {
541
548
  topOffset: 16,
542
549
  });
543
550
 
544
- // Pin-to-send uses a SOFTER feather than pin-to-bottom:
545
- // - default: h-24 + from-white via-white to-transparent (fully opaque
546
- // bottom half, aggressive). Good for streaming-to-bottom where
547
- // the edge is always churning.
548
- // - pin-to-send: h-12 + from-white to-transparent (gradual fade,
549
- // no opaque midline). Gives a visual soft edge above the input
550
- // without obscuring otherwise-readable content.
551
- // Consumers can still override with the `feather` slot.
552
- const BoundFeather = renderSlot(feather, PinToSendSoftFeather, {});
553
-
554
- // Feather and scroll-to-bottom button live OUTSIDE the scroll container.
555
- // `position: absolute` children of an `overflow: auto` element are
556
- // positioned relative to the scroll *content*, which means they scroll
557
- // away with it. Placing them as siblings of the scroll container
551
+ // The feather and scroll-to-bottom button live OUTSIDE the scroll
552
+ // container. `position: absolute` children of an `overflow: auto` element
553
+ // are positioned relative to the scroll *content*, which means they
554
+ // scroll away with it. Placing them as siblings of the scroll container
558
555
  // (inside a `relative` wrapper) keeps them pinned to the viewport bottom.
556
+ const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
557
+
559
558
  return (
560
559
  <ScrollElementContext.Provider value={nonAutoScrollEl}>
561
560
  <div
@@ -582,14 +581,13 @@ export namespace CopilotChatView {
582
581
  style={{ height: 0, flex: "0 0 auto" }}
583
582
  />
584
583
  </div>
585
- {/* Soft feather — pinned to wrapper bottom */}
586
584
  {BoundFeather}
587
585
  {/* Scroll to bottom button */}
588
586
  {showScrollButton && !isResizing && (
589
587
  <div
590
588
  className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
591
589
  style={{
592
- bottom: `${inputContainerHeight + PIN_TO_SEND_FEATHER_HEIGHT + 16}px`,
590
+ bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
593
591
  }}
594
592
  >
595
593
  {renderSlot(
@@ -722,7 +720,6 @@ export namespace CopilotChatView {
722
720
  {children}
723
721
  </div>
724
722
 
725
- {/* Feather gradient overlay */}
726
723
  {BoundFeather}
727
724
 
728
725
  {/* Scroll to bottom button for manual mode */}
@@ -730,7 +727,7 @@ export namespace CopilotChatView {
730
727
  <div
731
728
  className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
732
729
  style={{
733
- bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
730
+ bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
734
731
  }}
735
732
  >
736
733
  {renderSlot(
@@ -812,22 +809,14 @@ export namespace CopilotChatView {
812
809
  </Button>
813
810
  );
814
811
 
812
+ // Default renders an empty div — no visual, but the element is still in the
813
+ // tree so a slot override of the form `scrollView={{ feather: "my-class" }}`
814
+ // can apply classes (and any consumer with a full component override gets
815
+ // the className/style forwarding they expect).
815
816
  export const Feather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
816
817
  className,
817
- style,
818
818
  ...props
819
- }) => (
820
- <div
821
- className={cn(
822
- "cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-24 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
823
- "cpk:from-white cpk:via-white cpk:to-transparent",
824
- "cpk:dark:from-[rgb(33,33,33)] cpk:dark:via-[rgb(33,33,33)]",
825
- className,
826
- )}
827
- style={style}
828
- {...props}
829
- />
830
- );
819
+ }) => <div className={className} {...props} />;
831
820
 
832
821
  export const WelcomeMessage: React.FC<
833
822
  React.HTMLAttributes<HTMLDivElement>
@@ -0,0 +1,103 @@
1
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import { createPortal, flushSync } from "react-dom";
3
+ import { X } from "lucide-react";
4
+
5
+ interface LightboxProps {
6
+ onClose: () => void;
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export function Lightbox({ onClose, children }: LightboxProps) {
11
+ useEffect(() => {
12
+ const handleKey = (e: KeyboardEvent) => {
13
+ if (e.key === "Escape") onClose();
14
+ };
15
+ document.addEventListener("keydown", handleKey);
16
+ return () => document.removeEventListener("keydown", handleKey);
17
+ }, [onClose]);
18
+
19
+ if (typeof document === "undefined") return null;
20
+
21
+ return createPortal(
22
+ <div
23
+ className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
24
+ onClick={onClose}
25
+ >
26
+ <button
27
+ onClick={onClose}
28
+ className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
29
+ aria-label="Close preview"
30
+ >
31
+ <X className="cpk:w-5 cpk:h-5" />
32
+ </button>
33
+
34
+ <div onClick={(e) => e.stopPropagation()}>{children}</div>
35
+ </div>,
36
+ document.body,
37
+ );
38
+ }
39
+
40
+ type DocWithVT = Document & {
41
+ startViewTransition?: (cb: () => void) => { finished: Promise<void> };
42
+ };
43
+
44
+ /**
45
+ * Hook that manages lightbox open/close and uses the View Transition API to
46
+ * morph the thumbnail into fullscreen content.
47
+ *
48
+ * The trick: `view-transition-name` must live on exactly ONE element at a time.
49
+ * - Old state (thumbnail visible): name is on the thumbnail.
50
+ * - New state (lightbox visible): name moves to the lightbox content.
51
+ * `flushSync` ensures React commits the DOM change synchronously inside the
52
+ * `startViewTransition` callback so the API can snapshot old → new correctly.
53
+ */
54
+ export function useLightbox() {
55
+ const thumbnailRef = useRef<HTMLElement>(null);
56
+ const [open, setOpen] = useState(false);
57
+ const vtName = useId();
58
+
59
+ const openLightbox = useCallback(() => {
60
+ const thumb = thumbnailRef.current;
61
+ const doc = document as DocWithVT;
62
+
63
+ if (doc.startViewTransition && thumb) {
64
+ thumb.style.viewTransitionName = vtName;
65
+
66
+ doc.startViewTransition(() => {
67
+ thumb.style.viewTransitionName = "";
68
+ flushSync(() => setOpen(true));
69
+ });
70
+ } else {
71
+ setOpen(true);
72
+ }
73
+ }, [vtName]);
74
+
75
+ const closeLightbox = useCallback(() => {
76
+ const thumb = thumbnailRef.current;
77
+ const doc = document as DocWithVT;
78
+
79
+ if (doc.startViewTransition && thumb) {
80
+ const transition = doc.startViewTransition(() => {
81
+ flushSync(() => setOpen(false));
82
+ thumb.style.viewTransitionName = vtName;
83
+ });
84
+ transition.finished
85
+ .then(() => {
86
+ thumb.style.viewTransitionName = "";
87
+ })
88
+ .catch(() => {
89
+ thumb.style.viewTransitionName = "";
90
+ });
91
+ } else {
92
+ setOpen(false);
93
+ }
94
+ }, [vtName]);
95
+
96
+ return {
97
+ thumbnailRef,
98
+ vtName,
99
+ open,
100
+ openLightbox,
101
+ closeLightbox,
102
+ };
103
+ }