@djangocfg/ui-tools 2.1.430 → 2.1.432

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.
Files changed (188) hide show
  1. package/dist/tree/index.cjs +43 -8
  2. package/dist/tree/index.cjs.map +1 -1
  3. package/dist/tree/index.d.cts +31 -1
  4. package/dist/tree/index.d.ts +31 -1
  5. package/dist/tree/index.mjs +43 -8
  6. package/dist/tree/index.mjs.map +1 -1
  7. package/package.json +28 -14
  8. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  9. package/src/common/CodePanelHeader/index.ts +1 -0
  10. package/src/common/blocks/BlockError.tsx +46 -0
  11. package/src/common/blocks/index.ts +34 -0
  12. package/src/common/blocks/schemas.ts +143 -0
  13. package/src/common/blocks/types.ts +76 -0
  14. package/src/common/chips/README.md +67 -0
  15. package/src/common/chips/UrlChip.tsx +105 -0
  16. package/src/common/chips/index.ts +20 -0
  17. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  18. package/src/common/link-preview/index.ts +12 -0
  19. package/src/common/link-preview/types.ts +44 -0
  20. package/src/common/tiptap/index.ts +10 -0
  21. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  22. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  23. package/src/tools/chat/README.md +109 -2
  24. package/src/tools/chat/composer/Composer.tsx +45 -12
  25. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  26. package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
  27. package/src/tools/chat/composer/index.ts +9 -0
  28. package/src/tools/chat/composer/textToAttachment.ts +69 -0
  29. package/src/tools/chat/composer/types.ts +13 -0
  30. package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
  31. package/src/tools/chat/constants.ts +9 -2
  32. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  33. package/src/tools/chat/core/reducer.ts +32 -8
  34. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  35. package/src/tools/chat/core/transport/mock.ts +4 -1
  36. package/src/tools/chat/hooks/useChat.ts +20 -20
  37. package/src/tools/chat/lazy.tsx +10 -0
  38. package/src/tools/chat/messages/Attachments.tsx +335 -46
  39. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  40. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  41. package/src/tools/chat/messages/MessageList.tsx +107 -21
  42. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  43. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  44. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  45. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  46. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  47. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  48. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  49. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  50. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  51. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  52. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  53. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  54. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  55. package/src/tools/chat/messages/index.ts +13 -0
  56. package/src/tools/chat/public.ts +5 -1
  57. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  58. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  59. package/src/tools/chat/styles/index.ts +1 -0
  60. package/src/tools/chat/types/attachment.ts +38 -3
  61. package/src/tools/chat/types/block.ts +56 -9
  62. package/src/tools/chat/types/blockSchemas.ts +197 -0
  63. package/src/tools/chat/types/config.ts +33 -0
  64. package/src/tools/chat/types/events.ts +14 -7
  65. package/src/tools/chat/types/index.ts +4 -2
  66. package/src/tools/chat/types/message.ts +7 -0
  67. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  68. package/src/tools/data/Tree/README.md +30 -0
  69. package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
  70. package/src/tools/data/Tree/context/menu/render.tsx +56 -27
  71. package/src/tools/data/Tree/data/appearance.ts +65 -3
  72. package/src/tools/data/Tree/data/index.ts +2 -0
  73. package/src/tools/data/Tree/index.tsx +2 -0
  74. package/src/tools/dev/Map/README.md +30 -345
  75. package/src/tools/dev/Map/cards/index.ts +6 -0
  76. package/src/tools/dev/Map/cards/types.ts +67 -0
  77. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  78. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  79. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  80. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  81. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  82. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  83. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  84. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  85. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  86. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  87. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  88. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  89. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  90. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  91. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  92. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  93. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  94. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  95. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  96. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  97. package/src/tools/dev/Map/components/index.ts +35 -1
  98. package/src/tools/dev/Map/geocode/index.ts +2 -0
  99. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  100. package/src/tools/dev/Map/hooks/index.ts +37 -0
  101. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  102. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  103. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  104. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  105. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  106. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  107. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  108. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  109. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  110. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  111. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  112. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  113. package/src/tools/dev/Map/index.ts +59 -2
  114. package/src/tools/dev/Map/layers/index.ts +1 -0
  115. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  116. package/src/tools/dev/Map/lazy.tsx +120 -0
  117. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  118. package/src/tools/dev/Map/styles/index.ts +62 -0
  119. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  120. package/src/tools/dev/Map/types.ts +31 -3
  121. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  122. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  123. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  124. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  125. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  126. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  127. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  128. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  129. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  130. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  131. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  132. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  133. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  134. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  135. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  136. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  137. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  138. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  139. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  140. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  141. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  142. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  143. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  144. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  145. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  146. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  147. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  148. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  149. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  150. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  151. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  152. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  153. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  154. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  155. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  156. package/src/tools/forms/NotionEditor/README.md +109 -188
  157. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  158. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  159. package/src/tools/forms/NotionEditor/styles.css +25 -0
  160. package/src/tools/forms/NotionEditor/types.ts +9 -0
  161. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  162. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  163. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  164. package/src/tools/media/ImageViewer/README.md +9 -0
  165. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  166. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  167. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  168. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  169. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  170. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  171. package/src/tools/media/ImageViewer/types.ts +10 -0
  172. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  173. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  174. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  175. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  176. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  177. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  178. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  179. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  180. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  181. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  182. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  183. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  184. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  185. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  186. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  187. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  188. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -12,6 +12,14 @@ import { GalleryMedia } from '../media'
