@djangocfg/ui-tools 2.1.431 → 2.1.433

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 (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * MiddleEllipsis - Finder/Quick-Look-style middle truncation for filenames.
5
+ *
6
+ * macOS truncates a too-long filename in the MIDDLE so the start AND the
7
+ * extension stay readable ("screencapture-referee…very-long-name.png"),
8
+ * unlike a plain CSS `truncate` which clips the (most meaningful) extension.
9
+ *
10
+ * This is a CSS-only technique — no JS measurement, no ResizeObserver:
11
+ * - The "head" span flex-shrinks and truncates with an end-ellipsis.
12
+ * - The "tail" span is pinned (flex-shrink-0) and always shows the last
13
+ * `tailChars` characters, which include the extension.
14
+ * When the name fits, the head shows in full and no ellipsis appears; when it
15
+ * overflows, the head clips while the tail (extension) is preserved.
16
+ */
17
+
18
+ import { cn } from '@djangocfg/ui-core/lib';
19
+
20
+ interface MiddleEllipsisProps {
21
+ /** Full text to display. The container caps the width; CSS does the rest. */
22
+ text: string;
23
+ /**
24
+ * How many trailing characters to keep pinned (and thus always visible).
25
+ * Sized to comfortably cover an extension plus a little of the name tail.
26
+ */
27
+ tailChars?: number;
28
+ className?: string;
29
+ }
30
+
31
+ export function MiddleEllipsis({ text, tailChars = 8, className }: MiddleEllipsisProps) {
32
+ // Split so the tail keeps the extension visible. Guard against short names
33
+ // (where head/tail would overlap) by rendering them whole in the head.
34
+ const splitAt = Math.max(0, text.length - tailChars);
35
+ const head = text.slice(0, splitAt);
36
+ const tail = text.slice(splitAt);
37
+ const short = splitAt === 0;
38
+
39
+ return (
40
+ // `title` gives the full, lossless name on hover.
41
+ <span className={cn('flex min-w-0 items-center', className)} title={text}>
42
+ <span className="min-w-0 flex-1 truncate">{short ? text : head}</span>
43
+ {!short && <span className="flex-shrink-0 whitespace-pre">{tail}</span>}
44
+ </span>
45
+ );
46
+ }
@@ -5,3 +5,5 @@
5
5
  export { ImageViewer } from './ImageViewer';
6
6
  export { ImageToolbar } from './ImageToolbar';
7
7
  export { ImageInfo } from './ImageInfo';
8
+ export { LightboxChrome } from './LightboxChrome';
9
+ export { MiddleEllipsis } from './MiddleEllipsis';
@@ -7,3 +7,6 @@ export type { UseImageTransformOptions, UseImageTransformReturn } from './useIma
7
7
 
8
8
  export { useImageLoading } from './useImageLoading';
9
9
  export type { UseImageLoadingOptions, UseImageLoadingReturn } from './useImageLoading';
10
+
11
+ export { useIdleChrome } from './useIdleChrome';
12
+ export type { UseIdleChromeReturn } from './useIdleChrome';
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useIdleChrome - Auto-hides floating lightbox chrome when the pointer rests.
5
+ *
6
+ * macOS Quick Look / Preview hide their controls after a short idle period and
7
+ * bring them back on the slightest pointer movement. This hook tracks that
8
+ * "active" flag and gives back the pointer handlers to wire onto the canvas.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react';
12
+
13
+ import { CHROME_IDLE_MS } from '../utils';
14
+
15
+ export interface UseIdleChromeReturn {
16
+ /** True while the chrome should be visible (recent pointer activity). */
17
+ active: boolean;
18
+ /** Pointer-move handler — call to mark activity (resets the idle timer). */
19
+ onActivity: () => void;
20
+ /** Pin the chrome open (e.g. while hovering a control) regardless of idle. */
21
+ setPinned: (pinned: boolean) => void;
22
+ }
23
+
24
+ export function useIdleChrome(enabled: boolean): UseIdleChromeReturn {
25
+ const [active, setActive] = useState(true);
26
+ const pinnedRef = useRef(false);
27
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+
29
+ const clear = useCallback(() => {
30
+ if (timerRef.current) {
31
+ clearTimeout(timerRef.current);
32
+ timerRef.current = null;
33
+ }
34
+ }, []);
35
+
36
+ const schedule = useCallback(() => {
37
+ clear();
38
+ if (!enabled) return;
39
+ timerRef.current = setTimeout(() => {
40
+ if (!pinnedRef.current) setActive(false);
41
+ }, CHROME_IDLE_MS);
42
+ }, [clear, enabled]);
43
+
44
+ const onActivity = useCallback(() => {
45
+ setActive(true);
46
+ schedule();
47
+ }, [schedule]);
48
+
49
+ const setPinned = useCallback(
50
+ (pinned: boolean) => {
51
+ pinnedRef.current = pinned;
52
+ if (pinned) {
53
+ clear();
54
+ setActive(true);
55
+ } else {
56
+ schedule();
57
+ }
58
+ },
59
+ [clear, schedule],
60
+ );
61
+
62
+ // Start the idle countdown on mount; chrome shows briefly then fades.
63
+ useEffect(() => {
64
+ if (!enabled) {
65
+ setActive(true);
66
+ return;
67
+ }
68
+ schedule();
69
+ return clear;
70
+ }, [enabled, schedule, clear]);
71
+
72
+ return { active, onActivity, setPinned };
73
+ }
@@ -43,6 +43,16 @@ export interface ImageViewerProps {
43
43
  initialIndex?: number;
44
44
  /** Hide expand button when already in dialog */
45
45
  inDialog?: boolean;
46
+ /**
47
+ * Render the restrained macOS-grade fullscreen lightbox chrome instead of
48
+ * the embedded editing toolbar: a clean dark backdrop (no checkerboard),
49
+ * idle-fading floating controls (close + prev/next + status pill), and no
50
+ * rotate/flip slab. Intended for the click-to-expand image lightbox.
51
+ * Implies `inDialog`.
52
+ */
53
+ lightbox?: boolean;
54
+ /** Close request from the lightbox chrome (Esc / close button). */
55
+ onRequestClose?: () => void;
46
56
  /** Focus the viewer container on mount so its keyboard scope is active
47
57
  * immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
48
58
  * upstream when the parent wants a fresh focus per source change. */
@@ -40,6 +40,34 @@ export const MAX_ZOOM = 8;
40
40
  /** Pixels to pan per arrow-key press */
41
41
  export const KEYBOARD_PAN_STEP = 60;
42
42
 
43
+ // =============================================================================
44
+ // GESTURE SENSITIVITY (macOS-grade pinch / trackpad zoom)
45
+ // =============================================================================
46
+ //
47
+ // react-zoom-pan-pinch computes the per-event wheel zoom magnitude as
48
+ // `smoothStep * |event.deltaY|` (its `smooth` path), which means the scale
49
+ // tracks the gesture 1:1 — a harder pinch / faster wheel moves more. The
50
+ // library default `smoothStep` is `0.001`, which on a macOS trackpad
51
+ // (whose pinch `deltaY` arrives as small ~±1–6 values with `ctrlKey=true`)
52
+ // produces ~0.005 scale/frame — imperceptible, the "weak pinch" symptom.
53
+ //
54
+ // We raise it so a natural trackpad pinch reaches a meaningful zoom in a
55
+ // single gesture while a mouse-wheel-with-ctrl notch (large |deltaY| ~100)
56
+ // stays a sensible discrete step. The library clamps to min/max scale, so
57
+ // the larger coefficient cannot overshoot the bounds.
58
+
59
+ /** Wheel/trackpad-pinch zoom sensitivity. scaleΔ = WHEEL_SMOOTH_STEP * |deltaY|. */
60
+ export const WHEEL_SMOOTH_STEP = 0.0055;
61
+
62
+ /** Touch pinch sensitivity (two-finger pinch on touchscreens). */
63
+ export const PINCH_STEP = 5;
64
+
65
+ /** Double-click / double-tap zoom multiplier (fit ↔ zoomed toward the point). */
66
+ export const DOUBLE_CLICK_STEP = 1.4;
67
+
68
+ /** Idle delay (ms) before the lightbox chrome fades out when the pointer rests. */
69
+ export const CHROME_IDLE_MS = 2200;
70
+
43
71
  /** Available zoom presets */
44
72
  export const ZOOM_PRESETS: readonly ZoomPreset[] = [
45
73
  { label: 'Fit', value: 'fit' },
@@ -12,6 +12,10 @@ export {
12
12
  MIN_ZOOM,
13
13
  MAX_ZOOM,
14
14
  KEYBOARD_PAN_STEP,
15
+ WHEEL_SMOOTH_STEP,
16
+ PINCH_STEP,
17
+ DOUBLE_CLICK_STEP,
18
+ CHROME_IDLE_MS,
15
19
  ZOOM_PRESETS,
16
20
  DEFAULT_TRANSFORM,
17
21
  } from './constants';
@@ -12,8 +12,8 @@
12
12
  * iframe sources where the embed renders its own UI).
13
13
  */
14
14
 
15
- import { useCallback, useEffect, useMemo, useRef, type CSSProperties } from 'react';
16
- import { MediaController } from 'media-chrome/react';
15
+ import { useEffect, useMemo, useRef, type CSSProperties } from 'react';
16
+ import { MediaController, MediaGestureReceiver } from 'media-chrome/react';
17
17
  import { cn } from '@djangocfg/ui-core/lib';
18
18
  import './styles/video-player.css';
19
19
 
@@ -23,6 +23,7 @@ import { CanvasDispatcher } from './canvas/canvas-dispatcher';
23
23
  import {
24
24
  ControlsBar,
25
25
  PlayButton,
26
+ CenterPlay,
26
27
  SeekBar,
27
28
  Volume,
28
29
  PlaybackRate,
@@ -50,11 +51,20 @@ export function VideoPlayer({
50
51
  [source],
51
52
  );
52
53
 
53
- const isIframe = normalized.type === 'iframe';
54
- const isYouTube = normalized.type === 'youtube';
55
- // For iframe embeds media-chrome cannot drive the inner player hide the
56
- // control bar to avoid a non-functional UI.
57
- const showControls = controls && !isIframe;
54
+ // Provider embeds (YouTube / Vimeo) render their own native control set
55
+ // inside an opaque iframe. media-chrome can request play/pause but its
56
+ // `[mediapaused]` state desyncs from the real player, and YouTube's ToS
57
+ // chrome (title bar, branding tray, share/clock icons) bleeds through and
58
+ // CANNOT be hidden — so layering OUR controls on top produces a duplicate,
59
+ // jittery UI (two play affordances, two bottom bars). For these sources we
60
+ // let the native player own playback entirely: one coherent control set,
61
+ // native hover behaviour, zero duplication. Direct media (native `<video>`,
62
+ // HLS) is fully driven by media-chrome, so we keep OUR themed controls.
63
+ const isProviderEmbed =
64
+ normalized.type === 'iframe' ||
65
+ normalized.type === 'youtube' ||
66
+ normalized.type === 'vimeo';
67
+ const showControls = controls && !isProviderEmbed;
58
68
 
59
69
  // MediaController is a custom element; without `tabindex` it cannot take
60
70
  // focus, so its built-in keyboard shortcuts (space/arrows/f) never fire.
@@ -63,19 +73,6 @@ export function VideoPlayer({
63
73
  // fields we don't need to see.
64
74
  const controllerRef = useRef<HTMLElement | null>(null);
65
75
 
66
- // Click-to-play overlay for YouTube — the underlying YT iframe is made
67
- // `pointer-events: none` via CSS to suppress YouTube's own hover-state /
68
- // UI (title bar, pause-screen, branding tray; ToS forbids hiding them,
69
- // but we can make them inert). This overlay restores click-to-toggle
70
- // by dispatching media-chrome request events the controller listens for.
71
- const onYouTubeShieldClick = useCallback(() => {
72
- const el = controllerRef.current;
73
- if (!el) return;
74
- const paused = el.hasAttribute('mediapaused');
75
- const eventName = paused ? 'mediaplayrequest' : 'mediapauserequest';
76
- el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, composed: true }));
77
- }, []);
78
-
79
76
  useEffect(() => {
80
77
  if (!autoFocus) return;
81
78
  const el = controllerRef.current;
@@ -100,28 +97,25 @@ export function VideoPlayer({
100
97
  style={aspectRatioStyle(aspectRatio)}
101
98
  >
102
99
  <CanvasDispatcher source={normalized} {...settings} />
103
- {isYouTube && (
104
- // Transparent click-shield over the YT iframe. The iframe itself
105
- // is pointer-events:none (see video-player.css); this div absorbs
106
- // pointer events and forwards a play/pause request to media-chrome,
107
- // so users still get click-to-toggle without ever interacting with
108
- // YouTube's own UI.
109
- <div
110
- className="vp-yt-click-shield"
111
- onClick={onYouTubeShieldClick}
112
- aria-hidden="true"
113
- />
114
- )}
100
+ {/* Click / tap anywhere on the video toggles play. media-chrome's
101
+ gesture receiver handles desktop click + mobile tap (and on touch
102
+ a tap also reveals/hides the controls). Only mounted for direct
103
+ media provider embeds own their own click handling natively. */}
104
+ {showControls && <MediaGestureReceiver />}
115
105
  {children ??
116
106
  (showControls && (
117
- <ControlsBar>
118
- <PlayButton />
119
- <SeekBar />
120
- <Volume iconOnly />
121
- <PlaybackRate />
122
- <Pip />
123
- <Fullscreen />
124
- </ControlsBar>
107
+ <>
108
+ {/* Big centered play over the poster / paused frame. */}
109
+ <CenterPlay />
110
+ <ControlsBar>
111
+ <PlayButton />
112
+ <SeekBar />
113
+ <Volume iconOnly />
114
+ <PlaybackRate />
115
+ <Pip />
116
+ <Fullscreen />
117
+ </ControlsBar>
118
+ </>
125
119
  ))}
126
120
  </MediaController>
127
121
  );
@@ -6,16 +6,18 @@
6
6
  * YouTube IFrame API and exposes an `HTMLVideoElement`-shaped surface
7
7
  * that media-chrome's `<MediaController>` drives.
8
8
  *
9
- * `youtube-video-element` already defaults the embed to `controls=0`,
10
- * `rel=0`, `iv_load_policy=3`, `modestbranding=1`. We extend that via
11
- * the `config` prop (serialized into `data-config`, merged into the
12
- * IFrame `playerVars`) to also suppress YouTube's keyboard handling
13
- * (`disablekb=1` our controls own keyboard) and the in-player
14
- * close-captions toggle the element turns on by default.
9
+ * YouTube embeds render their OWN native control set inside the iframe,
10
+ * and YouTube's ToS chrome (title bar, branding tray, share/clock icons)
11
+ * cannot be hidden via player params. Trying to drive the embed with our
12
+ * own media-chrome controls layered on top produced a duplicate, jittery
13
+ * UI (two play buttons, two bottom bars) see VideoPlayer.tsx. So for
14
+ * YouTube we let the native player own playback entirely: `controls=1`
15
+ * gives YouTube's standard hover-to-reveal controls with seek bar, time,
16
+ * volume and fullscreen — one coherent, familiar control set.
15
17
  *
16
- * NOTE: YouTube's ToS forbids fully hiding its branding — a small logo
17
- * and the pause-screen overlay remain by design. `modestbranding=1` is
18
- * the maximum allowed suppression.
18
+ * We still pass `rel=0`, `modestbranding=1`, `iv_load_policy=3` to trim
19
+ * end-screen suggestions, annotations and branding to the maximum the
20
+ * ToS allows.
19
21
  */
20
22
 
21
23
  import 'youtube-video-element';
@@ -33,10 +35,8 @@ export interface YouTubeCanvasProps extends VideoPlayerSettings {
33
35
  * https://developers.google.com/youtube/player_parameters
34
36
  */
35
37
  const YT_PLAYER_VARS = {
36
- // We render our own media-chrome control bar — hide YouTube's.
37
- controls: 0,
38
- // Our keyboard shortcuts own the player; disable YouTube's.
39
- disablekb: 1,
38
+ // YouTube's native controls own playback (no duplicate media-chrome bar).
39
+ controls: 1,
40
40
  // Minimise the YouTube logo chrome (max suppression allowed by ToS).
41
41
  modestbranding: 1,
42
42
  // Do not surface "More videos" / related-channel end-screen suggestions.
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { MediaPlayButton } from 'media-chrome/react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+ import type { ComponentProps } from 'react';
6
+
7
+ export type CenterPlayProps = ComponentProps<typeof MediaPlayButton>;
8
+
9
+ /**
10
+ * Big centered play affordance shown over the poster / paused frame
11
+ * (YouTube / Telegram style). A translucent dark disc with a white glyph so
12
+ * it stays legible on ANY poster — light or dark. It lives in media-chrome's
13
+ * `centered-chrome` slot and is faded out while playing via CSS (see
14
+ * `video-player.css` → `.vp-center-play`).
15
+ */
16
+ export function CenterPlay({ className, ...props }: CenterPlayProps) {
17
+ return (
18
+ <MediaPlayButton
19
+ {...props}
20
+ slot="centered-chrome"
21
+ tabIndex={-1}
22
+ aria-hidden="true"
23
+ className={cn('vp-center-play', className)}
24
+ />
25
+ );
26
+ }
@@ -1,5 +1,7 @@
1
1
  export { PlayButton } from './play-button';
2
2
  export type { PlayButtonProps } from './play-button';
3
+ export { CenterPlay } from './center-play';
4
+ export type { CenterPlayProps } from './center-play';
3
5
  export { SeekBar } from './seek-bar';
4
6
  export type { SeekBarProps } from './seek-bar';
5
7
  export { Volume } from './volume';
@@ -33,7 +33,11 @@ media-controller {
33
33
  --media-range-track-background: color-mix(in oklab, white 28%, transparent);
34
34
  --media-range-track-border-radius: 9999px;
35
35
  --media-range-track-height: 4px;
36
- --media-range-track-pointer-background: color-mix(in oklab, white 40%, transparent);
36
+ /* Pointer-fill (the lit rail from start → cursor on hover). The default
37
+ `white 40%` paints a muddy light bar over a bright video frame; keep
38
+ it barely above the base track so hover reads as a subtle highlight,
39
+ not a slab. */
40
+ --media-range-track-pointer-background: color-mix(in oklab, white 12%, transparent);
37
41
  --media-range-bar-color: var(--primary);
38
42
  --media-range-thumb-background: var(--primary);
39
43
  --media-range-thumb-border-radius: 9999px;
@@ -82,6 +86,57 @@ media-control-bar {
82
86
  background: transparent;
83
87
  }
84
88
 
89
+ /*
90
+ * Big centered play affordance (YouTube / Telegram style). A translucent
91
+ * dark disc + white glyph so it reads on ANY poster, light or dark. Shown
92
+ * only while paused; fades out smoothly once playback starts. The
93
+ * `centered-chrome` slot is centered by media-chrome; we size the disc and
94
+ * scale the icon up via the play-button's `::part(button)`.
95
+ */
96
+ .vp-center-play {
97
+ --media-control-background: rgb(0 0 0 / 0.55);
98
+ --media-control-hover-background: rgb(0 0 0 / 0.7);
99
+ width: 64px;
100
+ height: 64px;
101
+ padding: 0;
102
+ border-radius: 9999px;
103
+ color: rgb(255 255 255);
104
+ backdrop-filter: blur(2px);
105
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.35);
106
+ transition: opacity 0.2s ease, transform 0.2s ease;
107
+ cursor: pointer;
108
+ }
109
+
110
+ .vp-center-play svg {
111
+ width: 28px;
112
+ height: 28px;
113
+ }
114
+
115
+ /* Hide the big play once the video is actually playing. */
116
+ media-controller:not([mediapaused]) .vp-center-play {
117
+ opacity: 0;
118
+ transform: scale(0.9);
119
+ pointer-events: none;
120
+ }
121
+
122
+ /* The gesture receiver catches click/tap anywhere on the video to toggle
123
+ play. It covers the whole frame (`inset: 0`) and must sit BELOW the
124
+ control bar / center play — otherwise it overlays those buttons and
125
+ swallows their clicks (the bug: clicking center-play did nothing while
126
+ the spacebar still worked). z-index:0 keeps it above the canvas but
127
+ under the controls, which we lift to z-index:2 below. */
128
+ media-gesture-receiver {
129
+ position: absolute;
130
+ inset: 0;
131
+ z-index: 0;
132
+ }
133
+
134
+ /* Controls sit ABOVE the gesture receiver so their clicks land on them. */
135
+ .vp-center-play,
136
+ .video-player__control-bar {
137
+ z-index: 2;
138
+ }
139
+
85
140
  /*
86
141
  * Suppress media-chrome's built-in tooltips. They are sticky elements
87
142
  * slotted under each control button's shadow root (exposed via
@@ -106,16 +161,32 @@ media-seek-backward-button::part(tooltip) {
106
161
  }
107
162
 
108
163
  /*
109
- * Auto-hide. media-chrome flags inactivity by setting `userinactive` on
110
- * the <media-controller>; it only does so while playback is running, and
111
- * removes it on mousemove / pause / focus. We fade the whole control bar
112
- * (controls + gradient scrim) together with a smooth opacity transition.
164
+ * Control-bar visibility YouTube-style, exactly one play affordance.
165
+ *
166
+ * - PAUSED (incl. before first play): the big center-play owns the frame;
167
+ * the bottom bar is hidden so there are never TWO play buttons at once.
168
+ * - PLAYING: center-play is gone (faded by the rule above); the bottom bar
169
+ * reveals on hover / interaction and auto-hides after media-chrome flags
170
+ * inactivity (`userinactive`, driven by `autohide` on <media-controller>),
171
+ * so the frame stays clean while watching. It reappears on mousemove /
172
+ * focus.
173
+ *
174
+ * media-chrome sets `userinactive` only while playback runs and removes it
175
+ * on mousemove / pause / focus. We fade the whole bar (controls + gradient
176
+ * scrim) together.
113
177
  */
114
178
  .video-player__control-bar {
115
179
  opacity: 1;
116
180
  transition: opacity 0.3s ease-out;
117
181
  }
118
182
 
183
+ /* Hide the bar while paused — center-play is the sole control then. */
184
+ media-controller[mediapaused] .video-player__control-bar {
185
+ opacity: 0;
186
+ pointer-events: none;
187
+ }
188
+
189
+ /* Auto-hide the bar after inactivity while playing. */
119
190
  media-controller[userinactive]:not([mediapaused]) .video-player__control-bar {
120
191
  opacity: 0;
121
192
  pointer-events: none;
@@ -129,8 +200,10 @@ media-controller[userinactive]:not([mediapaused]) .video-player__control-bar {
129
200
  * dark `rgb(31,31,31)` background — it shows as a stray empty
130
201
  * rectangle floating over the video.
131
202
  *
132
- * Hide the empty preview-box entirely; keep `current-box` and style
133
- * its time label as a compact popover chip.
203
+ * Both the empty `preview-box` and the `current-box` scrub chip are hidden
204
+ * (see the `::part` rules below) — without a thumbnails track neither
205
+ * carries useful content, and the elapsed time already shows in
206
+ * `<MediaTimeDisplay>`.
134
207
  */
135
208
  media-time-range {
136
209
  /* media-chrome's own preview/box vars — point them at our popover
@@ -168,54 +241,30 @@ media-time-range::part(preview-box) {
168
241
  }
169
242
 
170
243
  /*
171
- * YouTube iframe bleed-through suppression.
172
- *
173
- * YouTube's IFrame embed ToS requires showing some chrome that cannot be
174
- * hidden via player params: the title bar at the top, the large pause
175
- * button on the pause screen, and the bottom branding/share tray on
176
- * hover/pause. We can't remove them, but we CAN make them non-interactive
177
- * — so the user never triggers YouTube's hover-state, never gets a
178
- * "More videos" overlay, and clicks never reach the YT iframe.
179
- *
180
- * The <youtube-video> custom element renders an inner <iframe>; disable
181
- * pointer events on it (and on the wrapper itself, just in case the
182
- * element later swaps layout). The companion `.vp-yt-click-shield` div
183
- * inside <media-controller> absorbs clicks and converts them into
184
- * media-chrome play/pause requests — so click-to-toggle still works.
185
- *
186
- * Our control bar lives above the shield and re-enables pointer events
187
- * for the slot, so the controls stay clickable.
244
+ * `current-box` (the scrub-time chip) renders as a small white box ABOVE
245
+ * the rail at the pointer position. Without a thumbnails track it carries
246
+ * no useful preview, and the elapsed time already lives in
247
+ * `<MediaTimeDisplay>` so it only ever shows as a stray white dash over
248
+ * the video. Hide it entirely, same as `preview-box`.
188
249
  */
189
- youtube-video,
190
- youtube-video iframe {
191
- pointer-events: none;
192
- }
193
-
194
- .vp-yt-click-shield {
195
- position: absolute;
196
- inset: 0;
197
- cursor: pointer;
198
- background: transparent;
199
- pointer-events: auto;
200
- /* Sit above the iframe but below the slotted control bar. */
201
- z-index: 1;
250
+ media-time-range::part(current-box) {
251
+ display: none;
202
252
  }
203
253
 
204
- /* media-chrome slots its control surfaces directly under
205
- <media-controller>; raise them above the click-shield and restore
206
- pointer events so buttons remain interactive. */
207
- media-controller > media-control-bar,
208
- media-controller > [slot='centered-chrome'],
209
- media-controller > [slot='top-chrome'] {
210
- z-index: 2;
254
+ /*
255
+ * Provider embeds (YouTube / Vimeo) own their native controls.
256
+ *
257
+ * media-chrome cannot reliably drive an opaque provider iframe, and
258
+ * YouTube's ToS chrome (title bar, branding tray, share/clock icons)
259
+ * bleeds through and can't be hidden — layering OUR controls on top
260
+ * produced a duplicate, jittery UI. So we render NO media-chrome control
261
+ * surface for these sources (see `showControls` in VideoPlayer.tsx) and
262
+ * let the native player handle playback, hover-reveal and fullscreen.
263
+ *
264
+ * The provider iframe must stay fully interactive so its native controls
265
+ * work — so we explicitly DON'T disable pointer events here.
266
+ */
267
+ youtube-video iframe,
268
+ vimeo-video iframe {
211
269
  pointer-events: auto;
212
270
  }
213
-
214
- media-time-range::part(current-box) {
215
- padding: 2px 6px;
216
- border-radius: 6px;
217
- background: var(--popover) !important;
218
- color: var(--popover-foreground);
219
- font-size: 11px;
220
- box-shadow: 0 4px 12px color-mix(in oklab, var(--foreground) 18%, transparent);
221
- }