12
12
  import { GalleryThumbnails } from '../thumbnails'
13
13
  import type { GalleryLightboxProps } from '../../types'
14
14
 
15
+ // Liquid-glass control — token-driven (--card / --foreground), so it stays
16
+ // legible on BOTH the light scrim and the dark vibrancy backdrop. Foreground
17
+ // icon color flips with the theme; hover wash adapts via the foreground token.
18
+ const GLASS_BTN =
19
+ 'glass-liquid text-foreground/80 transition-colors ' +
20
+ 'hover:text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)] ' +
21
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
22
+
15
23
  /**
16
24
  * GalleryLightbox - Fullscreen image viewer with zoom and navigation
17
25
  *
@@ -241,11 +249,16 @@ export const GalleryLightbox = memo(function GalleryLightbox({
241
249
  ref={dialogRef}
242
250
  className={cn(
243
251
  'fixed inset-0 z-50',
244
- 'backdrop-blur-sm',
252
+ // Theme-aware Quick-Look scrim: a LIGHT neutral wash in light mode so
253
+ // the lightbox feels native (Quick Look matches system appearance),
254
+ // a deep vibrancy floor in dark mode. blur+saturate in both so the
255
+ // page behind bleeds through softly and the image floats.
256
+ 'backdrop-blur-2xl backdrop-saturate-150',
257
+ '[background-color:color-mix(in_oklab,var(--background)_72%,transparent)]',
258
+ 'dark:[background-color:color-mix(in_oklab,#000_82%,transparent)]',
245
259
  'animate-in fade-in-0 duration-200',
246
260
  'focus:outline-none'
247
261
  )}
248
- style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
249
262
  role="dialog"
250
263
  aria-modal="true"
251
264
  aria-label={title || labels.lightbox}
@@ -258,22 +271,28 @@ export const GalleryLightbox = memo(function GalleryLightbox({
258
271
  >
259
272
  {/* Header */}
260
273
  <div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4">
261
- {/* Title & Counter */}
262
- <div className="text-white">
263
- {title && <div className="font-medium">{title}</div>}
264
- {hasMultiple && (
265
- <div className="text-sm text-white/70" aria-live="polite">
266
- {currentIndex + 1} / {images.length}
267
- </div>
268
- )}
269
- </div>
274
+ {/* Title & Counter — liquid-glass pill so it reads on both the light
275
+ and dark scrim (glass-liquid is token-driven, foreground text
276
+ flips with the theme). */}
277
+ {(title || hasMultiple) ? (
278
+ <div className="glass-liquid rounded-2xl px-3 py-1.5 text-foreground">
279
+ {title && <div className="font-medium">{title}</div>}
280
+ {hasMultiple && (
281
+ <div className="text-sm text-foreground/70" aria-live="polite">
282
+ {currentIndex + 1} / {images.length}
283
+ </div>
284
+ )}
285
+ </div>
286
+ ) : (
287
+ <span aria-hidden />
288
+ )}
270
289
 
271
290
  {/* Actions */}
272
291
  <div className="flex items-center gap-2">
273
292
  {enableZoom && !isVideo && (
274
293
  <button
275
294
  type="button"
276
- className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
295
+ className={cn(GLASS_BTN, 'p-2 rounded-full')}
277
296
  onClick={handleToggleZoom}
278
297
  aria-label={isZoomed ? labels.zoomOut : labels.zoomIn}
279
298
  >
@@ -288,7 +307,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
288
307
  {enableDownload && (
289
308
  <button
290
309
  type="button"
291
- className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
310
+ className={cn(GLASS_BTN, 'p-2 rounded-full')}
292
311
  onClick={handleDownload}
293
312
  aria-label={labels.download}
294
313
  >
@@ -299,7 +318,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
299
318
  {enableShare && (
300
319
  <button
301
320
  type="button"
302
- className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
321
+ className={cn(GLASS_BTN, 'p-2 rounded-full')}
303
322
  onClick={handleShare}
304
323
  aria-label={labels.share}
305
324
  >
@@ -309,7 +328,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
309
328
 
310
329
  <button
311
330
  type="button"
312
- className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
331
+ className={cn(GLASS_BTN, 'p-2 rounded-full')}
313
332
  onClick={onClose}
314
333
  aria-label={labels.close}
315
334
  >
@@ -360,11 +379,10 @@ export const GalleryLightbox = memo(function GalleryLightbox({
360
379
  <button
361
380
  type="button"
362
381
  className={cn(
382
+ GLASS_BTN,
363
383
  'absolute left-4 top-1/2 -translate-y-1/2 z-10',
364
384
  'w-12 h-12 rounded-full',
365
- 'bg-white/10 hover:bg-white/20 text-white',
366
- 'flex items-center justify-center transition-colors',
367
- 'focus:outline-none focus-visible:ring-2 focus-visible:ring-white'
385
+ 'flex items-center justify-center'
368
386
  )}
369
387
  onClick={goToPrev}
370
388
  aria-label={labels.previous}
@@ -375,11 +393,10 @@ export const GalleryLightbox = memo(function GalleryLightbox({
375
393
  <button
376
394
  type="button"
377
395
  className={cn(
396
+ GLASS_BTN,
378
397
  'absolute right-4 top-1/2 -translate-y-1/2 z-10',
379
398
  'w-12 h-12 rounded-full',
380
- 'bg-white/10 hover:bg-white/20 text-white',
381
- 'flex items-center justify-center transition-colors',
382
- 'focus:outline-none focus-visible:ring-2 focus-visible:ring-white'
399
+ 'flex items-center justify-center'
383
400
  )}
384
401
  onClick={goToNext}
385
402
  aria-label={labels.next}
@@ -391,7 +408,7 @@ export const GalleryLightbox = memo(function GalleryLightbox({
391
408
 
392
409
  {/* Thumbnails */}
393
410
  {showThumbnails && hasMultiple && !isZoomed && (
394
- <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/50 to-transparent">
411
+ <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-white/40 dark:from-black/50 to-transparent">
395
412
  <GalleryThumbnails
396
413
  images={images}
397
414
  currentIndex={currentIndex}
@@ -139,6 +139,15 @@ Click the expand button to open in a fullscreen dialog. Gallery navigation works
139
139
  </Dialog>
140
140
  ```
141
141
 
142
+ ## Used as the chat attachment lightbox
143
+
144
+ The [Chat tool](../../chat/README.md) uses `LazyImageViewer` as the **default**
145
+ lightbox for image attachments. When a host wires no `onAttachmentOpen` /
146
+ `onClick`, clicking an image attachment opens this viewer in a dialog
147
+ (`inDialog`), seeded with all of that message's image attachments so prev/next
148
+ gallery navigation works across them, opened at the clicked `initialIndex`. See
149
+ `chat/messages/Attachments.tsx` (`ImageLightboxProvider`).
150
+
142
151
  ## Styling
143
152
 
144
153
  The component fills its container. Wrap in a sized element:
@@ -3,14 +3,21 @@
3
3
  /**
4
4
  * ImageViewer - Image viewer with zoom, pan, rotate, flip, gallery navigation
5
5
  *
6
- * Features:
7
- * - Zoom with mouse wheel and presets
8
- * - Pan with drag
9
- * - Rotate 90°
10
- * - Flip horizontal/vertical
11
- * - Fullscreen dialog
12
- * - Keyboard shortcuts (+/-, 0, r, ←/→ for gallery)
13
- * - Gallery mode: pass images[] with multiple items
6
+ * Two presentations share one engine (react-zoom-pan-pinch):
7
+ * - Embedded (default): checkerboard canvas + editing toolbar (zoom presets,
8
+ * rotate, flip, expand-to-fullscreen).
9
+ * - Lightbox (`lightbox` / `inDialog`): macOS Quick-Look-grade fullscreen —
10
+ * clean dark backdrop, idle-fading floating chrome, no rotate/flip slab.
11
+ *
12
+ * Gesture model (macOS-native):
13
+ * - Trackpad pinch arrives as wheel events with `ctrlKey=true`; the library's
14
+ * `smooth` path maps `|deltaY|` to the zoom magnitude so scale tracks the
15
+ * gesture 1:1. We raise `smoothStep` (see WHEEL_SMOOTH_STEP) so a real pinch
16
+ * reaches a meaningful zoom in one motion.
17
+ * - Zoom is anchored to the FOCAL POINT (cursor / pinch midpoint) — the point
18
+ * under the fingers stays put — handled natively by the library.
19
+ * - Pan by drag when zoomed; double-click/tap toggles fit ↔ zoom toward the
20
+ * point; keyboard +/-/0 and arrows (pan when zoomed, gallery nav at fit).
14
21
  */
15
22
 
16
23
  import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
@@ -26,8 +33,16 @@ import { useHotkey } from '@djangocfg/ui-core/hooks';
26
33
 
27
34
  import { ImageToolbar } from './ImageToolbar';
28
35
  import { ImageInfo } from './ImageInfo';
29
- import { useImageTransform, useImageLoading } from '../hooks';
30
- import { KEYBOARD_PAN_STEP } from '../utils';
36
+ import { LightboxChrome } from './LightboxChrome';
37
+ import { useImageTransform, useImageLoading, useIdleChrome } from '../hooks';
38
+ import {
39
+ KEYBOARD_PAN_STEP,
40
+ MIN_ZOOM,
41
+ MAX_ZOOM,
42
+ WHEEL_SMOOTH_STEP,
43
+ PINCH_STEP,
44
+ DOUBLE_CLICK_STEP,
45
+ } from '../utils';
31
46
  import type { ImageViewerProps } from '../types';
32
47
 
33
48
  // =============================================================================
@@ -38,10 +53,15 @@ export function ImageViewer({
38
53
  images,
39
54
  initialIndex = 0,
40
55
  inDialog = false,
56
+ lightbox = false,
57
+ onRequestClose,
41
58
  autoFocus = false,
42
59
  }: ImageViewerProps) {
43
60
  const t = useAppT();
44
61
 
62
+ // `lightbox` implies the in-dialog presentation.
63
+ const isLightbox = lightbox || inDialog;
64
+
45
65
  const [currentIndex, setCurrentIndex] = useState(() =>
46
66
  Math.max(0, Math.min(initialIndex, images.length - 1))
47
67
  );
@@ -61,12 +81,17 @@ export function ImageViewer({
61
81
  const containerRef = useRef<HTMLDivElement>(null);
62
82
  const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
63
83
 
84
+ // Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
85
+ // the controls visible; resting for CHROME_IDLE_MS fades them out.
86
+ const { active: chromeActive, onActivity, setPinned } = useIdleChrome(isLightbox);
87
+
64
88
  const labels = useMemo(() => ({
65
89
  noImage: t('tools.image.noImage'),
66
90
  failedToLoad: t('tools.image.failedToLoad'),
67
91
  loading: t('ui.form.loading'),
68
92
  prev: t('tools.gallery.previous'),
69
93
  next: t('tools.gallery.next'),
94
+ close: t('tools.gallery.close'),
70
95
  }), [t]);
71
96
 
72
97
  const {
@@ -114,14 +139,21 @@ export function ImageViewer({
114
139
 
115
140
  const handleExpand = useCallback(() => setDialogOpen(true), []);
116
141
 
142
+ // Reset zoom/pan to fit whenever the gallery image changes — a fresh image
143
+ // should never inherit the previous one's pan offset.
144
+ const goTo = useCallback((next: number) => {
145
+ setCurrentIndex(next);
146
+ controlsRef.current?.resetTransform();
147
+ }, []);
148
+
117
149
  const prev = useCallback(() =>
118
- setCurrentIndex((i) => (i - 1 + images.length) % images.length),
119
- [images.length]
150
+ goTo((currentIndex - 1 + images.length) % images.length),
151
+ [goTo, currentIndex, images.length]
120
152
  );
121
153
 
122
154
  const next = useCallback(() =>
123
- setCurrentIndex((i) => (i + 1) % images.length),
124
- [images.length]
155
+ goTo((currentIndex + 1) % images.length),
156
+ [goTo, currentIndex, images.length]
125
157
  );
126
158
 
127
159
  // Pan the image by a fixed offset (arrow keys)
@@ -154,7 +186,11 @@ export function ImageViewer({
154
186
  case '-': case '_': e.preventDefault(); controls.zoomOut(); break;
155
187
  case '0': e.preventDefault(); controls.resetTransform(); break;
156
188
  case 'r': case 'R':
157
- if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); }
189
+ // Rotate is an editing affordance embedded mode only.
190
+ if (!isLightbox && !e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); }
191
+ break;
192
+ case 'Escape':
193
+ if (isLightbox && onRequestClose) { e.preventDefault(); onRequestClose(); }
158
194
  break;
159
195
  // Arrow keys pan only when zoomed in. While at fit scale they
160
196
  // fall through to gallery navigation (handled by useHotkey).
@@ -166,7 +202,7 @@ export function ImageViewer({
166
202
  };
167
203
  window.addEventListener('keydown', handleKeyDown);
168
204
  return () => window.removeEventListener('keydown', handleKeyDown);
169
- }, [rotate, panBy]);
205
+ }, [rotate, panBy, isLightbox, onRequestClose]);
170
206
 
171
207
  // Keyboard: gallery navigation (global, only while not zoomed in so it
172
208
  // never competes with arrow-key panning)
@@ -215,20 +251,33 @@ export function ImageViewer({
215
251
  tabIndex={0}
216
252
  role="img"
217
253
  aria-label={current.file.name}
254
+ onMouseMove={isLightbox ? onActivity : undefined}
218
255
  className={cn(
219
256
  'flex-1 h-full relative overflow-hidden outline-none',
220
257
  'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
221
- 'bg-[length:16px_16px]',
222
- '[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
223
- '[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
224
- '[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
258
+ isLightbox
259
+ // Lightbox: a deep, slightly translucent vibrancy backdrop. The
260
+ // saturate+blur is the macOS Quick Look recipe — the page behind
261
+ // the dialog's scrim bleeds through softly so the image floats.
262
+ ? cn(
263
+ '[background-color:color-mix(in_oklab,#000_82%,transparent)]',
264
+ 'backdrop-blur-2xl backdrop-saturate-150',
265
+ )
266
+ // Embedded: the classic transparency checkerboard.
267
+ : cn(
268
+ 'bg-[length:16px_16px]',
269
+ '[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
270
+ '[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
271
+ '[background-position:0_0,0_8px,8px_-8px,-8px_0px]',
272
+ ),
225
273
  )}
226
274
  >
227
- {src && <ImageInfo src={src} />}
275
+ {/* Dimensions badge embedded only (lightbox keeps chrome minimal). */}
276
+ {!isLightbox && src && <ImageInfo src={src} />}
228
277
 
229
278
  {useProgressiveLoading && !isFullyLoaded && (
230
279
  <div className="absolute top-3 left-3 z-10 px-2 py-1 bg-black/60 backdrop-blur-sm border border-white/10 rounded text-[10px] text-white/80 font-mono flex items-center gap-1.5">
231
- <div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
280
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
232
281
  {labels.loading}
233
282
  </div>
234
283
  )}
@@ -243,29 +292,38 @@ export function ImageViewer({
243
292
 
244
293
  <TransformWrapper
245
294
  initialScale={1}
246
- minScale={0.1}
247
- maxScale={8}
295
+ minScale={MIN_ZOOM}
296
+ maxScale={MAX_ZOOM}
248
297
  centerOnInit
249
298
  centerZoomedOut
299
+ // `smooth` (default) makes the wheel/trackpad zoom magnitude scale with
300
+ // `|deltaY|`, i.e. 1:1 with the gesture. The raised `smoothStep` gives
301
+ // a macOS trackpad pinch real reach in a single motion (the library's
302
+ // 0.001 default felt dead). Focal-point anchoring is handled natively.
303
+ smooth
304
+ wheel={{ smoothStep: WHEEL_SMOOTH_STEP }}
305
+ pinch={{ step: PINCH_STEP }}
306
+ doubleClick={{ mode: 'toggle', step: DOUBLE_CLICK_STEP }}
307
+ panning={{ velocityDisabled: false }}
250
308
  onTransformed={(ref, state) => {
251
309
  controlsRef.current = ref;
252
310
  // Avoid a re-render on every pan frame — only when zoom changes
253
311
  setScale((prev) => (prev === state.scale ? prev : state.scale));
254
312
  }}
255
313
  onInit={(ref) => { controlsRef.current = ref; }}
256
- wheel={{ step: 0.1 }}
257
- doubleClick={{ mode: 'toggle', step: 2 }}
258
- panning={{ velocityDisabled: false }}
259
314
  >
260
- <ImageToolbar
261
- scale={scale}
262
- transform={transform}
263
- onRotate={rotate}
264
- onFlipH={flipH}
265
- onFlipV={flipV}
266
- onZoomPreset={handleZoomPreset}
267
- onExpand={!inDialog ? handleExpand : undefined}
268
- />
315
+ {/* Embedded editing toolbar (rotate/flip/expand) — not in the lightbox. */}
316
+ {!isLightbox && (
317
+ <ImageToolbar
318
+ scale={scale}
319
+ transform={transform}
320
+ onRotate={rotate}
321
+ onFlipH={flipH}
322
+ onFlipV={flipV}
323
+ onZoomPreset={handleZoomPreset}
324
+ onExpand={!inDialog ? handleExpand : undefined}
325
+ />
326
+ )}
269
327
 
270
328
  <TransformComponent
271
329
  wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
@@ -294,7 +352,12 @@ export function ImageViewer({
294
352
  <img
295
353
  src={src}
296
354
  alt={current.file.name}
297
- className="max-w-full max-h-full object-contain select-none"
355
+ className={cn(
356
+ 'max-w-full max-h-full object-contain select-none',
357
+ // A soft drop shadow lifts the image off the dark lightbox
358
+ // backdrop (Preview / Photos do the same).
359
+ isLightbox && 'drop-shadow-[0_8px_40px_rgba(0,0,0,0.55)]',
360
+ )}
298
361
  style={{
299
362
  transform: transformStyle,
300
363
  transition: 'transform 0.15s ease-out, opacity 0.3s ease-out',
@@ -310,52 +373,75 @@ export function ImageViewer({
310
373
  </TransformComponent>
311
374
  </TransformWrapper>
312
375
 
313
- {/* Gallery navigation */}
314
- {hasMultiple && (
315
- <>
316
- <Tooltip>
317
- <TooltipTrigger asChild>
318
- <button
319
- type="button"
320
- onClick={prev}
321
- aria-label={labels.prev}
322
- className="absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
323
- >
324
- <ChevronLeft className="h-5 w-5" />
325
- </button>
326
- </TooltipTrigger>
327
- <TooltipContent side="right">{labels.prev}</TooltipContent>
328
- </Tooltip>
329
- <Tooltip>
330
- <TooltipTrigger asChild>
331
- <button
332
- type="button"
333
- onClick={next}
334
- aria-label={labels.next}
335
- className="absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
336
- >
337
- <ChevronRight className="h-5 w-5" />
338
- </button>
339
- </TooltipTrigger>
340
- <TooltipContent side="left">{labels.next}</TooltipContent>
341
- </Tooltip>
342
- <div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-black/60 backdrop-blur-sm border border-white/10 text-white/80 text-xs font-mono px-2 py-0.5 rounded-full pointer-events-none">
343
- {currentIndex + 1} / {images.length}
344
- </div>
345
- </>
376
+ {/* Lightbox: restrained macOS-grade chrome that idle-fades. */}
377
+ {isLightbox ? (
378
+ <LightboxChrome
379
+ active={chromeActive}
380
+ index={currentIndex + 1}
381
+ total={images.length}
382
+ scale={scale}
383
+ fileName={current.file.name}
384
+ onPrev={prev}
385
+ onNext={next}
386
+ onClose={() => onRequestClose?.()}
387
+ onPinChange={setPinned}
388
+ labels={{ prev: labels.prev, next: labels.next, close: labels.close }}
389
+ />
390
+ ) : (
391
+ hasMultiple && (
392
+ // Embedded gallery nav (checkerboard canvas).
393
+ <>
394
+ <Tooltip>
395
+ <TooltipTrigger asChild>
396
+ <button
397
+ type="button"
398
+ onClick={prev}
399
+ aria-label={labels.prev}
400
+ className="absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
401
+ >
402
+ <ChevronLeft className="h-5 w-5" />
403
+ </button>
404
+ </TooltipTrigger>
405
+ <TooltipContent side="right">{labels.prev}</TooltipContent>
406
+ </Tooltip>
407
+ <Tooltip>
408
+ <TooltipTrigger asChild>
409
+ <button
410
+ type="button"
411
+ onClick={next}
412
+ aria-label={labels.next}
413
+ className="absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
414
+ >
415
+ <ChevronRight className="h-5 w-5" />
416
+ </button>
417
+ </TooltipTrigger>
418
+ <TooltipContent side="left">{labels.next}</TooltipContent>
419
+ </Tooltip>
420
+ <div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-black/60 backdrop-blur-sm border border-white/10 text-white/80 text-xs font-mono px-2 py-0.5 rounded-full pointer-events-none">
421
+ {currentIndex + 1} / {images.length}
422
+ </div>
423
+ </>
424
+ )
346
425
  )}
347
426
 
348
- {/* Fullscreen dialog */}
349
- {!inDialog && (
427
+ {/* Embedded expand-to-fullscreen dialog (uses the lightbox presentation). */}
428
+ {!inDialog && !lightbox && (
350
429
  <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
351
- <DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden flex flex-col">
430
+ <DialogContent
431
+ fullscreen
432
+ closeButton={false}
433
+ className="border-0 bg-transparent p-0 shadow-none"
434
+ >
352
435
  <DialogTitle className="sr-only">{current.file.name}</DialogTitle>
353
- <div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
354
- <span className="text-sm font-medium truncate">{current.file.name}</span>
355
- </div>
356
- <div className="flex-1 min-h-0">
357
- <ImageViewer images={images} initialIndex={currentIndex} inDialog />
358
- </div>
436
+ {dialogOpen && (
437
+ <ImageViewer
438
+ images={images}
439
+ initialIndex={currentIndex}
440
+ lightbox
441
+ autoFocus
442
+ onRequestClose={() => setDialogOpen(false)}
443
+ />
444
+ )}
359
445
  </DialogContent>
360
446
  </Dialog>
361
447
  )}
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * LightboxChrome - Restrained, macOS-grade floating controls for the
5
+ * fullscreen image lightbox.
6
+ *
7
+ * Design intent (Apple HIG / Quick Look / Preview):
8
+ * - Minimal chrome: a single close affordance (top-right), prev/next only
9
+ * when there is more than one image, and one unobtrusive status pill
10
+ * (counter + live zoom %).
11
+ * - Everything floats on liquid-glass and fades together when the pointer
12
+ * goes idle, reappearing on the slightest movement.
13
+ * - No checkerboard, no rotate/flip slab — the image is the subject.
14
+ */
15
+
16
+ import { ChevronLeft, ChevronRight, X } from 'lucide-react';
17
+
18
+ import { cn } from '@djangocfg/ui-core/lib';
19
+
20
+ import { MiddleEllipsis } from './MiddleEllipsis';
21
+
22
+ interface LightboxChromeProps {
23
+ /** Visible (recent pointer activity) vs faded (idle). */
24
+ active: boolean;
25
+ /** 1-based index for the counter. */
26
+ index: number;
27
+ total: number;
28
+ /** Live zoom scale (1 = fit). The % pill only shows when zoomed in/out. */
29
+ scale: number;
30
+ fileName?: string;
31
+ onPrev: () => void;
32
+ onNext: () => void;
33
+ onClose: () => void;
34
+ /** Pin chrome open while the pointer hovers a control. */
35
+ onPinChange: (pinned: boolean) => void;
36
+ labels: { prev: string; next: string; close: string };
37
+ }
38
+
39
+ // Liquid-glass circular control — light icon on translucent vibrancy so it
40
+ // stays legible over any photo (bright or dark) without a hard slab.
41
+ const GLASS_BTN =
42
+ 'glass-liquid pointer-events-auto inline-flex items-center justify-center ' +
43
+ 'rounded-full text-white/90 transition-[background-color,transform,opacity] ' +
44
+ 'hover:text-white hover:bg-white/15 active:scale-95 ' +
45
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70';
46
+
47
+ export function LightboxChrome({
48
+ active,
49
+ index,
50
+ total,
51
+ scale,
52
+ fileName,
53
+ onPrev,
54
+ onNext,
55
+ onClose,
56
+ onPinChange,
57
+ labels,
58
+ }: LightboxChromeProps) {
59
+ const hasMultiple = total > 1;
60
+ // Only surface the zoom pill once the image is meaningfully off fit — at
61
+ // exactly fit (1.0) the % is noise.
62
+ const zoomed = Math.abs(scale - 1) > 0.01;
63
+ const zoomPct = `${Math.round(scale * 100)}%`;
64
+
65
+ // Whole chrome layer fades as one. `pointer-events-none` on the layer means
66
+ // the faded state never eats clicks meant for the image (pan/double-click).
67
+ return (
68
+ <div
69
+ className={cn(
70
+ 'pointer-events-none absolute inset-0 z-20 transition-opacity duration-300 ease-out',
71
+ active ? 'opacity-100' : 'opacity-0',
72
+ )}
73
+ onMouseEnter={() => onPinChange(true)}
74
+ onMouseLeave={() => onPinChange(false)}
75
+ >
76
+ {/* Top bar: filename (left, whisper-quiet) + close (right).
77
+ The chip caps its width and middle-truncates long names so the start
78
+ and extension stay readable (Finder convention); `gap` + the pinned
79
+ close button guarantee no overlap. */}
80
+ <div className="absolute inset-x-0 top-0 flex items-start justify-between gap-3 p-3 sm:p-4">
81
+ {fileName ? (
82
+ <MiddleEllipsis
83
+ text={fileName}
84
+ className="glass-liquid pointer-events-auto max-w-[clamp(8rem,40vw,28rem)] rounded-full px-3 py-1.5 text-xs font-medium text-white/85"
85
+ />
86
+ ) : (
87
+ <span aria-hidden />
88
+ )}
89
+ <button
90
+ type="button"
91
+ aria-label={labels.close}
92
+ onClick={onClose}
93
+ className={cn(GLASS_BTN, 'h-9 w-9')}
94
+ >
95
+ <X className="h-[18px] w-[18px]" />
96
+ </button>
97
+ </div>
98
+
99
+ {/* Prev / next — edge-anchored, only when there is a gallery. */}
100
+ {hasMultiple && (
101
+ <>
102
+ <button
103
+ type="button"
104
+ aria-label={labels.prev}
105
+ onClick={onPrev}
106
+ className={cn(GLASS_BTN, 'absolute left-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:left-5')}
107
+ >
108
+ <ChevronLeft className="h-5 w-5" />
109
+ </button>
110
+ <button
111
+ type="button"
112
+ aria-label={labels.next}
113
+ onClick={onNext}
114
+ className={cn(GLASS_BTN, 'absolute right-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:right-5')}
115
+ >
116
+ <ChevronRight className="h-5 w-5" />
117
+ </button>
118
+ </>
119
+ )}
120
+
121
+ {/* Bottom status pill: counter + live zoom %. One quiet line. */}
122
+ {(hasMultiple || zoomed) && (
123
+ <div className="absolute inset-x-0 bottom-0 flex justify-center p-3 sm:p-4">
124
+ <div className="glass-liquid flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium tabular-nums text-white/85">
125
+ {hasMultiple && (
126
+ <span>
127
+ {index} / {total}
128
+ </span>
129
+ )}
130
+ {hasMultiple && zoomed && <span className="h-3 w-px bg-white/25" />}
131
+ {zoomed && <span className="font-mono text-white/70">{zoomPct}</span>}
132
+ </div>
133
+ </div>
134
+ )}
135
+ </div>
136
+ );
137
+ }